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:
@@ -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
@@ -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, "/")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user