From c09c7a2ac790f6da6a3d576df96fa9a4eae0ef76 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 14:20:54 +0000 Subject: [PATCH] Preserve literal characters in shared file paths --- docs/roadmap/remediation.md | 8 +++++++- internal/validation/tree.go | 2 +- internal/validation/tree_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index aca2409..db0c001 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -267,7 +267,13 @@ reference-cycle, outside-checkout, unavailable-content, and source-tag guards. ## Issue 8: Preserve literal shared filenames (P2) -Status: open. Category: unrequested path prohibition. +Status: complete. Category: unrequested path prohibition. + +**Completion:** Shared selection preserves literal backslashes in committed Git +paths. A Unix regression selects and reads such a file once despite duplicates; +traversal, absolute paths, and directories remain invalid selections. Existing +literal-metacharacter, missing/default/empty-list, scope, and lazy-input tests +pass offline. **Evidence:** [`normalizeSharedFile`](../../internal/validation/tree.go) rejects every backslash. On Linux, a committed file named `docs/notice\copy.txt` is a diff --git a/internal/validation/tree.go b/internal/validation/tree.go index 792dd86..e67bb10 100644 --- a/internal/validation/tree.go +++ b/internal/validation/tree.go @@ -210,7 +210,7 @@ func insideVendor(moduleRelative string) bool { } func normalizeSharedFile(name string) (string, error) { - if name == "" || path.IsAbs(name) || strings.Contains(name, "\\") { + if name == "" || path.IsAbs(name) { return "", fmt.Errorf("shared file path must be a repository-relative file path") } clean := path.Clean(name) diff --git a/internal/validation/tree_test.go b/internal/validation/tree_test.go index 47e1414..26948ee 100644 --- a/internal/validation/tree_test.go +++ b/internal/validation/tree_test.go @@ -231,6 +231,36 @@ func TestReadCandidateTreeDefaultsAreOptionalAndExplicitMissingFilesFail(t *test } } +func TestSharedFileSelectionPreservesLiteralBackslash(t *testing.T) { + if filepath.Separator == '\\' { + t.Skip("backslash is a filesystem separator on this platform") + } + repository := newTreeRepository(t) + writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n")) + const name = `docs/notice\copy.txt` + contents := []byte("literal filename\n") + writeTreeFile(t, repository, name, contents) + commit := commitCandidateTree(t, repository) + moduleDir := filepath.Join(repository, "module") + tree, err := ReadCandidateTreeForScope(t.Context(), repository, commit, TreeScope{ModuleDir: moduleDir, SharedFiles: []string{name, name}}) + if err != nil { + t.Fatal(err) + } + cleanupCandidateTree(t, tree) + if got := inputPaths(tree); strings.Join(got, "\n") != name+"\nmodule/go.mod" { + t.Fatalf("literal selection = %q", got) + } + read, err := tree.ReadFile(t.Context(), name) + if err != nil || string(read) != string(contents) { + t.Fatalf("literal file content = %q, error = %v", read, err) + } + for _, invalid := range []string{"../outside", "/absolute", "docs"} { + if _, err := ReadCandidateTreeForScope(t.Context(), repository, commit, TreeScope{ModuleDir: moduleDir, SharedFiles: []string{invalid}}); err == nil { + t.Fatalf("shared selection accepted %q", invalid) + } + } +} + func TestRunBuildsInputsOnlyForChecksOrExplicitSharedFiles(t *testing.T) { repository := newTreeRepository(t) writeTreeFile(t, repository, "go.mod", []byte("module example.com/validation-tree\n\ngo 1.25.0\n"))