2 Commits
Author SHA1 Message Date
sneak 040aa8e113 Drop atime from the format spec and pin the file entry fields (closes #158)
check / check (push) Failing after 2m22s
atime left mf.proto earlier and nothing reads or writes it, but
docs/FORMAT.md still narrated its removal and listed it among the
determinism rules. The spec now describes the file entry by its fields
only.

A new test compares the MFFilePath message descriptor, by name and
number, with a list copied from the spec's field table. It does not read
the spec, so changing a field means changing the proto, the spec and
that list together.

Model: opus-5-5
2026-10-06 00:26:42 +00:00
clawbot 99b3e0e202 fetch refuses a manifest whose paths differ only in letter case (closes #154)
check / check (push) Failing after 2s
On a case-insensitive filesystem such paths are one name. Of two files,
one replaced the other and fetch exited 0. For a file and a directory
another file is in, fetch stopped partway with a non-zero exit, leaving
a partial tree. For two spellings of one directory, both files landed
in one directory, one under a spelling the manifest does not list, and
check reported that file as not in the manifest.

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

Model: opus-5-5
2026-10-06 01:43:33 +02:00
5 changed files with 118 additions and 19 deletions
+5 -3
View File
@@ -274,9 +274,11 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
tree later. Each file is downloaded to a temp file beside it, such as tree later. Each file is downloaded to a temp file beside it, such as
`.a.txt.tmp` for `a.txt`, then moved into place. A manifest is refused `.a.txt.tmp` for `a.txt`, then moved into place. A manifest is refused
before any file is downloaded if it lists a file where fetch writes before any file is downloaded if it lists a file where fetch writes
another: at the temp file of a listed file, or at `index.mf` or another: at another listed file or a directory one is in, at the temp file
`.index.mf.tmp` at the top of the tree. Names are compared in any letter of a listed file, or at `index.mf` or `.index.mf.tmp` at the top of the
case. tree. Names are compared in any letter case, on every filesystem, since on
a case-insensitive one `A.txt` and `a.txt` are one file; a directory two
listed files are in must be spelled alike in both.
- `mfer fetch --require-signature <fingerprint> https://example.com/stuff/` - `mfer fetch --require-signature <fingerprint> https://example.com/stuff/`
- as above, but first refuses a manifest not signed by the key with that - as above, but first refuses a manifest not signed by the key with that
fingerprint, as `mfer check --require-signature` does, before downloading fingerprint, as `mfer check --require-signature` does, before downloading
-4
View File
@@ -78,9 +78,6 @@ Each file entry contains:
| `mtime` | 302 | Timestamp (optional) | Modification time | | `mtime` | 302 | Timestamp (optional) | Modification time |
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) | | `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
Field 304 (`atime`) has been removed from the specification. Access time is
volatile and non-deterministic; it is not useful for integrity verification.
## Path Rules ## Path Rules
All `path` values must satisfy these invariants: All `path` values must satisfy these invariants:
@@ -130,7 +127,6 @@ By default, manifests are generated deterministically:
- File entries are sorted by `path` in **lexicographic byte order** - File entries are sorted by `path` in **lexicographic byte order**
- `createdAt` is omitted unless explicitly requested - `createdAt` is omitted unless explicitly requested
- `atime` is never included (field removed from schema)
This ensures that two independent runs over the same directory tree produce This ensures that two independent runs over the same directory tree produce
byte-identical `.mf` files (assuming file contents and metadata have not byte-identical `.mf` files (assuming file contents and metadata have not
+25 -12
View File
@@ -466,17 +466,18 @@ func fetchManifest(
} }
// checkNoNameClash returns an error if files lists a file, or a directory // 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 // a file is in, under a name where fetch writes another file: another
// file it downloads a listed file to, or, at the top of the tree, the // listed file, a directory another listed file is in, the temp file it
// saved manifest or its temp file. fetch would remove or replace what is // 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 // listed there, or fail partway, leaving a tree check rejects. Names are
// compared ignoring case, since on a case-insensitive filesystem INDEX.MF // compared ignoring case, on every filesystem, since on a
// and index.mf are one file. // case-insensitive one A.txt and a.txt are one file.
func checkNoNameClash(files []*mfer.MFFilePath) error { func checkNoNameClash(files []*mfer.MFFilePath) error {
sep := string(filepath.Separator) sep := string(filepath.Separator)
// written maps each name fetch writes, other than the listed files // written maps each name fetch writes, in lower case, to the file or
// themselves, in lower case, to the file it writes there. // directory it writes there.
written := map[string]string{ written := map[string]string{
defaultManifestName: "the saved manifest", defaultManifestName: "the saved manifest",
tempPathFor(defaultManifestName): "the saved manifest's temp file", tempPathFor(defaultManifestName): "the saved manifest's temp file",
@@ -488,14 +489,26 @@ func checkNoNameClash(files []*mfer.MFFilePath) error {
} }
for _, f := range files { for _, f := range files {
// Look up each directory on the file's path, then the file itself. // Look up and add each directory on the file's path, then the
parts := strings.Split(strings.ToLower(filepath.Clean(f.GetPath())), sep) // 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 { for i := range parts {
what, ok := written[strings.Join(parts[:i+1], sep)] name := strings.Join(parts[:i+1], sep)
if ok {
return fmt.Errorf("%w: %s (%s)", errNameClash, f.GetPath(), what) 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
} }
} }
+63
View File
@@ -1236,6 +1236,69 @@ func TestFetchRefusesListedTempName(t *testing.T) {
} }
} }
// TestFetchRefusesNamesEqualIgnoringCase fetches manifests that list two
// paths that are one name on a case-insensitive filesystem. Fetched
// there, of two such files, at the top of the tree or in a directory,
// one replaces the other and fetch exits 0. A file and a directory
// another file is in stop fetch partway with a non-zero exit, leaving a
// partial tree. Two spellings of one directory put both files in one
// directory, one of them under a spelling the manifest does not list,
// and check reports that file as not in the manifest. So fetch must
// refuse each 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 // builtManifest returns a manifest of files, built directly rather than
// scanned, since a scan lists no hidden files and never a path starting // scanned, since a scan lists no hidden files and never a path starting
// with "./". // with "./".
+25
View File
@@ -8,6 +8,8 @@ import (
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
"sneak.berlin/go/mfer/mfer"
) )
// mf.pb.go is generated from mf.proto and committed. `make generate` // mf.pb.go is generated from mf.proto and committed. `make generate`
@@ -27,3 +29,26 @@ func TestGeneratedCodeMatchesProto(t *testing.T) {
"mfer/mf.proto has changed since mfer/mf.pb.go was generated "+ "mfer/mf.proto has changed since mfer/mf.pb.go was generated "+
"from it: run `make generate` and commit the result") "from it: run `make generate` and commit the result")
} }
// A file entry has exactly the fields docs/FORMAT.md lists for MFFilePath.
func TestFileEntryFieldsMatchSpec(t *testing.T) {
t.Parallel()
want := map[string]protoreflect.FieldNumber{
"path": 1,
"size": 2,
"hashes": 3,
"mimeType": 301,
"mtime": 302,
"ctime": 303,
}
got := map[string]protoreflect.FieldNumber{}
fields := (&mfer.MFFilePath{}).ProtoReflect().Descriptor().Fields()
for i := range fields.Len() {
got[string(fields.Get(i).Name())] = fields.Get(i).Number()
}
require.Equal(t, want, got)
}