From 2cfea66d273cded5cc37bede9e2eedcdf14ca0c9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 14 Oct 2026 00:01:12 +0200 Subject: [PATCH] Refuse a path with a . segment (closes #208) ValidatePath refused a .. segment but accepted a . segment, so a manifest could list ./a.txt. check then verified a.txt and also reported it as an extra file, and one file could be listed twice under two spellings despite the listed-once rule. A . segment is now refused when a manifest is written and when it is loaded, and the Path Rules in docs/FORMAT.md and the path comment in mfer/mf.proto list it. Two fetch tests listed ./ paths to check name clashes; such manifests can no longer be built, so those cases are removed. Model: opus-5-5 --- docs/FORMAT.md | 1 + internal/cli/fetch_test.go | 19 ++++++++----------- mfer/builder.go | 6 ++++++ mfer/builder_test.go | 3 +++ mfer/deserialize_path_test.go | 2 ++ mfer/errmsg_test.go | 6 ++++++ mfer/mf.pb.go | 4 ++-- mfer/mf.proto | 4 ++-- mfer/mf.proto.sha256 | 2 +- 9 files changed, 31 insertions(+), 16 deletions(-) diff --git a/docs/FORMAT.md b/docs/FORMAT.md index ed401f9..6e9dd5d 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -107,6 +107,7 @@ All `path` values must satisfy these invariants: - **Forward slashes**: use `/` as the path separator (never `\`) - **Relative only**: no leading `/` - **No parent traversal**: no `..` path segments +- **No current-directory segments**: no `.` path segments - **No empty segments**: no `//` sequences - **No trailing slash**: paths refer to files, not directories - **Listed once**: each path appears at most once in a manifest, compared byte diff --git a/internal/cli/fetch_test.go b/internal/cli/fetch_test.go index fa773bd..0c6b1e7 100644 --- a/internal/cli/fetch_test.go +++ b/internal/cli/fetch_test.go @@ -1266,10 +1266,10 @@ func TestFetchRequireSignature(t *testing.T) { // TestFetchRefusesListedManifestName fetches manifests that list, at the // top of the tree, the name fetch saves the manifest under or that name's -// temp file: as a file, as a directory, in capitals, and with a leading -// "./". Saving the manifest would replace or remove what is listed there, -// so fetch must refuse the manifest before it creates the destination or -// requests any file. +// temp file: as a file, as a directory and in capitals. Saving the +// manifest would replace or remove what is listed there, so fetch must +// refuse the manifest before it creates the destination or requests any +// file. func TestFetchRefusesListedManifestName(t *testing.T) { t.Parallel() @@ -1278,7 +1278,6 @@ func TestFetchRefusesListedManifestName(t *testing.T) { tempPathFor(defaultManifestName), defaultManifestName + "/" + testFileTxt, "INDEX.MF", - "./" + defaultManifestName, } { t.Run(listed, func(t *testing.T) { t.Parallel() @@ -1354,9 +1353,9 @@ func TestFetchRefusesListedTempName(t *testing.T) { // 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. +// requests any file. A file and a directory with the same name 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() @@ -1366,7 +1365,6 @@ func TestFetchRefusesNamesEqualIgnoringCase(t *testing.T) { {"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() @@ -1600,8 +1598,7 @@ func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) [] } // builtManifest returns a manifest of files, built directly rather than -// scanned, since a scan lists no hidden files and never a path starting -// with "./". +// scanned, since a scan lists no hidden files. func builtManifest(t *testing.T, files map[string][]byte) []byte { t.Helper() diff --git a/mfer/builder.go b/mfer/builder.go index a6a0d84..33cfd39 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -34,6 +34,7 @@ var ( errPathAbsolute = errors.New("is absolute; must be relative") errPathEmptySegment = errors.New("contains empty segment") errPathDotDot = errors.New("contains '..' segment") + errPathDot = errors.New("contains '.' segment") errSizeMismatch = errors.New("size mismatch") errNegativeSize = errors.New("size cannot be negative") errHashNotMultihash = errors.New("hash is not a valid multihash") @@ -46,6 +47,7 @@ var ( // - Must use forward slashes only (no backslashes) // - Must be relative (no leading /) // - Must not contain ".." segments +// - Must not contain "." segments // - Must not contain empty segments (no "//") // - Must not be empty func ValidatePath(p string) error { @@ -73,6 +75,10 @@ func ValidatePath(p string) error { if seg == ".." { return fmt.Errorf("path %q %w", p, errPathDotDot) } + + if seg == "." { + return fmt.Errorf("path %q %w", p, errPathDot) + } } return nil diff --git a/mfer/builder_test.go b/mfer/builder_test.go index db4f5ce..4a70e95 100644 --- a/mfer/builder_test.go +++ b/mfer/builder_test.go @@ -286,6 +286,9 @@ func TestValidatePath(t *testing.T) { {"has/../traversal", "dot-dot segment"}, {"has//double", "empty segment"}, {"..", "just dot-dot"}, + {"./a.txt", "leading dot segment"}, + {"has/./dot", "interior dot segment"}, + {".", "just dot"}, {string([]byte{0xff, 0xfe}), "invalid UTF-8"}, } for _, tt := range invalid { diff --git a/mfer/deserialize_path_test.go b/mfer/deserialize_path_test.go index b722f15..cbaadea 100644 --- a/mfer/deserialize_path_test.go +++ b/mfer/deserialize_path_test.go @@ -80,6 +80,8 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) { }{ {"parent traversal", "../escape"}, {"interior traversal", "a/../../escape"}, + {"leading dot segment", "./a.txt"}, + {"interior dot segment", "dir/./a.txt"}, {"absolute path", "/etc/passwd"}, {"backslash path", `a\b`}, {"double slash", "a//b"}, diff --git a/mfer/errmsg_test.go b/mfer/errmsg_test.go index 1f2d3c9..bd556c8 100644 --- a/mfer/errmsg_test.go +++ b/mfer/errmsg_test.go @@ -62,6 +62,12 @@ func TestValidatePathMessagesVerbatim(t *testing.T) { want: `path "a/../b" contains '..' segment`, is: errPathDotDot, }, + { + name: "dot segment", + path: "a/./b", + want: `path "a/./b" contains '.' segment`, + is: errPathDot, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() diff --git a/mfer/mf.pb.go b/mfer/mf.pb.go index 4cc2dc5..a80e409 100644 --- a/mfer/mf.pb.go +++ b/mfer/mf.pb.go @@ -333,8 +333,8 @@ type MFFilePath struct { state protoimpl.MessageState `protogen:"open.v1"` // required attributes: // Path invariants: must be valid UTF-8, use forward slashes only, - // be relative (no leading /), contain no ".." segments, and no - // empty segments (no "//"). + // be relative (no leading /), contain no ".." or "." segments, and + // no empty segments (no "//"). Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // gotta have at least one: diff --git a/mfer/mf.proto b/mfer/mf.proto index 0e97cd1..3193e26 100644 --- a/mfer/mf.proto +++ b/mfer/mf.proto @@ -49,8 +49,8 @@ message MFFileOuter { message MFFilePath { // required attributes: // Path invariants: must be valid UTF-8, use forward slashes only, - // be relative (no leading /), contain no ".." segments, and no - // empty segments (no "//"). + // be relative (no leading /), contain no ".." or "." segments, and + // no empty segments (no "//"). string path = 1; int64 size = 2; diff --git a/mfer/mf.proto.sha256 b/mfer/mf.proto.sha256 index b47d942..871641e 100644 --- a/mfer/mf.proto.sha256 +++ b/mfer/mf.proto.sha256 @@ -1 +1 @@ -4f8ea5276a8e66590e71ed154a9176b0f68ffa9ab69d8de5aff0e0d9b731164d mf.proto +1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto