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
+25 -12
View File
@@ -466,17 +466,18 @@ func fetchManifest(
}
// checkNoNameClash returns an error if files lists a file, or a directory
// a file is in, under a name where fetch writes another file: the temp
// file it downloads a listed file to, or, at the top of the tree, the
// saved manifest or its temp file. fetch would remove or replace what is
// a file is in, under a name where fetch writes another file: another
// listed file, a directory another listed file is in, the temp file it
// downloads a listed file to, or, at the top of the tree, the saved
// manifest or its temp file. fetch would remove or replace what is
// listed there, or fail partway, leaving a tree check rejects. Names are
// compared ignoring case, since on a case-insensitive filesystem INDEX.MF
// and index.mf are one file.
// compared ignoring case, on every filesystem, since on a
// case-insensitive one A.txt and a.txt are one file.
func checkNoNameClash(files []*mfer.MFFilePath) error {
sep := string(filepath.Separator)
// written maps each name fetch writes, other than the listed files
// themselves, in lower case, to the file it writes there.
// written maps each name fetch writes, in lower case, to the file or
// directory it writes there.
written := map[string]string{
defaultManifestName: "the saved manifest",
tempPathFor(defaultManifestName): "the saved manifest's temp file",
@@ -488,14 +489,26 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
}
for _, f := range files {
// Look up each directory on the file's path, then the file itself.
parts := strings.Split(strings.ToLower(filepath.Clean(f.GetPath())), sep)
// Look up and add each directory on the file's path, then the
// file itself. Only the same directory, spelled alike, may
// already be there.
parts := strings.Split(filepath.Clean(f.GetPath()), sep)
last := len(parts) - 1
for i := range parts {
what, ok := written[strings.Join(parts[:i+1], sep)]
if ok {
return fmt.Errorf("%w: %s (%s)", errNameClash, f.GetPath(), what)
name := strings.Join(parts[:i+1], sep)
what := "the directory " + name
if i == last {
what = "the file " + f.GetPath()
}
other, ok := written[strings.ToLower(name)]
if ok && (i == last || other != what) {
return fmt.Errorf("%w: %s (%s)", errNameClash, f.GetPath(), other)
}
written[strings.ToLower(name)] = what
}
}