google-labs-jules[bot] 65b7b1cb95
Fix(store): Honor nested .gpg-id files on key cleanup (#3207)
When cleaning up the public keys in the .public-keys directory, we were
previously only considering the top-level .gpg-id file. This could lead
to the removal of public keys that were still in use in sub-stores.

This commit fixes this by changing the `idFiles` function to correctly
find all .gpg-id files in the store, including nested ones.

It also introduces a new `AllRecipients` function that gathers all
recipients from the main store and all sub-stores by using the corrected
`idFiles` logic.

The `UpdateExportedPublicKeys` function is refactored to use
`AllRecipients` to get the complete list of recipients, removing the need
to pass them as an argument. All call sites of `UpdateExportedPublicKeys`
are updated accordingly.

Finally, the logic for finding extra keys to remove is extracted into a
standalone `extraKeys` function, and a new unit test `TestExtraKeys` is
added to verify its behavior.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-13 20:30:33 +02:00

295 lines
7.2 KiB
Go

package action
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"time"
"github.com/fatih/color"
"github.com/gopasspw/gopass/internal/backend"
"github.com/gopasspw/gopass/internal/config"
"github.com/gopasspw/gopass/internal/diff"
"github.com/gopasspw/gopass/internal/notify"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/internal/store/leaf"
"github.com/gopasspw/gopass/internal/tree"
"github.com/gopasspw/gopass/pkg/ctxutil"
"github.com/gopasspw/gopass/pkg/debug"
"github.com/urfave/cli/v2"
"github.com/xhit/go-str2duration/v2"
)
var (
autosyncInterval = time.Duration(3*24) * time.Hour
autosyncLastRun time.Time
)
func init() {
sv := os.Getenv("GOPASS_AUTOSYNC_INTERVAL")
if sv == "" {
return
}
debug.Log("GOPASS_AUTOSYNC_INTERVAL is deprecated. Please use autosync.interval")
iv, err := strconv.Atoi(sv)
if err != nil {
return
}
autosyncInterval = time.Duration(iv*24) * time.Hour
}
// Sync all stores with their remotes.
func (s *Action) Sync(c *cli.Context) error {
return s.sync(ctxutil.WithGlobalFlags(c), c.String("store"), false)
}
func (s *Action) autoSync(ctx context.Context) error {
if !ctxutil.IsInteractive(ctx) {
return nil
}
if !ctxutil.IsTerminal(ctx) {
return nil
}
if sv := os.Getenv("GOPASS_NO_AUTOSYNC"); sv != "" {
out.Warning(ctx, "GOPASS_NO_AUTOSYNC is deprecated. Please set core.autosync = false.")
return nil
}
if !config.Bool(ctx, "core.autosync") {
return nil
}
ls := s.rem.LastSeen("autosync")
debug.Log("autosync - last seen: %s", ls)
syncInterval := autosyncInterval
if intervalStr := s.cfg.Get("autosync.interval"); intervalStr != "" {
if _, err := strconv.Atoi(intervalStr); err == nil {
intervalStr += "d"
}
if duration, err := str2duration.ParseDuration(intervalStr); err != nil {
out.Warningf(ctx, "failed to parse autosync.interval %q: %q", intervalStr, err)
} else {
syncInterval = duration
}
}
debug.Log("autosync - interval: %s", syncInterval)
if time.Since(ls) > syncInterval {
err := s.sync(ctx, "", true)
if err != nil {
autosyncLastRun = time.Now()
}
return err
}
return nil
}
func (s *Action) sync(ctx context.Context, store string, isAutosync bool) error {
// we just did a full sync, no need to run it again
if time.Since(autosyncLastRun) < 10*time.Second {
debug.Log("skipping sync. last sync %ds ago", time.Since(autosyncLastRun))
return nil
}
// check if user asked for single store/remote sync or all remote sync
if store == "" {
out.Printf(ctx, "🚥 Syncing with all remotes ...")
}
numEntries := 0
if l, err := s.Store.Tree(ctx); err == nil {
numEntries = len(l.List(tree.INF))
}
numMPs := 0
mps := s.Store.MountPoints()
mps = append([]string{""}, mps...)
// sync all stores (root and all mounted sub stores).
for _, mp := range mps {
if store != "" {
out.Printf(ctx, "🚥 Syncing with store/remote %q...", store)
if store != "<root>" && mp != store {
continue
}
if store == "<root>" && mp != "" {
continue
}
}
numMPs++
_ = s.syncMount(ctx, mp, isAutosync)
}
if numMPs > 0 {
out.OKf(ctx, "All done")
} else {
out.Printf(ctx, "⚠️ No remotes were found")
}
// If we just sync'ed all stores we can reset the auto-sync interval
if store == "" {
_ = s.rem.Reset("autosync")
}
// Calculate number of changed entries.
// This is a rough estimate as additions and deletions.
// might cancel each other out.
if l, err := s.Store.Tree(ctx); err == nil {
numEntries = len(l.List(tree.INF)) - numEntries
}
diff := ""
if numEntries > 0 {
diff = fmt.Sprintf(" Added %d entries", numEntries)
} else if numEntries < 0 {
diff = fmt.Sprintf(" Removed %d entries", -1*numEntries)
}
if numEntries != 0 {
ctx = config.WithMount(ctx, store)
_ = notify.Notify(ctx, "gopass - sync", fmt.Sprintf("Finished. Synced %d remotes.%s", numMPs, diff))
}
return nil
}
// syncMount syncs a single mount.
func (s *Action) syncMount(ctx context.Context, mp string, isAutosync bool) error {
if isAutosync {
// using GetM here to get the value for this mount, it might be different
// from the global value
if as := s.cfg.GetM(mp, "core.autosync"); as == "false" {
debug.Log("not syncing %s, autosync is disabled for this mount", mp)
return nil
}
}
ctxno := out.WithNewline(ctx, false)
name := mp
if mp == "" {
name = "<root>"
}
out.Printf(ctxno, color.GreenString("[%s] ", name))
sub, err := s.Store.GetSubStore(mp)
if err != nil {
out.Errorf(ctx, "Failed to get sub store %q: %s", name, err)
return fmt.Errorf("failed to get sub stores (%w)", err)
}
if sub == nil {
out.Errorf(ctx, "Failed to get sub stores '%s: nil'", name)
return fmt.Errorf("failed to get sub stores (nil)")
}
l, err := sub.List(ctx, "")
if err != nil {
out.Errorf(ctx, "Failed to list store: %s", err)
}
out.Printf(ctxno, "\n "+color.GreenString("%s pull and push ... ", sub.Storage().Name()))
switch err := sub.Storage().Push(ctx, "", ""); {
case err == nil:
debug.Log("Push succeeded")
out.Printf(ctxno, color.GreenString("OK"))
case errors.Is(err, store.ErrGitNoRemote):
out.Printf(ctx, "Skipped (no remote)")
debug.Log("Failed to push %q to its remote: %s", name, err)
return err
case errors.Is(err, backend.ErrNotSupported):
out.Printf(ctxno, "Skipped (not supported)")
case errors.Is(err, store.ErrGitNotInit):
out.Printf(ctxno, "Skipped (no Git repo)")
default: // any other error
out.Errorf(ctx, "Failed to push %q to its remote: %s", name, err)
return err
}
ln, err := sub.List(ctx, "")
if err != nil {
out.Errorf(ctx, "Failed to list store: %s", err)
}
syncPrintDiff(ctxno, l, ln)
exportKeys := config.AsBool(s.cfg.GetM(mp, "core.exportkeys"))
debug.Log("Syncing Mount %s. Exportkeys: %t", mp, exportKeys)
if err := syncImportKeys(ctxno, sub, name); err != nil {
return err
}
if exportKeys {
if err := syncExportKeys(ctxno, sub, name); err != nil {
return err
}
}
out.Printf(ctx, "\n "+color.GreenString("done"))
return nil
}
func syncImportKeys(ctx context.Context, sub *leaf.Store, name string) error {
// import keys.
if err := sub.ImportMissingPublicKeys(ctx); err != nil {
out.Errorf(ctx, "Failed to import missing public keys for %q: %s", name, err)
return err
}
return nil
}
func syncExportKeys(ctx context.Context, sub *leaf.Store, name string) error {
// export keys.
exported, err := sub.UpdateExportedPublicKeys(ctx)
if err != nil {
out.Errorf(ctx, "Failed to export missing public keys for %q: %s", name, err)
return err
}
// only run second push if we did export any keys.
if !exported {
return nil
}
if err := sub.Storage().Push(ctx, "", ""); err != nil {
out.Errorf(ctx, "Failed to push %q to its remote: %s", name, err)
return err
}
return nil
}
func syncPrintDiff(ctxno context.Context, l, r []string) {
added, removed := diff.Stat(l, r)
debug.Log("diff - added: %d - removed: %d", added, removed)
if added > 0 {
out.Printf(ctxno, color.GreenString(" (Added %d entries)", added))
}
if removed > 0 {
out.Printf(ctxno, color.GreenString(" (Removed %d entries)", removed))
}
if added < 1 && removed < 1 {
out.Printf(ctxno, color.GreenString(" (no changes)"))
}
}