Compare commits
3
Commits
b8c699adee
...
v1.0.0-rc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2deea2e34 | ||
|
|
033d8be5be | ||
|
|
f360f1c070 |
@@ -196,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
|
||||||
@@ -287,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
|
||||||
@@ -301,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
|
||||||
@@ -362,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`
|
||||||
|
|||||||
+17
-16
@@ -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`)
|
||||||
|
|
||||||
@@ -80,15 +83,13 @@ 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 |
|
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
||||||
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
|
||||||
| `mode` | 304 | uint32 | Permission bits (see File Mode) |
|
|
||||||
|
|
||||||
## File Mode
|
## File Mode
|
||||||
|
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|
||||||
|
|||||||
+297
-14
@@ -27,19 +27,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
testApp = "mfer"
|
testApp = "mfer"
|
||||||
testGitrev = "abc123"
|
testGitrev = "abc123"
|
||||||
testDir = "/testdir"
|
testDir = "/testdir"
|
||||||
testFile1 = "/testdir/file1.txt"
|
testFile1 = "/testdir/file1.txt"
|
||||||
testMF = "/testdir/test.mf"
|
testMF = "/testdir/test.mf"
|
||||||
testOutput = "/output.mf"
|
testOutput = "/output.mf"
|
||||||
testManifest = "/manifest.mf"
|
testManifest = "/manifest.mf"
|
||||||
testFlagBase = "--base"
|
testFlagBase = "--base"
|
||||||
testFlagNoExtra = "--no-extra-files"
|
testFlagNoExtra = "--no-extra-files"
|
||||||
testFlagVersion = "--version"
|
testFlagFollowSymlinks = "--follow-symlinks"
|
||||||
testFlagVerbose = "--verbose"
|
testFlagVersion = "--version"
|
||||||
testFlagHelp = "--help"
|
testFlagVerbose = "--verbose"
|
||||||
testCmdHelp = "help"
|
testFlagHelp = "--help"
|
||||||
|
testCmdHelp = "help"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errSimulatedWrite = errors.New("simulated write failure")
|
var errSimulatedWrite = errors.New("simulated write failure")
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+10
-13
@@ -21,25 +21,22 @@ const (
|
|||||||
|
|
||||||
// Numbers in mf.proto of MFFile.files and of the MFFilePath fields
|
// Numbers in mf.proto of MFFile.files and of the MFFilePath fields
|
||||||
// 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
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-9
@@ -161,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))
|
||||||
|
|
||||||
@@ -194,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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -295,14 +295,17 @@ func TestDeserializeRefusesModeOutsidePermissionBits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entries of a path, a SHA-256 hash, an empty MIME type and empty
|
// Entries of a path, a SHA-256 hash, an empty second hash and an empty
|
||||||
// modification and change times are counted at 432 bytes each (176 + 112 +
|
// modification time are counted at 448 bytes each (160 + 112 + 112 + 64)
|
||||||
// 16 + 64 + 64) and take 52 bytes plus the path to encode. A one-character
|
// and take 48 bytes plus the path to encode. A 7-character path makes that
|
||||||
// path makes that 53 bytes, about 8.2 times: refused, and leaving any one of
|
// 55 bytes, about 8.1 times: refused, and leaving any one of the four
|
||||||
// the five uncounted, even the MIME type, brings it under 8. A two-character
|
// uncounted, even the modification time, brings it under 8. An 8-character
|
||||||
// path makes it 54 bytes, 8 times exactly: loaded. There is one entry for
|
// path makes it 56 bytes, 8 times exactly: loaded. Without the second hash
|
||||||
// each base-36 digit, and each entry's path is its number in base 36, padded
|
// an entry is never refused: it takes at least 47 bytes and is counted at
|
||||||
// with zeros to that length, since a manifest lists a path only once.
|
// 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()
|
||||||
|
|
||||||
@@ -316,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 {
|
||||||
@@ -338,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)
|
||||||
@@ -399,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
|
||||||
|
|||||||
+14
-36
@@ -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
@@ -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 @@
|
|||||||
1668b2f6b8639a9213873474e58dbacb3a4ef77cc27c42253cad8dd076e5f206 mf.proto
|
16a2e6b39986d02aa1c7c01dc6b97c9d4c30db7a9d000056bd5985a3fa7c8089 mf.proto
|
||||||
|
|||||||
+19
-7
@@ -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"
|
||||||
@@ -35,13 +36,11 @@ func TestFileEntryFieldsMatchSpec(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
want := map[string]protoreflect.FieldNumber{
|
want := map[string]protoreflect.FieldNumber{
|
||||||
"path": 1,
|
"path": 1,
|
||||||
"size": 2,
|
"size": 2,
|
||||||
"hashes": 3,
|
"hashes": 3,
|
||||||
"mimeType": 301,
|
"mtime": 302,
|
||||||
"mtime": 302,
|
"mode": 304,
|
||||||
"ctime": 303,
|
|
||||||
"mode": 304,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
got := map[string]protoreflect.FieldNumber{}
|
got := map[string]protoreflect.FieldNumber{}
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user