6 Commits
Author SHA1 Message Date
clawbot e2deea2e34 Drop mimeType and ctime from the manifest format (closes #235)
check / check (push) Waiting to run
Nothing goes in the 1.0 manifest that 1.0 does not read or write, and
mfer never wrote either field. mf.proto now reserves the numbers 301 and
303 and both names in MFFilePath, and mf.pb.go is regenerated. The
decoded-size check counts only file entries, hashes and timestamps, and
counts a file entry, 16 bytes smaller without the two fields, at 160
bytes. export prints no ctime, and FileEntry loses the Ctime nothing
set. FORMAT.md and the README no longer describe either field.

The test of entries that decode too large gives each entry a second,
empty hash: an entry with one hash and a modification time can no
longer reach 8 times its size.

Model: opus-5-5
2026-10-19 20:52:00 +02:00
clawbot 033d8be5be Never list index.mf at the top of the tree in gen and freshen (closes #233)
check / check (push) Canceled after 0s
fetch saves the manifest as index.mf at the top of the tree and refuses a
manifest that lists a file there. gen given --output, and freshen with its
manifest outside the base directory, listed such a file, so they wrote
manifests fetch would not take.

Both now leave out each file the manifest would list as index.mf: one at
the top of the tree, or a file given to gen by that name. The rule goes by
that path alone and looks up no file, so a symlink named index.mf never
hides the file it points to. index.mf in a subdirectory is still listed.
The scanner in package mfer gets an option to leave out files by the path
they would be listed by.

Model: opus-5-5
2026-10-16 00:58:56 +02:00
clawbot f360f1c070 Never report index.mf at the top of the base as unlisted in check (closes #229)
check / check (push) Canceled after 0s
gen and fetch write the manifest as index.mf at the top of the tree.
check left out only the manifest file it was given, so given a URL, or a
manifest stored elsewhere, it reported that index.mf as not in the
manifest, and --no-extra-files failed a tree fetch had just verified.
check now drops that one report for index.mf at the top of the base
directory. A manifest that lists index.mf still has it checked, and
index.mf in a subdirectory or fetch's leftover .index.mf.tmp are still
reported. The change is in package cli, which owns the default manifest
name; the Checker in package mfer is unchanged.

Model: opus-5-5
2026-10-16 00:42:24 +02:00
clawbot 06127e291f Describe releases as SemVer git tags in the README (closes #226)
check / check (push) Canceled after 0s
The first section of the README said the project had no versioned
release. It now says releases are git tags named by SemVer and names
the first one. That sentence opens the paragraph on where `mfer
version` gets its number, in place of its old opening sentence, "A
version is a git tag", which the new one covers. The rest of that
paragraph is unchanged and still matches `script/build`,
`script/docker` and the `Dockerfile`.

Model: opus-5-5
2026-10-15 22:42:35 +02:00
clawbot 31e22a9337 Refuse a manifest entry whose mode is above 0777 on load (closes #225)
check / check (push) Canceled after 0s
Loading accepted an entry recording a mode above 0777, such as 04755.
check then reported a file with exactly the recorded permission bits
as a mode mismatch, list and export printed the out-of-range mode, and
freshen silently rewrote it as the permission bits alone. Loading now
refuses such an entry, naming the path and the mode, so every command
that reads a manifest refuses it with one message. fetch's own check
for this could no longer be reached and is gone; its test moves to the
loading tests in package mfer. docs/FORMAT.md says readers reject a
mode above 0777.

Model: opus-5-5
2026-10-15 22:02:54 +02:00
clawbot 95a37d8009 Load the manifest check downloads from memory, not a temp file (closes #223)
check / check (push) Canceled after 0s
check given a URL wrote the manifest it downloaded to a file in the temp
directory before loading it, so it failed wherever that directory is
missing or cannot be written, such as the scratch image script/cibuild
builds. It now keeps the manifest in memory, as fetch, list and export
do. NewChecker reads a manifest handed to it as a reader through the new
CheckerOptions.ManifestReader field, in place of ManifestPath, because it
otherwise reads the manifest from the filesystem holding the files it
checks. Only check uses that field. verifyRequiredSigner takes the
signer's fingerprint instead of a Checker, so fetch passes the signer of
the manifest it has already loaded and builds no Checker.

Model: opus-5-5
2026-10-15 21:59:12 +02:00
21 changed files with 645 additions and 231 deletions
+16 -13
View File
@@ -13,14 +13,11 @@ The structure of these files can be found
schema `mfer/mf.proto` for exact field numbers and types. Both are included in schema `mfer/mf.proto` for exact field numbers and types. Both are included in
the [project repository](https://git.eeqj.de/sneak/mfer). the [project repository](https://git.eeqj.de/sneak/mfer).
The current version is pre-1.0 and while the repo was published in 2022, there Releases are git tags named by [SemVer](https://semver.org), starting with
has not yet been any versioned release. [SemVer](https://semver.org) will be `v1.0.0`. No file in the tree records a version number. `mfer version` prints
used for releases. the revision the build stamped into the binary, which for `make build` and the
Docker image is the output of `git describe` on the checkout they build. A
A version is a git tag, and no file in the tree records a version number. binary built without that stamp, as
`mfer version` prints the revision the build stamped into the binary, which for
`make build` and the Docker image is the output of `git describe` on the
checkout they build. A binary built without that stamp, as
`go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module `go install sneak.berlin/go/mfer/cmd/mfer@<tag>` builds it, prints the module
version Go records in it instead. A program that imports the `mfer` package version Go records in it instead. A program that imports the `mfer` package
finds the version it was built against in its own build information finds the version it was built against in its own build information
@@ -199,8 +196,7 @@ The manifest file would do several important things:
a signature and a public key alongside in a single file a signature and a public key alongside in a single file
- contain a list of files, each with a relative path to the manifest - contain a list of files, each with a relative path to the manifest
- contain manifest timestamp - contain manifest timestamp
- contain ctime/mtime information for files so that file metadata can be - contain mtime information for files so that file metadata can be preserved
preserved
- contain cryptographic checksums in several different algorithms for each file - contain cryptographic checksums in several different algorithms for each file
- probably encoded with multihash to indicate algo + hash - probably encoded with multihash to indicate algo + hash
- sha256 at the minimum - sha256 at the minimum
@@ -290,6 +286,9 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
only on the paths it is given and the files under them, so with the same only on the paths it is given and the files under them, so with the same
`--seed` and an unchanged tree it writes the same bytes wherever the `--seed` and an unchanged tree it writes the same bytes wherever the
manifest goes. The file it writes to is never listed manifest goes. The file it writes to is never listed
- never lists `index.mf` at the top of a directory it is given, or a file it
is given by that name, wherever it writes the manifest, since `fetch`
saves the manifest there; `index.mf` in a subdirectory is listed
- `mfer check` / `mfer check .` - `mfer check` / `mfer check .`
- verifies checksums of all files in manifest, displaying error and exiting - verifies checksums of all files in manifest, displaying error and exiting
nonzero if any files are missing or corrupted, or have permission bits nonzero if any files are missing or corrupted, or have permission bits
@@ -304,11 +303,15 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
`--no-extra-files` each one is a failure instead. A named pipe, socket or `--no-extra-files` each one is a failure instead. A named pipe, socket or
device, a symlink to a directory and a broken symlink are never reported, device, a symlink to a directory and a broken symlink are never reported,
since gen cannot list them since gen cannot list them
- never reports `index.mf` at the top of the base directory as a file the
manifest does not list, even when given another manifest or a URL, since
gen and fetch write the manifest there
- `mfer freshen` / `mfer freshen .` - `mfer freshen` / `mfer freshen .`
- rewrites `index.mf` to list the files now under the directory holding it, - rewrites `index.mf` to list the files now under the directory holding it,
or under the one `--base` names, hashing only the files that are new or or under the one `--base` names, hashing only the files that are new or
changed changed
- lists regular files only, as gen does - lists regular files only, and never `index.mf` at the top of that
directory, as gen does
- leaves out hidden files unless given `--include-dotfiles`, and symlinks - leaves out hidden files unless given `--include-dotfiles`, and symlinks
unless given `--follow-symlinks`, which lists each symlink to a regular unless given `--follow-symlinks`, which lists each symlink to a regular
file under its own name with the contents of the file it points to file under its own name with the contents of the file it points to
@@ -365,8 +368,8 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- (fetches `https://example.com/manifestdirectory/index.mf`, downloads and - (fetches `https://example.com/manifestdirectory/index.mf`, downloads and
checksums all files, resumes any that exist locally already) checksums all files, resumes any that exist locally already)
- `mfer fetch https://example.com/manifestdirectory/` - `mfer fetch https://example.com/manifestdirectory/`
- a command line option to zero/omit mtime/ctime, as well as manifest timestamp, - a command line option to zero/omit mtime, as well as manifest timestamp, and
and sort all directory listings so that manifest file generation is sort all directory listings so that manifest file generation is
deterministic/reproducible deterministic/reproducible
- URL format - URL format
`mfer fetch https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2` `mfer fetch https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2`
+16 -16
View File
@@ -9,8 +9,11 @@ including their paths, sizes, and cryptographic checksums. It supports optional
OpenPGP signatures for integrity verification and optional timestamps and file OpenPGP signatures for integrity verification and optional timestamps and file
permissions for metadata preservation. permissions for metadata preservation.
Nothing goes in the 1.0 manifest that 1.0 does not read or write: no field is Nothing goes in the 1.0 manifest that 1.0 does not read or write, and no field
reserved or kept for later use. is kept for later use. [`mfer/mf.proto`](../mfer/mf.proto) reserves the field
numbers 301 and 303 and the names `mimeType` and `ctime` in `MFFilePath`, so
that a later version cannot reuse them by accident. No field uses them, and
readers ignore them like any field they do not know.
## File Structure ## File Structure
@@ -58,11 +61,11 @@ enforce a decompression size limit to prevent decompression bombs. The reference
implementation limits decompressed size to 256 MiB. It writes zstd frames with a implementation limits decompressed size to 256 MiB. It writes zstd frames with a
window of at most 8 MiB, the largest window the zstd format recommends decoders window of at most 8 MiB, the largest window the zstd format recommends decoders
support, and refuses frames that ask for a larger one. It also refuses an inner support, and refuses frames that ask for a larger one. It also refuses an inner
message whose file entries, hashes, timestamps and MIME types, counted at 176, message whose file entries, hashes and timestamps, counted at 160, 112 and 64
112, 64 and 16 bytes each, add up to more than 8 times its size. It refuses a bytes each, add up to more than 8 times its size. It refuses a manifest file
manifest file larger than 258 MiB without reading the rest of it: zstd's worst larger than 258 MiB without reading the rest of it: zstd's worst case grows a
case grows a 256 MiB inner message by 1/256 to 257 MiB, and the last MiB is room 256 MiB inner message by 1/256 to 257 MiB, and the last MiB is room for the
for the signature, the signing key and the other outer fields. signature, the signing key and the other outer fields.
## Inner Message (`MFFile`) ## Inner Message (`MFFile`)
@@ -81,28 +84,25 @@ After decompressing `innerMessage`, the result is a serialized `MFFile`
Each file entry contains: Each file entry contains:
| Field | Number | Type | Description | | Field | Number | Type | Description |
| ---------- | ------ | ------------------------- | ------------------------------------------------------------ | | -------- | ------ | ------------------------- | ------------------------------------------------------------ |
| `path` | 1 | string | Relative file path (see Path Rules) | | `path` | 1 | string | Relative file path (see Path Rules) |
| `size` | 2 | int64 | File size in bytes | | `size` | 2 | int64 | File size in bytes |
| `hashes` | 3 | repeated `MFFileChecksum` | Must include exactly one SHA-256 multihash (see Hash Format) | | `hashes` | 3 | repeated `MFFileChecksum` | Must include exactly one SHA-256 multihash (see Hash Format) |
| `mimeType` | 301 | string (optional) | MIME type |
| `mtime` | 302 | Timestamp (optional) | Modification time | | `mtime` | 302 | Timestamp (optional) | Modification time |
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
| `mode` | 304 | uint32 | Permission bits (see File Mode) | | `mode` | 304 | uint32 | Permission bits (see File Mode) |
## File Mode ## File Mode
`mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never `mode` holds a file's Unix permission bits, the nine `rwx` bits, so it is never
above `0777` (octal); the setuid, setgid and sticky bits are never recorded. above `0777` (octal); the setuid, setgid and sticky bits are never recorded. A
Writers record `0000` unless whoever creates the manifest asks for permissions. reader must reject a manifest with an entry whose mode is above `0777`. Writers
`0000`, the proto3 default, means no mode was recorded: readers never check or record `0000` unless whoever creates the manifest asks for permissions. `0000`,
apply it. the proto3 default, means no mode was recorded: readers never check or apply it.
The reference implementation records modes when `gen` or `freshen` is given The reference implementation records modes when `gen` or `freshen` is given
`--include-permissions`. `check` fails a file whose permission bits differ from `--include-permissions`. `check` fails a file whose permission bits differ from
a recorded mode other than `0000`. `fetch` sets a recorded mode other than a recorded mode other than `0000`. `fetch` sets a recorded mode other than
`0000` on each file it writes, and refuses a manifest that records a mode above `0000` on each file it writes.
`0777` before it requests any file.
## Path Rules ## Path Rules
+8 -1
View File
@@ -195,7 +195,10 @@ func countCheckFailures(
// findExtraFiles reports files present on disk but absent from the // findExtraFiles reports files present on disk but absent from the
// manifest, and anything the search cannot read: each is a failure, logged // manifest, and anything the search cannot read: each is a failure, logged
// at error level, under --no-extra-files, otherwise a warning. // at error level, under --no-extra-files, otherwise a warning. index.mf at
// the top of the base directory is never reported as absent from the
// manifest, even when check is given another manifest or a URL, since gen
// and fetch write the manifest there.
func findExtraFiles( func findExtraFiles(
ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64, ctx context.Context, cmd *cli.Command, chk *mfer.Checker, failures *int64,
) error { ) error {
@@ -204,6 +207,10 @@ func findExtraFiles(
go func() { go func() {
for result := range extraResults { for result := range extraResults {
if result.Status == mfer.StatusExtra && result.Path == defaultManifestName {
continue
}
if cmd.Bool("no-extra-files") { if cmd.Bool("no-extra-files") {
*failures++ *failures++
+284 -1
View File
@@ -36,6 +36,7 @@ const (
testManifest = "/manifest.mf" testManifest = "/manifest.mf"
testFlagBase = "--base" testFlagBase = "--base"
testFlagNoExtra = "--no-extra-files" testFlagNoExtra = "--no-extra-files"
testFlagFollowSymlinks = "--follow-symlinks"
testFlagVersion = "--version" testFlagVersion = "--version"
testFlagVerbose = "--verbose" testFlagVerbose = "--verbose"
testFlagHelp = "--help" testFlagHelp = "--help"
@@ -1002,7 +1003,7 @@ func TestCheckReportsUnlistedSymlinkToFileOnly(t *testing.T) {
exitCode int exitCode int
extras []string // what check reports as not in the manifest extras []string // what check reports as not in the manifest
}{ }{
"gen --follow-symlinks": {[]string{"--follow-symlinks"}, 0, nil}, "gen --follow-symlinks": {[]string{testFlagFollowSymlinks}, 0, nil},
"plain gen": {nil, 1, []string{"filelink"}}, "plain gen": {nil, 1, []string{"filelink"}},
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -1190,6 +1191,99 @@ func TestCheckURLManifestWithoutTempDirectory(t *testing.T) {
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
} }
// TestCheckNeverReportsIndexMFAtTopOfBase checks a tree holding index.mf at
// its top, where gen and fetch write the manifest, against a manifest that
// does not list it, given by URL and by a path outside the tree: check never
// reports it, and passes with and without --no-extra-files. index.mf in a
// subdirectory, and the temp file fetch saves index.mf through, are still
// reported.
func TestCheckNeverReportsIndexMFAtTopOfBase(t *testing.T) {
t.Parallel()
files := map[string][]byte{testFileTxt: []byte("hello")}
manifest := manifestOf(t, files)
server := httptest.NewServer(fetchTestHandler(manifest, files))
t.Cleanup(server.Close)
manifestOutsideTree := filepath.Join(t.TempDir(), "other.mf")
require.NoError(t, os.WriteFile(manifestOutsideTree, manifest, 0o600))
for name, manifestArg := range map[string]string{
"manifest by URL": server.URL + "/" + defaultManifestName,
"manifest by path": manifestOutsideTree,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
base := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(base, testFileTxt), files[testFileTxt], 0o600))
require.NoError(t, os.WriteFile(
filepath.Join(base, defaultManifestName), manifest, 0o600))
check := func(flags ...string) (int, string) {
opts := testOpts(slices.Concat(
[]string{testApp, cmdCheck}, flags,
[]string{testFlagBase, base, manifestArg},
), afero.NewOsFs())
return runCLI(opts), testStderr(t, opts)
}
exitCode, stderr := check()
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName)
exitCode, stderr = check(testFlagNoExtra)
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName)
reported := []string{
testSubdirManifest,
tempPathFor(defaultManifestName),
}
require.NoError(t, os.Mkdir(filepath.Join(base, testSubdir), 0o750))
for _, path := range reported {
require.NoError(t,
os.WriteFile(filepath.Join(base, path), manifest, 0o600))
}
exitCode, stderr = check(testFlagNoExtra)
assert.Equal(t, 1, exitCode, "stderr: %s", stderr)
assert.NotContains(t, stderr, "EXTRA: "+defaultManifestName)
for _, path := range reported {
assert.Contains(t, stderr, "EXTRA: "+path)
}
})
}
}
// TestCheckChecksListedIndexMF checks a tree against a manifest that lists
// index.mf at the top of the tree, where that file has changed since: check
// reports it like any other changed file.
func TestCheckChecksListedIndexMF(t *testing.T) {
t.Parallel()
manifestPath := filepath.Join(t.TempDir(), "other.mf")
require.NoError(t, os.WriteFile(manifestPath, manifestOf(t, map[string][]byte{
defaultManifestName: []byte("listed"),
}), 0o600))
base := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(base, defaultManifestName), []byte("edited"), 0o600))
opts := testOpts([]string{
testApp, cmdCheck, testFlagBase, base, manifestPath,
}, afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), "HASH_MISMATCH: "+defaultManifestName)
}
// unlistableDirFs is a filesystem on which one directory cannot be listed. // unlistableDirFs is a filesystem on which one directory cannot be listed.
type unlistableDirFs struct { type unlistableDirFs struct {
afero.Fs afero.Fs
@@ -1238,6 +1332,60 @@ func TestCheckWithUnlistableDirectory(t *testing.T) {
assert.Contains(t, testStderr(t, opts), "unlisted.txt") assert.Contains(t, testStderr(t, opts), "unlisted.txt")
} }
// TestCheckReportsUnreadableIndexMFAtTopOfBase has a directory named
// index.mf at the top of the base that cannot be listed. check leaves out
// only the report that index.mf is not in the manifest, so it reports the
// error and fails under --no-extra-files.
func TestCheckReportsUnreadableIndexMFAtTopOfBase(t *testing.T) {
t.Parallel()
unreadable := filepath.Join(testDir, defaultManifestName)
mem := afero.NewMemMapFs()
require.NoError(t, mem.MkdirAll(unreadable, 0o755))
writeTestFile(t, mem, testFile1, "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", testManifest, testDir,
}, mem)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
fs := unlistableDirFs{Fs: mem, dir: unreadable}
opts = testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest,
}, fs)
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), "ERROR: "+defaultManifestName)
}
// TestCheckReportsUpperCaseIndexMFAtTopOfBase checks a tree holding
// INDEX.MF at its top against a manifest that does not list it. Only the
// name index.mf, in exactly that letter case, is left out, so check reports
// INDEX.MF. The tree is in memory, where the two names are two files
// whatever the letter case rules of the host's filesystem.
func TestCheckReportsUpperCaseIndexMFAtTopOfBase(t *testing.T) {
t.Parallel()
upperCase := strings.ToUpper(defaultManifestName)
fs := afero.NewMemMapFs()
writeTestFile(t, fs, testFile1, "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", testManifest, testDir,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
writeTestFile(t, fs, filepath.Join(testDir, upperCase), "not listed")
opts = testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest,
}, fs)
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), "EXTRA: "+upperCase)
}
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1420,6 +1568,141 @@ func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) {
assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output)) assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output))
} }
// TestGenerateLeavesTopIndexMFOutOfListing runs gen with --output outside
// the paths it is given: index.mf at the top of each directory given, and
// a file given by that name, are not listed, since fetch saves the manifest
// as index.mf at the top of the tree, while sub/index.mf is listed.
func TestGenerateLeavesTopIndexMFOutOfListing(t *testing.T) {
t.Parallel()
// Paths are relative to a temp dir holding tree, which holds file.txt,
// index.mf and sub/index.mf, and second, which holds second.txt and
// index.mf.
for name, tc := range map[string]struct {
paths []string
listed []string
}{
"directory": {
[]string{"tree"},
[]string{testFileTxt, testSubdirManifest},
},
"two directories": {
[]string{"tree", "second"},
[]string{testFileTxt, testSubdirManifest, "second.txt"},
},
"files": {
[]string{"tree/" + testFileTxt, "tree/" + defaultManifestName},
[]string{testFileTxt},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
second := filepath.Join(root, "second")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750))
require.NoError(t, fs.MkdirAll(second, 0o750))
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(tree, defaultManifestName), "an ordinary file")
writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file")
writeTestFile(t, fs, filepath.Join(second, "second.txt"), "second")
writeTestFile(t, fs, filepath.Join(second, defaultManifestName), "an ordinary file")
paths := make([]string, 0, len(tc.paths))
for _, path := range tc.paths {
paths = append(paths, filepath.Join(root, path))
}
output := filepath.Join(root, "other.mf")
opts := testOpts(slices.Concat(
[]string{testApp, cmdGenerate, "-q", "-o", output}, paths,
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
})
}
}
// TestGenerateListsTargetOfTopIndexMFSymlink makes index.mf at the top of
// the tree a symlink to another file in it, and runs gen with --output
// outside the tree: the file it points to is listed under its own name,
// and the symlink, which gen lists only with --follow-symlinks, is left out
// like any index.mf at the top of the tree.
func TestGenerateListsTargetOfTopIndexMFSymlink(t *testing.T) {
t.Parallel()
// Paths are relative to tree, which holds file.txt, sub/index.mf and
// index.mf, a symlink to target.
for name, tc := range map[string]struct {
target string
flags []string
path string // the path gen is given
listed []string
}{
"directory given": {
testSubdirManifest, nil, ".",
[]string{testFileTxt, testSubdirManifest},
},
"directory given, --follow-symlinks": {
testSubdirManifest,
[]string{testFlagFollowSymlinks},
".",
[]string{testFileTxt, testSubdirManifest},
},
"file given": {
testFileTxt, nil, testFileTxt,
[]string{testFileTxt},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file")
require.NoError(t,
os.Symlink(tc.target, filepath.Join(tree, defaultManifestName)))
output := filepath.Join(root, "other.mf")
opts := testOpts(slices.Concat(
[]string{testApp, cmdGenerate, "-q", "-o", output}, tc.flags,
[]string{filepath.Join(tree, tc.path)},
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.listed, manifestPaths(t, fs, output))
})
}
}
// TestGenerateListsIndexMFBesideFileGiven runs gen with --output outside
// the tree, given sub/b.txt and the tree: sub/index.mf, beside the file
// given, is listed, since a file given is left out only when its own name
// is index.mf.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateListsIndexMFBesideFileGiven(t *testing.T) {
setupManifestInSubdir(t)
fs := afero.NewOsFs()
output := filepath.Join(t.TempDir(), "other.mf")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", output, filepath.Join(testSubdir, "b.txt"), ".",
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, manifestPaths(t, fs, output), testSubdirManifest)
}
// writeTestTree writes file.txt and sub/nested.txt under dir. // writeTestTree writes file.txt and sub/nested.txt under dir.
func writeTestTree(t *testing.T, fs afero.Fs, dir string) { func writeTestTree(t *testing.T, fs afero.Fs, dir string) {
t.Helper() t.Helper()
-7
View File
@@ -17,7 +17,6 @@ type ExportEntry struct {
Size int64 `json:"size"` Size int64 `json:"size"`
Hashes []string `json:"hashes"` Hashes []string `json:"hashes"`
Mtime *string `json:"mtime,omitempty"` Mtime *string `json:"mtime,omitempty"`
Ctime *string `json:"ctime,omitempty"`
Mode string `json:"mode"` // octal, "0000" when none was recorded Mode string `json:"mode"` // octal, "0000" when none was recorded
} }
@@ -61,12 +60,6 @@ func (mfa *CLIApp) exportManifestOperation(
entry.Mtime = &t entry.Mtime = &t
} }
if f.GetCtime() != nil {
t := time.Unix(f.GetCtime().GetSeconds(), int64(f.GetCtime().GetNanos())).
UTC().Format(time.RFC3339Nano)
entry.Ctime = &t
}
entries = append(entries, entry) entries = append(entries, entry)
} }
+32
View File
@@ -4,8 +4,10 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"maps"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -84,6 +86,36 @@ func TestExportManifestOperation(t *testing.T) {
assert.True(t, pathSet["sub/file.txt"]) assert.True(t, pathSet["sub/file.txt"])
} }
// export prints the fields of a file entry and nothing else, so no ctime.
func TestExportPrintsOnlyFileEntryFields(t *testing.T) {
t.Parallel()
manifestData := buildTestManifest(t, map[string][]byte{"entry.txt": []byte("aaa")})
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/test.mf", manifestData, 0o644))
var stdout, stderr bytes.Buffer
exitCode := runCLI(&RunOptions{
Appname: testApp,
Args: []string{testApp, testCmdExport, "/test.mf"},
Stdin: &bytes.Buffer{},
Stdout: &stdout,
Stderr: &stderr,
Fs: fs,
})
require.Equal(t, 0, exitCode, "stderr: %s", stderr.String())
var entries []map[string]json.RawMessage
require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries))
require.Len(t, entries, 1)
assert.Equal(t,
[]string{"hashes", "mode", "mtime", "path", "size"},
slices.Sorted(maps.Keys(entries[0])))
}
func TestExportFromHTTPURL(t *testing.T) { func TestExportFromHTTPURL(t *testing.T) {
t.Parallel() t.Parallel()
+3 -16
View File
@@ -95,9 +95,6 @@ var (
// writes another file. // writes another file.
errNameClash = errors.New( errNameClash = errors.New(
"manifest lists a file where fetch writes another file") "manifest lists a file where fetch writes another file")
// errModeOutOfRange indicates a manifest that lists a mode with more
// than the permission bits, such as setuid.
errModeOutOfRange = errors.New("manifest lists a mode outside 0777")
) )
// DownloadProgress reports the progress of a single file download. // DownloadProgress reports the progress of a single file download.
@@ -428,9 +425,9 @@ func (mfa *CLIApp) fetchManifestOperation(
// fetchManifest downloads the manifest at manifestURL and parses it, // fetchManifest downloads the manifest at manifestURL and parses it,
// enforcing --require-signature if it is given and refusing a manifest // enforcing --require-signature if it is given and refusing a manifest
// that lists a file where fetch writes another or a mode outside 0777. It // that lists a file where fetch writes another. It returns the manifest as
// returns the manifest as downloaded, to be saved once the files are in // downloaded, to be saved once the files are in place, and the files it
// place, and the files it lists. // lists.
func (mfa *CLIApp) fetchManifest( func (mfa *CLIApp) fetchManifest(
ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string, ctx context.Context, cmd *cli.Command, client retryingClient, manifestURL string,
) ([]byte, []*mfer.MFFilePath, error) { ) ([]byte, []*mfer.MFFilePath, error) {
@@ -487,16 +484,6 @@ func (mfa *CLIApp) fetchManifest(
return nil, nil, err return nil, nil, err
} }
// fetch sets each recorded mode on the file it writes, so a mode above
// 0777, which could carry setuid, setgid or sticky bits, is refused
// before any file is requested.
for _, f := range files {
if f.GetMode() > uint32(os.ModePerm) {
return nil, nil, fmt.Errorf("%w: %s (%#o)",
errModeOutOfRange, f.GetPath(), f.GetMode())
}
}
log.Infof("manifest contains %d files", len(files)) log.Infof("manifest contains %d files", len(files))
return manifestData, files, nil return manifestData, files, nil
+1 -67
View File
@@ -19,15 +19,12 @@ import (
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"uuid"
"github.com/klauspost/compress/zstd"
"github.com/multiformats/go-multihash" "github.com/multiformats/go-multihash"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v3" urfcli "github.com/urfave/cli/v3"
"google.golang.org/protobuf/proto"
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
) )
@@ -1535,27 +1532,9 @@ func TestFetchSetsRecordedMode(t *testing.T) {
[]string{"/" + defaultManifestName, "/" + testFileTxt}, requested) []string{"/" + defaultManifestName, "/" + testFileTxt}, requested)
} }
// TestFetchRefusesModeOutsidePermissionBits fetches manifests that record
// a mode with the setuid bit, once as Unix writes it (04755) and once as
// Go keeps it in os.FileMode. fetch must refuse both before it creates the
// destination or requests any file.
func TestFetchRefusesModeOutsidePermissionBits(t *testing.T) {
t.Parallel()
files := map[string][]byte{testFileTxt: []byte("a file")}
assertFetchRefused(t, manifestWithMode(t, testFileTxt, files[testFileTxt], 0o4755),
files, "manifest lists a mode outside 0777: file.txt (04755)")
assertFetchRefused(t,
manifestWithMode(t, testFileTxt, files[testFileTxt],
uint32(os.ModeSetuid|0o755)),
files, "manifest lists a mode outside 0777: file.txt (040000755)")
}
// TestDownloadFileSetsOnlyPermissionBits downloads a file whose entry // TestDownloadFileSetsOnlyPermissionBits downloads a file whose entry
// records mode 0755 together with setuid, setgid or sticky, as Go keeps // records mode 0755 together with setuid, setgid or sticky, as Go keeps
// them in os.FileMode. fetchManifest would refuse such an entry; called // them in os.FileMode. Loading a manifest refuses such an entry; called
// directly, downloadFile must still set only the permission bits. // directly, downloadFile must still set only the permission bits.
func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) { func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1592,51 +1571,6 @@ func TestDownloadFileSetsOnlyPermissionBits(t *testing.T) {
} }
} }
// manifestWithMode returns a manifest listing one file, path, with content
// and the given recorded mode. It is assembled by hand, since the builder
// never records a mode outside 0777.
func manifestWithMode(t *testing.T, path string, content []byte, mode uint32) []byte {
t.Helper()
digest := sha256.Sum256(content)
hash, err := multihash.Encode(digest[:], multihash.SHA2_256)
require.NoError(t, err)
id := uuid.NewV4()
inner, err := proto.Marshal(&mfer.MFFile{
Version: mfer.MFFile_VERSION_ONE,
Files: []*mfer.MFFilePath{{
Path: path,
Size: int64(len(content)),
Hashes: []*mfer.MFFileChecksum{{MultiHash: hash}},
Mode: mode,
}},
Uuid: id[:],
})
require.NoError(t, err)
encoder, err := zstd.NewWriter(nil)
require.NoError(t, err)
compressed := encoder.EncodeAll(inner, nil)
require.NoError(t, encoder.Close())
sum := sha256.Sum256(compressed)
outer, err := proto.Marshal(&mfer.MFFileOuter{
Version: mfer.MFFileOuter_VERSION_ONE,
CompressionType: mfer.MFFileOuter_COMPRESSION_ZSTD,
Size: int64(len(inner)),
Sha256: sum[:],
Uuid: id[:],
InnerMessage: compressed,
})
require.NoError(t, err)
return append([]byte(mfer.MAGIC), outer...)
}
// 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. // scanned, since a scan lists no hidden files.
func builtManifest(t *testing.T, files map[string][]byte) []byte { func builtManifest(t *testing.T, files map[string][]byte) []byte {
+6
View File
@@ -192,6 +192,12 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
return nil return nil
} }
// Leave out a file listed as index.mf, at the top of the base
// directory, since fetch saves the manifest there, as gen does.
if relPath == defaultManifestName {
return nil
}
// Handle symlinks // Handle symlinks
if info.Mode()&fs.ModeSymlink != 0 { if info.Mode()&fs.ModeSymlink != 0 {
realInfo, keep := s.resolveSymlink(path) realInfo, keep := s.resolveSymlink(path)
+71
View File
@@ -413,6 +413,77 @@ func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) {
manifestPaths(t, fs, manifestPath)) manifestPaths(t, fs, manifestPath))
} }
// TestFreshenLeavesTopIndexMFOutOfListing freshens a manifest kept outside
// the directory --base names, after index.mf and sub/index.mf are added
// there: index.mf at the top is not listed, since fetch saves the manifest
// there, while sub/index.mf is listed.
func TestFreshenLeavesTopIndexMFOutOfListing(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
manifestPath := filepath.Join(root, "other.mf")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, tree}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
writeTestFile(t, fs, filepath.Join(tree, defaultManifestName), "an ordinary file")
writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file")
runFreshen(t, fs, tree, manifestPath)
assert.ElementsMatch(t, []string{testFileTxt, testSubdirManifest},
manifestPaths(t, fs, manifestPath))
}
// TestFreshenListsTargetOfTopIndexMFSymlink freshens a manifest kept
// outside the directory --base names, after sub/index.mf is added there
// and index.mf at the top is made a symlink to it: sub/index.mf is listed,
// and the symlink, which freshen lists only with --follow-symlinks, is left
// out like any index.mf at the top.
func TestFreshenListsTargetOfTopIndexMFSymlink(t *testing.T) {
t.Parallel()
for name, flags := range map[string][]string{
"plain freshen": nil,
"freshen --follow-symlinks": {testFlagFollowSymlinks},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "tree")
manifestPath := filepath.Join(root, "other.mf")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, testSubdir), 0o750))
writeTestFile(t, fs, filepath.Join(tree, testFileTxt), "hello")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "-o", manifestPath, tree,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
writeTestFile(t, fs, filepath.Join(tree, testSubdirManifest), "an ordinary file")
require.NoError(t, os.Symlink(testSubdirManifest,
filepath.Join(tree, defaultManifestName)))
opts = testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, flags,
[]string{testFlagBase, tree, manifestPath},
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{testFileTxt, testSubdirManifest},
manifestPaths(t, fs, manifestPath))
})
}
}
// TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then // TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then
// runs freshen from the directory above sub, on the manifest in sub. // runs freshen from the directory above sub, on the manifest in sub.
// Without --base, the manifest then lists the files in sub, whether freshen // Without --base, the manifest then lists the files in sub, whether freshen
+3
View File
@@ -137,6 +137,9 @@ func (mfa *CLIApp) buildScannerOptions(
// Neither a manifest being replaced nor a temp file left by an // Neither a manifest being replaced nor a temp file left by an
// interrupted run belongs in the new manifest. // interrupted run belongs in the new manifest.
ExcludePaths: []string{output, manifestTempPath(output)}, ExcludePaths: []string{output, manifestTempPath(output)},
// Nor does a file listed as index.mf, at the top of the tree, since
// fetch saves the manifest there and refuses a manifest that lists it.
ExcludeRelPaths: []mfer.RelFilePath{defaultManifestName},
} }
// Set seed for deterministic UUID if provided // Set seed for deterministic UUID if provided
+1 -1
View File
@@ -81,7 +81,7 @@ func TestFreshenSkipsPipes(t *testing.T) {
require.NoError(t, os.Symlink(target, filepath.Join(root, testLink))) require.NoError(t, os.Symlink(target, filepath.Join(root, testLink)))
opts := testOpts([]string{ opts := testOpts([]string{
testApp, cmdFreshen, "-v", "--follow-symlinks", testApp, cmdFreshen, "-v", testFlagFollowSymlinks,
testFlagBase, root, manifestPath, testFlagBase, root, manifestPath,
}, fs) }, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
+7 -10
View File
@@ -23,23 +23,20 @@ const (
// that decoding sets aside a fixed amount of memory for. // that decoding sets aside a fixed amount of memory for.
filesFieldNumber = 101 filesFieldNumber = 101
hashesFieldNumber = 3 hashesFieldNumber = 3
mimeTypeFieldNumber = 301
mtimeFieldNumber = 302 mtimeFieldNumber = 302
ctimeFieldNumber = 303
// Bytes decoding sets aside for each file entry, hash, timestamp and // Bytes decoding sets aside for each file entry, hash and timestamp,
// MIME type, however short its encoding. checkDecodedSize refuses an // however short its encoding. checkDecodedSize refuses an inner
// inner message for which these add up to more than maxDecodedGrowth // message for which these add up to more than maxDecodedGrowth times
// times its size. The mode is held in the file entry itself. // its size. The mode is held in the file entry itself.
decodedFileEntrySize = 176 decodedFileEntrySize = 160
decodedHashSize = 112 decodedHashSize = 112
decodedTimestampSize = 64 decodedTimestampSize = 64
decodedMIMETypeSize = 16
// Each file entry mfer writes holds a path of at least one byte, a // Each file entry mfer writes holds a path of at least one byte, a
// multihash at least as long as SHA-256's 34 bytes (AddFileWithHash // multihash at least as long as SHA-256's 34 bytes (AddFileWithHash
// refuses shorter ones) and a modification time: at least 47 bytes, // refuses shorter ones) and a modification time: at least 47 bytes,
// counted at 352. So its manifests add up to at most about 7.49 times // counted at 336. So its manifests add up to at most about 7.15 times
// their size, and this limit is about 7% above that. // their size, and this limit is about 12% above that.
maxDecodedGrowth = 8 maxDecodedGrowth = 8
) )
+14 -10
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"strings" "strings"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
@@ -29,6 +30,7 @@ var (
errInvalidManifestPath = errors.New("invalid file entry") errInvalidManifestPath = errors.New("invalid file entry")
errEntryMissingHash = errors.New("file entry has no SHA-256 hash") errEntryMissingHash = errors.New("file entry has no SHA-256 hash")
errEntryExtraSHA256Hash = errors.New("file entry has more than one SHA-256 hash") errEntryExtraSHA256Hash = errors.New("file entry has more than one SHA-256 hash")
errModeOutOfRange = errors.New("file entry has a mode above 0777")
errDecodedTooLarge = errors.New("too much memory needed") errDecodedTooLarge = errors.New("too much memory needed")
errSignerNotSigningKey = errors.New( errSignerNotSigningKey = errors.New(
"signer is not the fingerprint of the key that made the signature") "signer is not the fingerprint of the key that made the signature")
@@ -159,10 +161,10 @@ func (m *manifest) decompressInner() ([]byte, error) {
} }
// checkDecodedSize refuses an encoded inner message whose file entries, // checkDecodedSize refuses an encoded inner message whose file entries,
// hashes, timestamps and MIME types would take more than maxDecodedGrowth // hashes and timestamps would take more than maxDecodedGrowth times its
// times its size to decode. Decoding sets aside a fixed amount for each, // size to decode. Decoding sets aside a fixed amount for each, however
// however short its encoding, so a message of empty ones would take about // short its encoding, so a message of empty ones would take about 50 times
// 50 times its size. // its size.
func checkDecodedSize(inner []byte) error { func checkDecodedSize(inner []byte) error {
limit := maxDecodedGrowth * int64(len(inner)) limit := maxDecodedGrowth * int64(len(inner))
@@ -192,14 +194,10 @@ func checkDecodedSize(inner []byte) error {
return add(decodedHashSize) return add(decodedHashSize)
} }
if num == mtimeFieldNumber || num == ctimeFieldNumber { if num == mtimeFieldNumber {
return add(decodedTimestampSize) return add(decodedTimestampSize)
} }
if num == mimeTypeFieldNumber {
return add(decodedMIMETypeSize)
}
return nil return nil
}) })
}) })
@@ -320,7 +318,9 @@ func (m *manifest) deserializeInner() error {
// dropping entries, which would let a hostile manifest hide files from a // dropping entries, which would let a hostile manifest hide files from a
// check. A path listed twice is refused too: check would check the one // check. A path listed twice is refused too: check would check the one
// file against both entries. So is an entry that does not list exactly // file against both entries. So is an entry that does not list exactly
// one SHA-256 hash. // one SHA-256 hash, and one that records a mode above 0777, such as one
// with setuid: a mode holds the permission bits alone, which are all
// check compares and fetch sets.
seen := make(map[string]bool, len(m.pbInner.GetFiles())) seen := make(map[string]bool, len(m.pbInner.GetFiles()))
for _, f := range m.pbInner.GetFiles() { for _, f := range m.pbInner.GetFiles() {
@@ -334,6 +334,10 @@ func (m *manifest) deserializeInner() error {
return err return err
} }
if f.GetMode() > uint32(os.ModePerm) {
return fmt.Errorf("%w: %q (%#o)", errModeOutOfRange, f.GetPath(), f.GetMode())
}
if seen[f.GetPath()] { if seen[f.GetPath()] {
return fmt.Errorf("%w %q", errDuplicatePath, f.GetPath()) return fmt.Errorf("%w %q", errDuplicatePath, f.GetPath())
} }
+2 -2
View File
@@ -48,8 +48,8 @@ func FuzzNewManifestFromReader(f *testing.F) {
// It also keeps a few copies of its input. Buffers grow by // It also keeps a few copies of its input. Buffers grow by
// copying, so reaching those sizes allocates up to about six times // copying, so reaching those sizes allocates up to about six times
// them in total. Decoding the decompressed data takes up to // them in total. Decoding the decompressed data takes up to
// maxDecodedGrowth times its size for file entries, hashes, // maxDecodedGrowth times its size for file entries, hashes and
// timestamps and MIME types, and drops fields it does not know. // timestamps, and drops fields it does not know.
// The strings and bytes it copies out of it, such as many one-byte // The strings and bytes it copies out of it, such as many one-byte
// values in one hash, take up to about five times more under the // values in one hash, take up to about five times more under the
// race detector, which pads every small copy to 16 bytes, and about // race detector, which pads every small copy to 16 bytes, and about
+113 -13
View File
@@ -7,6 +7,7 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/sha512" "crypto/sha512"
"fmt" "fmt"
"os"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -234,14 +235,77 @@ func TestDeserializeRequiresExactlyOneSHA256Hash(t *testing.T) {
} }
} }
// Entries of a path, a SHA-256 hash, an empty MIME type and empty // A file entry that records a mode with more than the permission bits is
// modification and change times are counted at 432 bytes each (176 + 112 + // refused as it is loaded, naming the path and the mode: once with setuid as
// 16 + 64 + 64) and take 52 bytes plus the path to encode. A one-character // Unix writes it (04755) and once as Go keeps it in os.FileMode. An entry
// path makes that 53 bytes, about 8.2 times: refused, and leaving any one of // recording 0777 loads.
// the five uncounted, even the MIME type, brings it under 8. A two-character func TestDeserializeRefusesModeOutsidePermissionBits(t *testing.T) {
// path makes it 54 bytes, 8 times exactly: loaded. There is one entry for t.Parallel()
// each base-36 digit, and each entry's path is its number in base 36, padded
// with zeros to that length, since a manifest lists a path only once. hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
require.NoError(t, err)
tests := []struct {
name string
mode uint32
want string // "" for an entry that loads
}{
{
"setuid as Unix writes it",
0o4755,
`file entry has a mode above 0777: "x" (04755)`,
},
{
"setuid as Go keeps it",
uint32(os.ModeSetuid | 0o755),
`file entry has a mode above 0777: "x" (040000755)`,
},
{"permission bits only", 0o777, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
id := uuid.NewV4()
inner, err := proto.Marshal(&MFFile{
Version: MFFile_VERSION_ONE,
Files: []*MFFilePath{{
Path: "x",
Size: 1,
Hashes: []*MFFileChecksum{{MultiHash: hash}},
Mode: tt.mode,
}},
Uuid: id[:],
})
require.NoError(t, err)
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
if tt.want == "" {
require.NoError(t, err)
require.Len(t, m.Files(), 1)
assert.Equal(t, tt.mode, m.Files()[0].GetMode())
return
}
require.ErrorIs(t, err, errModeOutOfRange)
require.EqualError(t, err, tt.want)
})
}
}
// Entries of a path, a SHA-256 hash, an empty second hash and an empty
// modification time are counted at 448 bytes each (160 + 112 + 112 + 64)
// and take 48 bytes plus the path to encode. A 7-character path makes that
// 55 bytes, about 8.1 times: refused, and leaving any one of the four
// uncounted, even the modification time, brings it under 8. An 8-character
// path makes it 56 bytes, 8 times exactly: loaded. Without the second hash
// an entry is never refused: it takes at least 47 bytes and is counted at
// 336. There is one entry for each base-36 digit, enough that the 22 bytes
// of the version and UUID change neither outcome, and each entry's path is
// its number in base 36, padded with zeros to that length, since a manifest
// lists a path only once.
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) { func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
t.Parallel() t.Parallel()
@@ -255,8 +319,8 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
pathLen int pathLen int
refused bool refused bool
}{ }{
{1, true}, {7, true},
{2, false}, {8, false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -277,12 +341,10 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
entry = protowire.AppendString(entry, path) entry = protowire.AppendString(entry, path)
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
entry = protowire.AppendBytes(entry, checksum) entry = protowire.AppendBytes(entry, checksum)
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // MFFilePath.mimeType entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
entry = protowire.AppendBytes(entry, nil) entry = protowire.AppendBytes(entry, nil)
entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime entry = protowire.AppendTag(entry, 302, protowire.BytesType) // MFFilePath.mtime
entry = protowire.AppendBytes(entry, nil) entry = protowire.AppendBytes(entry, nil)
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // MFFilePath.ctime
entry = protowire.AppendBytes(entry, nil)
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendBytes(inner, entry) inner = protowire.AppendBytes(inner, entry)
@@ -338,6 +400,44 @@ func TestDeserializeDropsUnknownFields(t *testing.T) {
assert.Empty(t, m.Files()[0].ProtoReflect().GetUnknown()) assert.Empty(t, m.Files()[0].ProtoReflect().GetUnknown())
} }
// A file entry holding fields 301 and 303, which mf.proto reserves, loads,
// and the loaded entry keeps neither.
func TestDeserializeDropsReservedFields(t *testing.T) {
t.Parallel()
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
require.NoError(t, err)
checksum, err := proto.Marshal(&MFFileChecksum{MultiHash: hash})
require.NoError(t, err)
timestamp, err := proto.Marshal(&Timestamp{Seconds: 1})
require.NoError(t, err)
entry := protowire.AppendTag(nil, 1, protowire.BytesType) // MFFilePath.path
entry = protowire.AppendString(entry, "a")
entry = protowire.AppendTag(entry, 3, protowire.BytesType) // MFFilePath.hashes
entry = protowire.AppendBytes(entry, checksum)
entry = protowire.AppendTag(entry, 301, protowire.BytesType) // reserved
entry = protowire.AppendString(entry, "text/plain")
entry = protowire.AppendTag(entry, 303, protowire.BytesType) // reserved
entry = protowire.AppendBytes(entry, timestamp)
id := uuid.NewV4()
inner := protowire.AppendTag(nil, 100, protowire.VarintType) // MFFile.version
inner = protowire.AppendVarint(inner, uint64(MFFile_VERSION_ONE))
inner = protowire.AppendTag(inner, 101, protowire.BytesType) // MFFile.files
inner = protowire.AppendBytes(inner, entry)
inner = protowire.AppendTag(inner, 102, protowire.BytesType) // MFFile.uuid
inner = protowire.AppendBytes(inner, id[:])
m, err := NewManifestFromReader(bytes.NewReader(wrapInner(t, id, inner)))
require.NoError(t, err)
require.Len(t, m.Files(), 1)
assert.Equal(t, "a", m.Files()[0].GetPath())
assert.Empty(t, m.Files()[0].ProtoReflect().GetUnknown())
}
// Many empty files with names of at most three characters and modification // Many empty files with names of at most three characters and modification
// times at the epoch make about the densest manifest mfer writes: it takes // times at the epoch make about the densest manifest mfer writes: it takes
// about 7 times its size to decode, and still loads. A signature would not // about 7 times its size to decode, and still loads. A signature would not
+13 -35
View File
@@ -340,9 +340,7 @@ type MFFilePath struct {
// gotta have at least one: // gotta have at least one:
Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"` Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"`
// optional per-file metadata // optional per-file metadata
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"` Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
// permission bits, at most 0777; 0 when not recorded // permission bits, at most 0777; 0 when not recorded
Mode uint32 `protobuf:"varint,304,opt,name=mode,proto3" json:"mode,omitempty"` Mode uint32 `protobuf:"varint,304,opt,name=mode,proto3" json:"mode,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@@ -400,13 +398,6 @@ func (x *MFFilePath) GetHashes() []*MFFileChecksum {
return nil return nil
} }
func (x *MFFilePath) GetMimeType() string {
if x != nil && x.MimeType != nil {
return *x.MimeType
}
return ""
}
func (x *MFFilePath) GetMtime() *Timestamp { func (x *MFFilePath) GetMtime() *Timestamp {
if x != nil { if x != nil {
return x.Mtime return x.Mtime
@@ -414,13 +405,6 @@ func (x *MFFilePath) GetMtime() *Timestamp {
return nil return nil
} }
func (x *MFFilePath) GetCtime() *Timestamp {
if x != nil {
return x.Ctime
}
return nil
}
func (x *MFFilePath) GetMode() uint32 { func (x *MFFilePath) GetMode() uint32 {
if x != nil { if x != nil {
return x.Mode return x.Mode
@@ -573,21 +557,16 @@ const file_mf_proto_rawDesc = "" +
"\n" + "\n" +
"_signatureB\t\n" + "_signatureB\t\n" +
"\a_signerB\x10\n" + "\a_signerB\x10\n" +
"\x0e_signingPubKey\"\x85\x02\n" + "\x0e_signingPubKey\"\xc5\x01\n" +
"\n" + "\n" +
"MFFilePath\x12\x12\n" + "MFFilePath\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
"\x04size\x18\x02 \x01(\x03R\x04size\x12'\n" + "\x04size\x18\x02 \x01(\x03R\x04size\x12'\n" +
"\x06hashes\x18\x03 \x03(\v2\x0f.MFFileChecksumR\x06hashes\x12 \n" + "\x06hashes\x18\x03 \x03(\v2\x0f.MFFileChecksumR\x06hashes\x12&\n" +
"\bmimeType\x18\xad\x02 \x01(\tH\x00R\bmimeType\x88\x01\x01\x12&\n" +
"\x05mtime\x18\xae\x02 \x01(\v2\n" + "\x05mtime\x18\xae\x02 \x01(\v2\n" +
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" + ".TimestampH\x00R\x05mtime\x88\x01\x01\x12\x13\n" +
"\x05ctime\x18\xaf\x02 \x01(\v2\n" + "\x04mode\x18\xb0\x02 \x01(\rR\x04modeB\b\n" +
".TimestampH\x02R\x05ctime\x88\x01\x01\x12\x13\n" + "\x06_mtimeJ\x06\b\xad\x02\x10\xae\x02J\x06\b\xaf\x02\x10\xb0\x02R\bmimeTypeR\x05ctime\".\n" +
"\x04mode\x18\xb0\x02 \x01(\rR\x04modeB\v\n" +
"\t_mimeTypeB\b\n" +
"\x06_mtimeB\b\n" +
"\x06_ctime\".\n" +
"\x0eMFFileChecksum\x12\x1c\n" + "\x0eMFFileChecksum\x12\x1c\n" +
"\tmultiHash\x18\x01 \x01(\fR\tmultiHash\"\xd6\x01\n" + "\tmultiHash\x18\x01 \x01(\fR\tmultiHash\"\xd6\x01\n" +
"\x06MFFile\x12)\n" + "\x06MFFile\x12)\n" +
@@ -631,15 +610,14 @@ var file_mf_proto_depIdxs = []int32{
1, // 1: MFFileOuter.compressionType:type_name -> MFFileOuter.CompressionType 1, // 1: MFFileOuter.compressionType:type_name -> MFFileOuter.CompressionType
6, // 2: MFFilePath.hashes:type_name -> MFFileChecksum 6, // 2: MFFilePath.hashes:type_name -> MFFileChecksum
3, // 3: MFFilePath.mtime:type_name -> Timestamp 3, // 3: MFFilePath.mtime:type_name -> Timestamp
3, // 4: MFFilePath.ctime:type_name -> Timestamp 2, // 4: MFFile.version:type_name -> MFFile.Version
2, // 5: MFFile.version:type_name -> MFFile.Version 5, // 5: MFFile.files:type_name -> MFFilePath
5, // 6: MFFile.files:type_name -> MFFilePath 3, // 6: MFFile.createdAt:type_name -> Timestamp
3, // 7: MFFile.createdAt:type_name -> Timestamp 7, // [7:7] is the sub-list for method output_type
8, // [8:8] is the sub-list for method output_type 7, // [7:7] is the sub-list for method input_type
8, // [8:8] is the sub-list for method input_type 7, // [7:7] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension type_name 7, // [7:7] is the sub-list for extension extendee
8, // [8:8] is the sub-list for extension extendee 0, // [0:7] is the sub-list for field type_name
0, // [0:8] is the sub-list for field type_name
} }
func init() { file_mf_proto_init() } func init() { file_mf_proto_init() }
+4 -2
View File
@@ -58,11 +58,13 @@ message MFFilePath {
repeated MFFileChecksum hashes = 3; repeated MFFileChecksum hashes = 3;
// optional per-file metadata // optional per-file metadata
optional string mimeType = 301;
optional Timestamp mtime = 302; optional Timestamp mtime = 302;
optional Timestamp ctime = 303;
// permission bits, at most 0777; 0 when not recorded // permission bits, at most 0777; 0 when not recorded
uint32 mode = 304; uint32 mode = 304;
// used by no field, so that a later version cannot reuse them by accident
reserved 301, 303;
reserved "mimeType", "ctime";
} }
message MFFileChecksum { message MFFileChecksum {
+1 -1
View File
@@ -1 +1 @@
1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto 16a2e6b39986d02aa1c7c01dc6b97c9d4c30db7a9d000056bd5985a3fa7c8089 mf.proto
+14 -2
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoreflect"
"sneak.berlin/go/mfer/mfer" "sneak.berlin/go/mfer/mfer"
@@ -38,9 +39,7 @@ func TestFileEntryFieldsMatchSpec(t *testing.T) {
"path": 1, "path": 1,
"size": 2, "size": 2,
"hashes": 3, "hashes": 3,
"mimeType": 301,
"mtime": 302, "mtime": 302,
"ctime": 303,
"mode": 304, "mode": 304,
} }
@@ -53,3 +52,16 @@ func TestFileEntryFieldsMatchSpec(t *testing.T) {
require.Equal(t, want, got) require.Equal(t, want, got)
} }
// A file entry reserves the numbers 301 and 303 and the names mimeType and
// ctime, as docs/FORMAT.md says, so that no field can take them.
func TestFileEntryReservesMIMETypeAndCtime(t *testing.T) {
t.Parallel()
desc := (&mfer.MFFilePath{}).ProtoReflect().Descriptor()
assert.True(t, desc.ReservedRanges().Has(301))
assert.True(t, desc.ReservedRanges().Has(303))
assert.True(t, desc.ReservedNames().Has("mimeType"))
assert.True(t, desc.ReservedNames().Has("ctime"))
}
+8 -6
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -64,6 +65,9 @@ type ScannerOptions struct {
Seed string Seed string
// ExcludePaths lists files to leave out of the listing, matched with os.SameFile. // ExcludePaths lists files to leave out of the listing, matched with os.SameFile.
ExcludePaths []string ExcludePaths []string
// ExcludeRelPaths lists paths to leave out of the listing, matched
// against the path a file would be listed by, with no file looked up.
ExcludeRelPaths []RelFilePath
} }
// FileEntry represents a file that has been enumerated. // FileEntry represents a file that has been enumerated.
@@ -72,7 +76,6 @@ type FileEntry struct {
AbsPath AbsFilePath // Absolute path (used for reading file content) AbsPath AbsFilePath // Absolute path (used for reading file content)
Size FileSize // File size in bytes Size FileSize // File size in bytes
Mtime ModTime // Last modification time Mtime ModTime // Last modification time
Ctime time.Time // Creation time (platform-dependent)
Mode fs.FileMode // Permission bits (Perm() of the file's mode) Mode fs.FileMode // Permission bits (Perm() of the file's mode)
} }
@@ -439,9 +442,10 @@ func (s *Scanner) enumerateFileWithInfo(
} }
// Clean the path - remove leading slash if present // Clean the path - remove leading slash if present
cleanPath := filePath cleanPath := strings.TrimPrefix(filePath, "/")
if len(cleanPath) > 0 && cleanPath[0] == '/' {
cleanPath = cleanPath[1:] if slices.Contains(s.options.ExcludeRelPaths, RelFilePath(cleanPath)) {
return nil
} }
// Compute absolute path for file reading // Compute absolute path for file reading
@@ -493,8 +497,6 @@ func (s *Scanner) enumerateFileWithInfo(
Size: FileSize(info.Size()), Size: FileSize(info.Size()),
Mtime: ModTime(info.ModTime()), Mtime: ModTime(info.ModTime()),
Mode: info.Mode().Perm(), Mode: info.Mode().Perm(),
// Note: Ctime not available from fs.FileInfo on all platforms
// Will need platform-specific code to extract it
} }
s.mu.Lock() s.mu.Lock()