From b9425c9ae2aec3ae5c993f33a331cedfe50a3cbe Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 21 Sep 2026 13:26:01 +0000 Subject: [PATCH] Ignore hidden entries during secrets bootstrap --- docs/config.md | 11 +++++++---- internal/engine/secrets.go | 7 ++++++- internal/engine/secrets_test.go | 35 +++++++++++++++++++++++++++++++++ secrets.go | 1 + 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/config.md b/docs/config.md index 9d18a64..7a2d6ae 100644 --- a/docs/config.md +++ b/docs/config.md @@ -293,10 +293,13 @@ operation once before starting concurrent work. The selected path may itself be symlink; ConfigKit resolves it to an existing canonical directory before examining its immediate entries. -The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every -entry must have an ASCII environment-variable name and be a regular, nonsymlink -file. Metadata for all entries is validated before file content is read or process -state is changed. Existing environment variables win, including variables whose +The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries. +Entries whose names begin with `.` (files, directories, or symlinks, such as `.git` +and `.gitignore`) are ignored before name or metadata validation, are never opened, +and are omitted from the report. Remaining entries are sorted by filename; each +must have an ASCII environment-variable name and be a regular, nonsymlink +file. Metadata for all remaining entries is validated before file content is read +or process state is changed. Existing environment variables win, including variables whose value is empty; their files are not opened. Each eligible file is limited to 1 MiB, and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and diff --git a/internal/engine/secrets.go b/internal/engine/secrets.go index effb3ec..9741299 100644 --- a/internal/engine/secrets.go +++ b/internal/engine/secrets.go @@ -130,6 +130,7 @@ type stagedSecret struct { // LoadSecretsDir explicitly loads immediate regular files from directory into // previously unset process environment variables. Applications should call it // once during startup, before concurrent work; Load never calls it. +// Dot-prefixed entries are ignored and omitted from the report. func LoadSecretsDir(directory string) (*SecretsReport, error) { return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{ openDirectory: os.Open, @@ -168,13 +169,17 @@ func loadSecretsDir(directory string, environment secretsEnvironment, access sec if closeErr != nil { return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical)) } + entryLimitExceeded := len(entries) > secretsEntryLimit + entries = slices.DeleteFunc(entries, func(entry fs.DirEntry) bool { + return strings.HasPrefix(entry.Name(), ".") + }) slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) }) report.entries = make([]SecretFileResult, len(entries)) for index, entry := range entries { name := entry.Name() report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied} } - if len(entries) > secretsEntryLimit { + if entryLimitExceeded { return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical)) } diff --git a/internal/engine/secrets_test.go b/internal/engine/secrets_test.go index 9b9a3b4..9177f30 100644 --- a/internal/engine/secrets_test.go +++ b/internal/engine/secrets_test.go @@ -64,6 +64,41 @@ func TestLoadSecretsDirLoadsCanonicalSortedValues(t *testing.T) { } } +func TestLoadSecretsDirIgnoresDotEntries(t *testing.T) { + directory := t.TempDir() + if err := os.Mkdir(filepath.Join(directory, ".git"), 0o700); err != nil { + t.Fatal(err) + } + writeSecretTestFile(t, directory, ".gitignore", "CANARY\x00") + writeSecretTestFile(t, directory, ".DS_Store", "CANARY\x00") + if runtime.GOOS != "windows" { + if err := os.Symlink(filepath.Join(directory, "missing"), filepath.Join(directory, ".hidden-link")); err != nil { + t.Fatal(err) + } + } + access := testSecretsAccess() + access.lstat = func(path string) (fs.FileInfo, error) { + if strings.HasPrefix(filepath.Base(path), ".") { + t.Fatalf("inspected ignored entry %q", path) + } + return os.Lstat(path) + } + environment := map[string]string{} + report, err := loadSecretsDir(directory, mapSecretsEnvironment(environment), access) + if err != nil || len(report.Entries()) != 0 || len(environment) != 0 { + t.Fatalf("hidden-only directory: error = %v, report = %#v, environment = %#v", err, report.Entries(), environment) + } + writeSecretTestFile(t, directory, "SECRET", "value\n") + report, err = loadSecretsDir(directory, mapSecretsEnvironment(environment), access) + if err != nil { + t.Fatal(err) + } + want := []SecretFileResult{{Name: "SECRET", Path: filepath.Join(canonicalTestPath(t, directory), "SECRET"), Status: SecretFileLoaded}} + if !reflect.DeepEqual(report.Entries(), want) || !reflect.DeepEqual(environment, map[string]string{"SECRET": "value"}) { + t.Fatalf("mixed directory: report = %#v, environment = %#v", report.Entries(), environment) + } +} + func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) { directory := t.TempDir() writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit))) diff --git a/secrets.go b/secrets.go index e8141a1..d9a1108 100644 --- a/secrets.go +++ b/secrets.go @@ -88,6 +88,7 @@ func (e *SecretsError) Diagnostics() []Diagnostic { // LoadSecretsDir explicitly loads immediate regular files from directory into // previously unset process environment variables. Applications should call it // once during startup, before concurrent work; Load never calls it. +// Dot-prefixed entries are ignored and omitted from the report. func LoadSecretsDir(directory string) (*SecretsReport, error) { report, err := engine.LoadSecretsDir(directory) result := &SecretsReport{value: report}