Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ This is a list of available options:
| `core.autoimport` | `bool` | Import missing keys stored in the pass repository without asking. | `false` |
| `core.autopush` | `bool` | Always do a `git push` after a commit to the store. Makes sure your local changes are always available on your git remote. | `true` |
| `core.autosync` | `bool` | Automatically sync (fetch & push) the git remote on an interval. | `true` |
| `core.casefold` | `bool` | Normalize secret names to lowercase on case-insensitive filesystems (macOS, Windows). Prevents phantom duplicates and silent overwrites when names differ only in case. On case-sensitive filesystems (Linux) this is a no-op. Opt-in because renaming existing secrets may be disruptive. | `false` |
| `core.cliptimeout` | `int` | How many seconds the secret is stored when using `-c`. Setting this to `0` disables auto-clear. | `45` |
| `core.exportkeys` | `bool` | Export public keys of all recipients to the store. | `true` |
| `core.nocolor` | `bool` | Do not use color. | `false` |
Expand Down
3 changes: 3 additions & 0 deletions internal/action/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ age.agent-timeout = 0
core.autoimport = true
core.autopush = true
core.autosync = true
core.casefold = false
core.cliptimeout = 45
core.exportkeys = true
core.follow-references = false
Expand Down Expand Up @@ -87,6 +88,7 @@ age.agent-timeout = 0
core.autoimport = true
core.autopush = true
core.autosync = true
core.casefold = false
core.cliptimeout = 45
core.exportkeys = true
core.follow-references = false
Expand Down Expand Up @@ -125,6 +127,7 @@ age.agent-timeout
core.autoimport
core.autopush
core.autosync
core.casefold
core.cliptimeout
core.exportkeys
core.follow-references
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ var defaults = map[string]string{
"age.agent-timeout": "0",
"core.autopush": "true",
"core.autosync": "true",
"core.casefold": "false",
"core.cliptimeout": "45",
"core.exportkeys": "true",
"core.notifications": "true",
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestConfig(t *testing.T) {
"core.autopush",
"core.autosync",
"core.bool",
"core.casefold",
"core.cliptimeout",
"core.exportkeys",
"core.follow-references",
Expand Down
40 changes: 39 additions & 1 deletion internal/store/leaf/fsck.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ func (s *Store) Fsck(ctx context.Context, path string, progress ctxutil.Progress
out.Errorf(ctx, "Invalid recipients found: %s", err)
}

// check for case-conflicting entries that would collide on
// case-insensitive filesystems.
debug.Log("Checking for case conflicts")
if err := s.fsckCheckCaseConflicts(ctx); err != nil {
out.Warningf(ctx, "Case conflicts detected: %s", err)
}

// then we'll make sure all the secrets are readable by us and every
// valid recipient
if path != "" {
Expand Down Expand Up @@ -327,7 +334,7 @@ func (s *Store) fsckCheckRecipients(ctx context.Context, name string) *fsckMulti

// now compare the recipients this secret was encoded for and fix it if
// it doesn't match.
ciphertext, err := s.storage.Get(ctx, s.Passfile(name))
ciphertext, err := s.storage.Get(ctx, s.passfile(ctx, name))
if err != nil {
return e.Append(errsFatal, fmt.Errorf("failed to get raw secret: %w", err))
}
Expand Down Expand Up @@ -365,6 +372,37 @@ func (s *Store) fsckCheckRecipients(ctx context.Context, name string) *fsckMulti
return e
}

// fsckCheckCaseConflicts lists all secrets in the store and warns if any two
// entries have the same name after lowercasing. Such entries would collide on
// case-insensitive filesystems (macOS, Windows) and can cause data loss.
func (s *Store) fsckCheckCaseConflicts(ctx context.Context) error {
names, err := s.List(ctx, "")
if err != nil {
return fmt.Errorf("failed to list entries: %w", err)
}

seen := make(map[string]string, len(names))
var conflicts []string

for _, name := range names {
lower := strings.ToLower(name)
if prev, ok := seen[lower]; ok {
conflicts = append(conflicts, fmt.Sprintf("%q and %q", prev, name))
} else {
seen[lower] = name
}
}

if len(conflicts) > 0 {
slices.Sort(conflicts)

return fmt.Errorf("case-conflicting entries that would collide on case-insensitive filesystems: %s",
strings.Join(conflicts, ", "))
}

return nil
}

func fingerprints(ctx context.Context, crypto backend.Crypto, in []string) []string {
out := make([]string, 0, len(in))
for _, r := range in {
Expand Down
45 changes: 45 additions & 0 deletions internal/store/leaf/fsck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package leaf
import (
"bytes"
"os"
"runtime"
"testing"

"github.com/gopasspw/gopass/internal/backend/crypto/plain"
"github.com/gopasspw/gopass/internal/backend/storage/fs"
"github.com/gopasspw/gopass/internal/config"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/recipients"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/pkg/gopass/secrets"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -51,6 +53,49 @@ func TestFsck(t *testing.T) {
obuf.Reset()
}

func TestFsckCheckCaseConflicts(t *testing.T) {
t.Parallel()

ctx := config.NewContextInMemory()

tempdir := t.TempDir()

s := &Store{
alias: "",
path: tempdir,
crypto: plain.New(),
storage: fs.New(tempdir),
}

rs := recipients.New()
rs.Add("john.doe")
require.NoError(t, s.saveRecipients(ctx, rs, "test"))

// Set up entries with no case conflicts — should be fine.
for _, e := range []string{"foo/bar", "foo/baz"} {
sec := secrets.NewAKV()
sec.SetPassword("x")
require.NoError(t, s.Set(ctx, e, sec))
}

assert.NoError(t, s.fsckCheckCaseConflicts(ctx),
"no case conflicts expected")

// Now add entries that differ only in case.
for _, e := range []string{"foo/Bar", "Foo/baz"} {
sec := secrets.NewAKV()
sec.SetPassword("x")
if runtime.GOOS == "linux" {
require.NoError(t, s.Set(ctx, e, sec), "Linux should allow case conflicts")
} else {
require.ErrorIs(t, s.Set(ctx, e, sec), store.ErrMeaninglessWrite)
}
}

err := s.fsckCheckCaseConflicts(ctx)
assert.Error(t, err, "case conflicts should be reported")
}

func TestCompareStringSlices(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 2 additions & 2 deletions internal/store/leaf/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ func (s *Store) Link(ctx context.Context, from, to string) error {
return fmt.Errorf("destination %q already exists", to)
}

if err := s.storage.Link(ctx, s.Passfile(from), s.Passfile(to)); err != nil {
if err := s.storage.Link(ctx, s.passfile(ctx, from), s.passfile(ctx, to)); err != nil {
return fmt.Errorf("failed to create symlink from %q to %q: %w", from, to, err)
}

debug.Log("created symlink from %q to %q", from, to)

if err := s.storage.Add(ctx, s.Passfile(to)); err != nil {
if err := s.storage.Add(ctx, s.passfile(ctx, to)); err != nil {
if errors.Is(err, store.ErrGitNotInit) {
return nil
}
Expand Down
6 changes: 3 additions & 3 deletions internal/store/leaf/move.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ func (s *Store) Move(ctx context.Context, from, to string) error {
}

func (s *Store) directMove(ctx context.Context, from, to string, del bool) error {
pFrom := s.Passfile(from)
pTo := s.Passfile(to)
pFrom := s.passfile(ctx, from)
pTo := s.passfile(ctx, to)

// if original destination has trailing slash,
// it means we should create folder and move/copy source file in it
Expand Down Expand Up @@ -150,7 +150,7 @@ func (s *Store) Prune(ctx context.Context, tree string) error {
// delete will either delete one file or an directory tree depending on the
// recurse flag.
func (s *Store) delete(ctx context.Context, name string, recurse bool) error {
path := s.Passfile(name)
path := s.passfile(ctx, name)

if recurse {
if err := s.deleteRecurse(ctx, name, path); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/store/leaf/rcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ func (s *Store) GitInit(ctx context.Context) error {

// ListRevisions will list all revisions for a secret.
func (s *Store) ListRevisions(ctx context.Context, name string) ([]backend.Revision, error) {
p := s.Passfile(name)
p := s.passfile(ctx, name)

return s.storage.Revisions(ctx, p)
}

// GetRevision will retrieve a single revision from the backend.
func (s *Store) GetRevision(ctx context.Context, name, revision string) (gopass.Secret, error) {
p := s.Passfile(name)
p := s.passfile(ctx, name)
ciphertext, err := s.storage.GetRevision(ctx, p, revision)
if err != nil {
return nil, fmt.Errorf("failed to get ciphertext of %q@%q: %w", name, revision, err)
Expand Down
2 changes: 1 addition & 1 deletion internal/store/leaf/read.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

// Get returns the plaintext of a single key.
func (s *Store) Get(ctx context.Context, name string) (gopass.Secret, error) {
p := s.Passfile(name)
p := s.passfile(ctx, name)

ciphertext, err := s.storage.Get(ctx, p)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/store/leaf/reencrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func (s *Store) reencrypt(ctx context.Context) error {
// to avoid a race condition on git .index.lock file, so we do it now.
if conc > 1 {
for _, name := range entries {
p := s.Passfile(name)
p := s.passfile(ctx, name)
if err := s.storage.TryAdd(ctx, p); err != nil {
return fmt.Errorf("failed to add %q to git: %w", p, err)
}
Expand Down
16 changes: 15 additions & 1 deletion internal/store/leaf/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import (
"strings"

"github.com/gopasspw/gopass/internal/backend"
"github.com/gopasspw/gopass/internal/config"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/pkg/debug"
"github.com/gopasspw/gopass/pkg/fsutil"
"github.com/gopasspw/gopass/pkg/set"
)

Expand Down Expand Up @@ -164,7 +166,7 @@ func (s *Store) IsDir(ctx context.Context, name string) bool {

// Exists checks the existence of a single entry.
func (s *Store) Exists(ctx context.Context, name string) bool {
return s.storage.Exists(ctx, s.Passfile(name))
return s.storage.Exists(ctx, s.passfile(ctx, name))
}

func (s *Store) useableKeys(ctx context.Context, name string) ([]string, error) {
Expand Down Expand Up @@ -197,6 +199,18 @@ func (s *Store) Passfile(name string) string {
return strings.TrimPrefix(name+"."+s.crypto.Ext(), "/")
}

// passfile is the context-aware version of Passfile. If core.casefold is
// enabled in the config, the name is normalized via fsutil.NormalizeSecretName
// before constructing the path. On case-sensitive platforms
// NormalizeSecretName is a no-op regardless of the config setting.
func (s *Store) passfile(ctx context.Context, name string) string {
if config.Bool(ctx, "core.casefold") {
name = fsutil.NormalizeSecretName(name)
}

return s.Passfile(name)
}

// String implement fmt.Stringer.
func (s *Store) String() string {
return fmt.Sprintf("Store(Alias: %s, Path: %s)", s.alias, s.path)
Expand Down
35 changes: 35 additions & 0 deletions internal/store/leaf/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,38 @@ func TestNew(t *testing.T) {
})
}
}

// TestPassfileNormalization verifies that passfile() normalizes secret names
// when core.casefold is enabled, and is a no-op when it is disabled.
// On case-sensitive platforms (Linux) NormalizeSecretName is always a no-op,
// so we only verify the config-gating here.
func TestPassfileNormalization(t *testing.T) {
// Cannot be parallel: createSubStore uses t.Setenv.

s, err := createSubStore(t)
require.NoError(t, err)

// Build a context with casefold=false.
cfgOff := config.NewInMemory()
require.NoError(t, cfgOff.Set("", "core.casefold", "false"))
ctxOff := cfgOff.WithConfig(context.Background())
ctxOff, err = backend.WithCryptoBackendString(ctxOff, "plain")
require.NoError(t, err)

// With casefold=false the passfile path must equal the public Passfile path.
assert.Equal(t, s.Passfile("foo/Bar"), s.passfile(ctxOff, "foo/Bar"),
"passfile without casefold must equal Passfile()")

// Build a context with casefold=true.
cfgOn := config.NewInMemory()
require.NoError(t, cfgOn.Set("", "core.casefold", "true"))
ctxOn := cfgOn.WithConfig(context.Background())
ctxOn, err = backend.WithCryptoBackendString(ctxOn, "plain")
require.NoError(t, err)

// With casefold=true the result must be deterministic.
p1 := s.passfile(ctxOn, "foo/Bar")
p2 := s.passfile(ctxOn, "foo/Bar")
assert.Equal(t, p1, p2, "passfile must be deterministic")
assert.NotEmpty(t, p1)
}
2 changes: 1 addition & 1 deletion internal/store/leaf/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (s *Store) Set(ctx context.Context, name string, sec gopass.Byter) error {
return fmt.Errorf("writing to %s is disabled by `core.readonly`", s.alias)
}

p := s.Passfile(name)
p := s.passfile(ctx, name)

recipients, err := s.useableKeys(ctx, name)
if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions pkg/fsutil/case_insensitive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build darwin || windows

package fsutil

import "strings"

// NormalizeSecretName returns a canonical lowercase version of the secret
// name. On case-insensitive filesystems (macOS and Windows) secret names that
// differ only in case refer to the same underlying file, so we normalize to
// lowercase to avoid phantom duplicates and silent overwrites.
func NormalizeSecretName(name string) string {
return strings.ToLower(name)
}
9 changes: 9 additions & 0 deletions pkg/fsutil/case_sensitive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin && !windows

package fsutil

// NormalizeSecretName returns the secret name unchanged.
// On case-sensitive filesystems (Linux and others) no normalization is needed.
func NormalizeSecretName(name string) string {
return name
}
Loading
Loading