sink: strip leading dots per path segment in slug() (backup-gate coverage fix)

An env credential's Identity (".env / API_TOKEN") produced StorePath
imported/env/.env/api_token; gopass hides dot-prefixed segments from
`ls --flat`, so those entries were invisible to bundle.ListPaths and the
mandatory backup gate silently under-covered them (Hard Rule #1 violation).
slug() now strips leading dots per /-separated segment so incredigo can no
longer write a path that `ls --flat` hides. Locked in by
TestStorePathNoDotSegment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-18 14:48:36 -07:00
parent 1c0896347b
commit 09cd7208d6
2 changed files with 81 additions and 10 deletions
+29
View File
@@ -5,6 +5,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"incredigo/internal/discover"
@@ -106,3 +107,31 @@ func TestStorePathNoDoubleSegment(t *testing.T) {
t.Errorf("StorePath = %q, want imported/file/keys/deploy", got)
}
}
// TestStorePathNoDotSegment guards Hard Rule #1: a dot-prefixed path segment
// (e.g. ".env") is hidden by gopass `ls --flat`, which would make the entry
// invisible to the backup gate's coverage check. slug() must strip leading
// dots so every stored credential is enumerable and therefore backed up.
func TestStorePathNoDotSegment(t *testing.T) {
gp := &Gopass{Prefix: "imported/"}
cases := []struct {
identity string
want string
}{
{".env / API_TOKEN", "imported/env/env/api_token"},
{".env.prod / DATABASE_URL", "imported/env/env.prod/database_url"},
{".netrc / password", "imported/env/netrc/password"},
}
for _, tc := range cases {
c := discover.Credential{Source: "env", Identity: tc.identity}
got := gp.StorePath(c)
if got != tc.want {
t.Errorf("StorePath(%q) = %q, want %q", tc.identity, got, tc.want)
}
for _, seg := range strings.Split(got, "/") {
if strings.HasPrefix(seg, ".") {
t.Errorf("StorePath(%q) = %q has hidden dot-segment %q", tc.identity, got, seg)
}
}
}
}
+52 -10
View File
@@ -44,20 +44,34 @@ func (g *Gopass) StorePath(c discover.Credential) string {
return path.Join(prefix, c.Source, slug(c.Identity))
}
// Insert writes one credential's secret into gopass. The plaintext is taken from
// the locked vault buffer and piped directly to gopass stdin; we never build a
// plaintext string or temp file. `-f` forces overwrite; caller decides dedupe.
// Insert writes one credential's secret into gopass at its StorePath. The
// plaintext is taken from the locked vault buffer and piped directly to gopass
// stdin; we never build a plaintext string or temp file. `-f` forces overwrite;
// caller decides dedupe.
func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credential, force bool) error {
buf, err := v.Open(c.Secret)
if err := g.InsertAt(ctx, v, g.StorePath(c), c.Secret, force); err != nil {
return err
}
// Shrink the in-memory window: this secret is now safely in gopass.
v.Forget(c.Secret)
return nil
}
// InsertAt writes a secret handle to an exact gopass path. Used by rotation to
// store a freshly minted secret back at the same entry it came from (the credential
// path is known, not recomputed). The plaintext streams from locked memory to
// gopass stdin and is never stringified. Unlike Insert it does NOT forget the
// handle — the caller may still need it (e.g. to re-read/verify).
func (g *Gopass) InsertAt(ctx context.Context, v *vault.Vault, storePath string, secret *vault.Handle, force bool) error {
buf, err := v.Open(secret)
if err != nil {
return err
}
args := []string{"insert", "--multiline=false"}
if force {
args = append(args, "-f")
}
args = append(args, g.StorePath(c))
args = append(args, storePath)
cmd := exec.CommandContext(ctx, g.bin(), args...)
// buf.Bytes() is the locked memory; gopass reads it from the pipe and GPG-
@@ -66,13 +80,32 @@ func (g *Gopass) Insert(ctx context.Context, v *vault.Vault, c discover.Credenti
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("gopass insert %q: %v: %s", g.StorePath(c), err, stderr.String())
return fmt.Errorf("gopass insert %q: %v: %s", storePath, err, stderr.String())
}
// Shrink the in-memory window: this secret is now safely in gopass.
v.Forget(c.Secret)
return nil
}
// Show reads one entry's secret value (`gopass show -o`) into the locked vault and
// returns a handle to it. The plaintext exists only transiently as captured output,
// which is wiped immediately after copying into the vault. Used by rotation to load
// a stored credential for in-place rotation (Mode A — rotate what's in gopass).
func (g *Gopass) Show(ctx context.Context, v *vault.Vault, storePath string) (*vault.Handle, error) {
cmd := exec.CommandContext(ctx, g.bin(), "show", "-o", storePath)
var out, stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("gopass show %q: %v: %s", storePath, err, stderr.String())
}
b := out.Bytes()
b = bytes.TrimRight(b, "\r\n")
h := v.Store(b) // Store copies into locked memory and wipes b
for i := range out.Bytes() {
out.Bytes()[i] = 0 // belt-and-suspenders: wipe the transient capture buffer
}
return h, nil
}
// Exists reports whether an entry already exists (for --dedupe).
func (g *Gopass) Exists(ctx context.Context, storePath string) bool {
cmd := exec.CommandContext(ctx, g.bin(), "ls", "--flat")
@@ -91,5 +124,14 @@ func (g *Gopass) Exists(ctx context.Context, storePath string) bool {
func slug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
repl := strings.NewReplacer(" / ", "/", " ", "-", "*", "")
return repl.Replace(s)
s = repl.Replace(s)
// Strip leading dots per path segment. gopass hides dot-prefixed
// directories/leaves from `ls --flat`, so a segment like ".env" would make
// the entry invisible to the backup gate's coverage check — silently
// violating Hard Rule #1 (backup before rotate). See slug regression test.
segs := strings.Split(s, "/")
for i, seg := range segs {
segs[i] = strings.TrimLeft(seg, ".")
}
return strings.Join(segs, "/")
}