diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go
index 96f4ab0..ecc8f76 100644
--- a/cmd/incredigo/main.go
+++ b/cmd/incredigo/main.go
@@ -43,6 +43,18 @@ var (
)
func main() {
+ if err := newRootCmd().Execute(); err != nil {
+ fmt.Fprintln(os.Stderr, "incredigo:", err)
+ os.Exit(1)
+ }
+}
+
+// newRootCmd builds the fully-wired root command. Extracted from main so tests can
+// drive the real CLI (persistent flags + every subcommand) with SetArgs/Execute.
+// Because the flag vars are package-level and pflag writes each default into its
+// bound variable at registration, constructing a fresh root per call also resets
+// flag state between test invocations.
+func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "incredigo",
Short: "Local-first, encrypted, RAM-only credential custody",
@@ -55,11 +67,7 @@ func main() {
root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)")
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd(), passwordsCmd())
-
- if err := root.Execute(); err != nil {
- fmt.Fprintln(os.Stderr, "incredigo:", err)
- os.Exit(1)
- }
+ return root
}
// applyPaths registers any --path targets with the file scanner and, when an
diff --git a/cmd/incredigo/main_test.go b/cmd/incredigo/main_test.go
new file mode 100644
index 0000000..3602838
--- /dev/null
+++ b/cmd/incredigo/main_test.go
@@ -0,0 +1,328 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "incredigo/internal/discover"
+)
+
+// runCLI drives the real root command with args, capturing os.Stdout/os.Stderr.
+// It swaps the process stdio around Execute so the CLI's direct fmt.Fprintln(os.Stdout,…)
+// writes are captured; goroutines drain the pipes to avoid blocking on large output.
+func runCLI(t *testing.T, args ...string) (stdout, stderr string, err error) {
+ t.Helper()
+
+ outR, outW, perr := os.Pipe()
+ if perr != nil {
+ t.Fatal(perr)
+ }
+ errR, errW, perr := os.Pipe()
+ if perr != nil {
+ t.Fatal(perr)
+ }
+ origOut, origErr := os.Stdout, os.Stderr
+ os.Stdout, os.Stderr = outW, errW
+
+ var ob, eb bytes.Buffer
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() { defer wg.Done(); io.Copy(&ob, outR) }()
+ go func() { defer wg.Done(); io.Copy(&eb, errR) }()
+
+ // The file scanner's --path targets are process-global; reset so a prior test's
+ // (now-deleted) fixture path can't leak into this invocation.
+ discover.ClearPaths()
+ root := newRootCmd()
+ root.SetArgs(args)
+ err = root.Execute()
+
+ os.Stdout, os.Stderr = origOut, origErr
+ outW.Close()
+ errW.Close()
+ wg.Wait()
+ outR.Close()
+ errR.Close()
+ return ob.String(), eb.String(), err
+}
+
+// fakeGopassOnPath installs a stub `gopass` as the first entry on PATH and points it
+// at a fresh file-backed store, so CLI commands that build sink.Gopass{} (using the
+// default "gopass" binary) never touch the user's real store. failShow makes `gopass
+// show` exit non-zero, used to force a backup-gate failure. Returns the store dir.
+func fakeGopassOnPath(t *testing.T, failShow bool, seed map[string]string) string {
+ t.Helper()
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "gopass")
+ showBody := `cat "$store/$3"`
+ if failShow {
+ showBody = `echo "show failed" >&2; exit 1`
+ }
+ script := `#!/usr/bin/env bash
+store="$FAKE_GOPASS_STORE"
+case "$1" in
+ ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
+ show) ` + showBody + ` ;;
+ insert)
+ shift; path=""
+ for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
+ mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
+esac
+`
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ store := t.TempDir()
+ for p, s := range seed {
+ full := filepath.Join(store, p)
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("FAKE_GOPASS_STORE", store)
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ return store
+}
+
+// fixtureDir writes files as credential material and returns the dir, for discovery
+// via `--path
--source file`. Isolating discovery to the file scanner keeps the
+// host's real aws/env/ssh secrets out of the test.
+func fixtureDir(t *testing.T, files map[string]string) string {
+ t.Helper()
+ dir := t.TempDir()
+ for name, body := range files {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return dir
+}
+
+// isolateHome points HOME at a throwaway dir so no host dotfiles are ever scanned or
+// written, and the rotate default backup dir (~/.incredigo) lands under the temp dir.
+func isolateHome(t *testing.T) { t.Setenv("HOME", t.TempDir()) }
+
+func TestScanFindsFixtureViaPath(t *testing.T) {
+ isolateHome(t)
+ dir := fixtureDir(t, map[string]string{"deploy.key": "unique-scan-secret-value"})
+ out, serr, err := runCLI(t, "scan", "--source", "file", "--path", dir)
+ if err != nil {
+ t.Fatalf("scan: %v (stderr=%s)", err, serr)
+ }
+ if !strings.Contains(out, "deploy.key") {
+ t.Errorf("scan output missing fixture identity:\n%s", out)
+ }
+ if strings.Contains(out, "unique-scan-secret-value") {
+ t.Errorf("scan leaked the secret value:\n%s", out)
+ }
+ if !strings.Contains(serr, "credential(s) found") {
+ t.Errorf("scan summary missing:\n%s", serr)
+ }
+}
+
+func TestStatusHappyPath(t *testing.T) {
+ isolateHome(t)
+ dir := fixtureDir(t, map[string]string{"token.txt": "status-secret"})
+ out, _, err := runCLI(t, "status", "--source", "file", "--path", dir)
+ if err != nil {
+ t.Fatalf("status: %v", err)
+ }
+ if !strings.Contains(out, "STATE") || !strings.Contains(out, "token.txt") {
+ t.Errorf("status output unexpected:\n%s", out)
+ }
+}
+
+func TestWorklistHappyPath(t *testing.T) {
+ isolateHome(t)
+ dir := fixtureDir(t, map[string]string{"aws.creds": "worklist-secret"})
+ out, _, err := runCLI(t, "worklist", "--source", "file", "--path", dir)
+ if err != nil {
+ t.Fatalf("worklist: %v", err)
+ }
+ if strings.Contains(out, "worklist-secret") {
+ t.Errorf("worklist leaked a secret:\n%s", out)
+ }
+ if !strings.Contains(strings.ToLower(out), "rotat") {
+ t.Errorf("worklist output does not look like a worklist:\n%s", out)
+ }
+}
+
+func TestMigrateDedupeAndForce(t *testing.T) {
+ isolateHome(t)
+ store := fakeGopassOnPath(t, false, nil)
+ dir := fixtureDir(t, map[string]string{"svc.token": "migrate-secret"})
+
+ // First migrate: stores the credential.
+ out, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/")
+ if err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if !strings.Contains(out, "store imported/file/") {
+ t.Errorf("first migrate did not store:\n%s", out)
+ }
+ // The secret must be in the store (streamed via stdin, never argv).
+ got := readAnyUnder(t, filepath.Join(store, "imported/file"))
+ if got != "migrate-secret" {
+ t.Errorf("stored value = %q, want migrate-secret", got)
+ }
+
+ // Second migrate with --dedupe: the same entry is skipped, not overwritten.
+ out2, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/", "--dedupe")
+ if err != nil {
+ t.Fatalf("migrate --dedupe: %v", err)
+ }
+ if !strings.Contains(out2, "(exists)") {
+ t.Errorf("--dedupe did not skip an existing entry:\n%s", out2)
+ }
+}
+
+func TestExportImportRoundTripCLI(t *testing.T) {
+ isolateHome(t)
+ t.Setenv("INCREDIGO_PASSPHRASE", "round-trip-pass")
+ seed := map[string]string{
+ "imported/aws/default": "AKIA/roundtrip+secret",
+ "imported/env/api": "tok-末-value",
+ }
+ store := fakeGopassOnPath(t, false, seed)
+ bundle := filepath.Join(t.TempDir(), "backup.age")
+
+ // export from store A
+ _, serr, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
+ if err != nil {
+ t.Fatalf("export: %v (stderr=%s)", err, serr)
+ }
+ if fi, e := os.Stat(bundle); e != nil || fi.Size() == 0 {
+ t.Fatalf("bundle missing/empty: %v", e)
+ }
+ if !strings.Contains(serr, "sealed 2") {
+ t.Errorf("export summary unexpected:\n%s", serr)
+ }
+ // The bundle must not contain the plaintext secret.
+ if b, _ := os.ReadFile(bundle); bytes.Contains(b, []byte("AKIA/roundtrip+secret")) {
+ t.Fatal("sealed bundle leaked plaintext")
+ }
+
+ // import into a fresh empty store B
+ storeB := t.TempDir()
+ t.Setenv("FAKE_GOPASS_STORE", storeB)
+ _, serr, err = runCLI(t, "import", "--in", bundle, "--force")
+ if err != nil {
+ t.Fatalf("import: %v (stderr=%s)", err, serr)
+ }
+ for path, want := range seed {
+ got, e := os.ReadFile(filepath.Join(storeB, path))
+ if e != nil {
+ t.Fatalf("restored %s missing: %v", path, e)
+ }
+ if string(got) != want {
+ t.Errorf("%s restored %q, want %q", path, got, want)
+ }
+ }
+ _ = store
+}
+
+func TestExportRefusesToOverwrite(t *testing.T) {
+ isolateHome(t)
+ t.Setenv("INCREDIGO_PASSPHRASE", "pw")
+ fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
+ bundle := filepath.Join(t.TempDir(), "exists.age")
+ if err := os.WriteFile(bundle, []byte("preexisting"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/"); err == nil {
+ t.Fatal("export overwrote an existing bundle")
+ }
+}
+
+// --- Gate refusals (the safety-critical CLI contracts) ---
+
+func TestRotateExecuteRefusedWithoutEnv(t *testing.T) {
+ isolateHome(t)
+ os.Unsetenv("INCREDIGO_ALLOW_EXECUTE")
+ fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
+ _, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
+ if err == nil {
+ t.Fatal("rotate --execute ran without INCREDIGO_ALLOW_EXECUTE=1")
+ }
+ if !strings.Contains(err.Error(), "refused") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestExportMissingPassphrase(t *testing.T) {
+ isolateHome(t)
+ t.Setenv("INCREDIGO_PASSPHRASE", "") // set-but-empty is an explicit error
+ fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
+ bundle := filepath.Join(t.TempDir(), "b.age")
+ _, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
+ if err == nil {
+ t.Fatal("export proceeded with an empty passphrase")
+ }
+ if !strings.Contains(err.Error(), "PASSPHRASE") {
+ t.Errorf("unexpected error: %v", err)
+ }
+ // export opens the output file before reading the passphrase, so an empty file
+ // may remain — but it must never contain sealed (or any) content.
+ if fi, e := os.Stat(bundle); e == nil && fi.Size() != 0 {
+ t.Errorf("bundle has %d bytes despite the passphrase error", fi.Size())
+ }
+}
+
+// TestRotateExecuteBackupGateAborts proves the backup gate blocks execute BEFORE any
+// driver runs: with authorization present but `gopass show` failing, Snapshot fails
+// and the command aborts with a backup-gate error — no rotation is attempted.
+func TestRotateExecuteBackupGateAborts(t *testing.T) {
+ isolateHome(t)
+ t.Setenv("INCREDIGO_ALLOW_EXECUTE", "1")
+ t.Setenv("INCREDIGO_PASSPHRASE", "pw")
+ // ls returns a path so ExportTo tries to read it, but show fails → Snapshot fails.
+ fakeGopassOnPath(t, true, map[string]string{"imported/env/api": "v"})
+ out, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
+ if err == nil {
+ t.Fatal("rotate --execute proceeded despite a failing backup gate")
+ }
+ if !strings.Contains(err.Error(), "backup gate failed") {
+ t.Errorf("expected a backup-gate error, got: %v", err)
+ }
+ // The rotation results table must never have been printed.
+ if strings.Contains(out, "ROTATED") || strings.Contains(out, "DRIVER") {
+ t.Errorf("rotation ran despite the backup gate failing:\n%s", out)
+ }
+}
+
+func TestUnknownSealerRejected(t *testing.T) {
+ isolateHome(t)
+ t.Setenv("INCREDIGO_PASSPHRASE", "pw")
+ fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
+ bundle := filepath.Join(t.TempDir(), "b.age")
+ _, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/", "--sealer", "rot13")
+ if err == nil || !strings.Contains(err.Error(), "unknown sealer") {
+ t.Fatalf("expected unknown-sealer error, got: %v", err)
+ }
+}
+
+// readAnyUnder returns the contents of the first regular file under root (tests seed
+// exactly one), or fails.
+func readAnyUnder(t *testing.T, root string) string {
+ t.Helper()
+ var found string
+ filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
+ if err == nil && !fi.IsDir() && found == "" {
+ b, _ := os.ReadFile(p)
+ found = string(b)
+ }
+ return nil
+ })
+ if found == "" {
+ t.Fatalf("no file found under %s", root)
+ }
+ return found
+}
diff --git a/internal/discover/file.go b/internal/discover/file.go
index c79c0a5..924b078 100644
--- a/internal/discover/file.go
+++ b/internal/discover/file.go
@@ -29,6 +29,12 @@ func AddPath(p string) {
}
}
+// ClearPaths drops all registered --path targets. The CLI is one-shot so it never
+// needs this, but a long-lived process (notably a test binary invoking the CLI many
+// times) must reset between runs or the singleton accumulates stale, since-removed
+// paths and the scanner errors on the first vanished target.
+func ClearPaths() { fileSingleton.targets = nil }
+
// fileScanner harvests whole files as credentials from user-supplied paths. Unlike
// the format-specific scanners (aws, env, …), it does no field parsing: each
// regular, non-empty, reasonably-sized file becomes ONE Credential whose secret is