diff --git a/docs/FORMAT.md b/docs/FORMAT.md index 6758f88..d571a97 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 98b5bdc..e57a1b6 100644 --- a/internal/cli/fetch_test.go +++ b/internal/cli/fetch_test.go @@ -1243,10 +1243,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() @@ -1255,7 +1255,6 @@ func TestFetchRefusesListedManifestName(t *testing.T) { tempPathFor(defaultManifestName), defaultManifestName + "/" + testFileTxt, "INDEX.MF", - "./" + defaultManifestName, } { t.Run(listed, func(t *testing.T) { t.Parallel() @@ -1331,9 +1330,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() @@ -1343,7 +1342,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() @@ -1573,8 +1571,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..ae4bff0 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"}, + {"dir/./a.txt", "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..1e51c03 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: "dir/./a.txt", + want: `path "dir/./a.txt" contains '.' segment`, + is: errPathDot, + }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel()