fetch refuses a manifest whose paths differ only in letter case (closes #154)
check / check (push) Failing after 1s

On a case-insensitive filesystem, such as the macOS default, A.txt and
a.txt are one file: fetch downloaded one, replaced it with the other and
exited 0, leaving a tree check rejects. A file and a directory whose
names differ only in case did the same.

The existing name-clash check now also records each listed file, and
each directory one is in, among the names fetch writes. A listed file at
a name already taken, or a directory spelled differently from one
already there, refuses the manifest on every filesystem, before the
destination is created or any file requested. The message names both
paths.

Model: opus-5-5
This commit is contained in:
2026-10-05 23:01:15 +00:00
parent ce024baaed
commit c3f96d768f
3 changed files with 90 additions and 15 deletions
+60
View File
@@ -1236,6 +1236,66 @@ func TestFetchRefusesListedTempName(t *testing.T) {
}
}
// TestFetchRefusesNamesEqualIgnoringCase fetches manifests that list two
// paths that are one name on a case-insensitive filesystem: two files,
// at the top of the tree and in a directory, a file and a directory
// another file is in, and two directories. Fetching such a manifest
// there would replace one with the other, so fetch must refuse it on
// every filesystem before it creates the destination or requests any
// file. A file and a directory with the same name, and a file listed
// twice, are refused the same way. A manifest whose names differ in more
// than letter case is fetched in full.
func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ first, second, message string }{
{"X.txt", "x.txt", "x.txt (the file X.txt)"},
{"sub/X.txt", "sub/x.txt", "sub/x.txt (the file sub/X.txt)"},
{"Dir", "dir/x", "dir/x (the file Dir)"},
{"Dir/a.txt", "dir/b.txt", "dir/b.txt (the directory Dir)"},
{"dir", "dir/x", "dir/x (the file dir)"},
{"./x.txt", "x.txt", "x.txt (the file ./x.txt)"},
} {
t.Run(tc.first+" and "+tc.second, func(t *testing.T) {
t.Parallel()
files := map[string][]byte{
tc.first: []byte("the first file"),
tc.second: []byte("the second file"),
}
assertFetchRefused(t, builtManifest(t, files), files,
"manifest lists a file where fetch writes another file: "+tc.message)
})
}
t.Run("names that differ in more than letter case", func(t *testing.T) {
t.Parallel()
files := map[string][]byte{
"A.txt": []byte("at the top"),
"B.txt": []byte("also at the top"),
"dir/a.txt": []byte("in a directory"),
"dir/b.txt": []byte("in the same directory"),
}
manifest := builtManifest(t, files)
server := httptest.NewServer(fetchTestHandler(manifest, files))
defer server.Close()
dest := t.TempDir()
opts := testOpts([]string{
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
}, afero.NewOsFs())
require.Equal(t, 0, runCLI(opts), testStderr(t, opts))
want := maps.Clone(files)
want[defaultManifestName] = manifest
assert.Equal(t, want, filesUnder(t, dest))
})
}
// builtManifest returns a manifest of files, built directly rather than
// scanned, since a scan lists no hidden files and never a path starting
// with "./".