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"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"incredigo/internal/discover"
|
"incredigo/internal/discover"
|
||||||
@@ -106,3 +107,31 @@ func TestStorePathNoDoubleSegment(t *testing.T) {
|
|||||||
t.Errorf("StorePath = %q, want imported/file/keys/deploy", got)
|
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))
|
return path.Join(prefix, c.Source, slug(c.Identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert writes one credential's secret into gopass. The plaintext is taken from
|
// Insert writes one credential's secret into gopass at its StorePath. The
|
||||||
// the locked vault buffer and piped directly to gopass stdin; we never build a
|
// plaintext is taken from the locked vault buffer and piped directly to gopass
|
||||||
// plaintext string or temp file. `-f` forces overwrite; caller decides dedupe.
|
// 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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"insert", "--multiline=false"}
|
args := []string{"insert", "--multiline=false"}
|
||||||
if force {
|
if force {
|
||||||
args = append(args, "-f")
|
args = append(args, "-f")
|
||||||
}
|
}
|
||||||
args = append(args, g.StorePath(c))
|
args = append(args, storePath)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, g.bin(), args...)
|
cmd := exec.CommandContext(ctx, g.bin(), args...)
|
||||||
// buf.Bytes() is the locked memory; gopass reads it from the pipe and GPG-
|
// 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
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
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
|
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).
|
// Exists reports whether an entry already exists (for --dedupe).
|
||||||
func (g *Gopass) Exists(ctx context.Context, storePath string) bool {
|
func (g *Gopass) Exists(ctx context.Context, storePath string) bool {
|
||||||
cmd := exec.CommandContext(ctx, g.bin(), "ls", "--flat")
|
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 {
|
func slug(s string) string {
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
repl := strings.NewReplacer(" / ", "/", " ", "-", "*", "")
|
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