1 Commits
Author SHA1 Message Date
sneak 438b73eddf fetch: destination directory, skip files already present, save the manifest, require a signer (closes #101)
check / check (push) Failing after 3s
fetch takes --dest (default .) and writes every file there through the
existing symlink and hard-link guards, which now work relative to that
directory. A file already there with the listed size and hash is
skipped; a leftover temp file is still replaced. Once every file
verifies, the manifest is saved as index.mf through the same temp file
and rename, so check runs on the result. --require-signature is shared
with check and enforced through verifyRequiredSigner, on a Checker over
the manifest held in memory, before anything is downloaded or written.

Model: opus-5-5
2026-10-04 15:20:55 +00:00
14 changed files with 222 additions and 531 deletions
+1 -2
View File
@@ -9,8 +9,7 @@ RUN go mod download
COPY . . COPY . .
# Go half of fmt-check only: this image has no node, so no prettier. The # Go half of fmt-check only: this image has no node, so no prettier. The
# markdown half runs in the mdfmt stage below. The image has no gofumpt # markdown half runs in the mdfmt stage below.
# either; script/gofumpt builds the version it pins with `go run`.
RUN make fmt-check-go RUN make fmt-check-go
# The linter directly, not `make lint`: script/lint builds this stage, and # The linter directly, not `make lint`: script/lint builds this stage, and
# there is no docker inside this build. # there is no docker inside this build.
+1 -1
View File
@@ -46,7 +46,7 @@ fmt-check:
# Halves of fmt-check, for environments that have only one toolchain: # Halves of fmt-check, for environments that have only one toolchain:
# the Docker lint stage has Go but no node, the markdown stage the reverse. # the Docker lint stage has Go but no node, the markdown stage the reverse.
fmt-check-go: fmt-check-go:
@script/gofumpt --check @script/fmt-check-go
fmt-check-md: fmt-check-md:
@script/prettier --check @script/prettier --check
+7 -15
View File
@@ -40,7 +40,7 @@ tree by URL:
bin/mfer gen . bin/mfer gen .
# Verify the files on disk against the manifest. Exits nonzero if any file # Verify the files on disk against the manifest. Exits nonzero if any file
# it lists is missing or corrupted; warns about files it does not list. # is missing or corrupted.
bin/mfer check index.mf bin/mfer check index.mf
# Download and cryptographically verify a tree published over HTTP into # Download and cryptographically verify a tree published over HTTP into
@@ -93,19 +93,16 @@ provide:
- `script/lint` — run `golangci-lint` in Docker: builds only the `lint` stage of - `script/lint` — run `golangci-lint` in Docker: builds only the `lint` stage of
the `Dockerfile` (the Go format check, then the linter), uncached so it runs the `Dockerfile` (the Go format check, then the linter), uncached so it runs
every time, then removes the image every time, then removes the image
- `script/fmt` — format all code and docs (writes): `script/gofumpt --write` and - `script/fmt` — format all code and docs (writes): `gofumpt` and
`script/prettier --write` `script/prettier --write`
- `script/gofumpt` — run `gofumpt` over every Go file in the repository in the
given mode, `--write` or `--check`, at the one version it pins (built on
demand by `go run`, so nothing installs it); `script/fmt`, `script/fmt-check`
and the Docker lint stage (`make fmt-check-go`) all go through it, so they
cannot disagree about Go formatting
- `script/prettier` — run prettier over the repository's canonical file set - `script/prettier` — run prettier over the repository's canonical file set
(Markdown and JSON, minus `.prettierignore`) in the given mode, `--write` or (Markdown and JSON, minus `.prettierignore`) in the given mode, `--write` or
`--check`; the single definition of that file set, so `script/fmt` and `--check`; the single definition of that file set, so `script/fmt` and
`script/fmt-check` cannot disagree about it `script/fmt-check` cannot disagree about it
- `script/fmt-check` — check formatting without writing: - `script/fmt-check` — check formatting without writing: `script/fmt-check-go`
`script/gofumpt --check` plus `script/prettier --check` plus `script/prettier --check`
- `script/fmt-check-go` — the Go half of `script/fmt-check`, on its own, for the
Docker lint stage, whose image has no node
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check` - `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: builds the image with the same command as - `script/cibuild` — CI entrypoint: builds the image with the same command as
@@ -256,18 +253,13 @@ are now tracked only in the [issues](https://git.eeqj.de/sneak/mfer/issues).
- `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 nonzero if any files are missing or corrupted
- warns about each file under the base directory that the manifest does not
list, hidden files included; with `--no-extra-files` each one is a failure
instead
- `mfer fetch https://example.com/stuff/` - `mfer fetch https://example.com/stuff/`
- fetches `/stuff/index.mf` and downloads all files listed in manifest into - fetches `/stuff/index.mf` and downloads all files listed in manifest into
the current directory, or the one given with `--dest`, and assures the current directory, or the one given with `--dest`, and assures
cryptographic integrity of downloaded files. A file already there with the cryptographic integrity of downloaded files. A file already there with the
size and hash the manifest lists is skipped. Once every file is in place, size and hash the manifest lists is skipped. Once every file is in place,
the manifest is saved there as `index.mf`, so `mfer check` can verify the the manifest is saved there as `index.mf`, so `mfer check` can verify the
tree later. A manifest that lists `index.mf` (in any letter case) or tree later.
`.index.mf.tmp` at the top of the tree is refused before any file is
downloaded, since saving the manifest would replace it.
- `mfer fetch --require-signature <fingerprint> https://example.com/stuff/` - `mfer fetch --require-signature <fingerprint> https://example.com/stuff/`
- as above, but first refuses a manifest not signed by the key with that - as above, but first refuses a manifest not signed by the key with that
fingerprint, as `mfer check --require-signature` does, before downloading fingerprint, as `mfer check --require-signature` does, before downloading
+9 -11
View File
@@ -206,21 +206,16 @@ 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 under // manifest, counting each as a failure.
// --no-extra-files, otherwise a warning.
func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error { func findExtraFiles(ctx *cli.Context, chk *mfer.Checker, failures *int64) error {
extraResults := make(chan mfer.Result, 1) extraResults := make(chan mfer.Result, 1)
extraDone := make(chan struct{}) extraDone := make(chan struct{})
go func() { go func() {
for result := range extraResults { for result := range extraResults {
if ctx.Bool("no-extra-files") { *failures++
*failures++
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message) log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
} else {
log.Warnf("%s: %s (%s)", result.Status, result.Path, result.Message)
}
} }
close(extraDone) close(extraDone)
@@ -275,9 +270,12 @@ func runCheck(ctx *cli.Context, chk *mfer.Checker, showProgress bool) (int64, er
// Wait for results processing to complete // Wait for results processing to complete
<-done <-done
err = findExtraFiles(ctx, chk, &failures) // Check for extra files if requested
if err != nil { if ctx.Bool("no-extra-files") {
return 0, err err = findExtraFiles(ctx, chk, &failures)
if err != nil {
return 0, err
}
} }
return failures, nil return failures, nil
+16 -141
View File
@@ -705,155 +705,30 @@ func TestNoExtraFilesWithSubdirectory(t *testing.T) {
"check should fail when extra files exist in subdirectory") "check should fail when extra files exist in subdirectory")
} }
// TestCheckWarnsAboutUnlistedFiles adds one file the manifest does not list func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) {
// to a tree that had none: it gets one warning and the check passes, unless
// --no-extra-files makes it a failure. --quiet hides the warning, not the
// failure.
func TestCheckWarnsAboutUnlistedFiles(t *testing.T) {
t.Parallel() t.Parallel()
for name, unlisted := range map[string]string{ fs := afero.NewMemMapFs()
"regular file": "extra.txt",
"dotfile": ".hidden",
"file in hidden directory": ".git/config",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() // Create test file
require.NoError(t, fs.MkdirAll(testDir, 0o755)) require.NoError(t, fs.MkdirAll(testDir, 0o755))
writeTestFile(t, fs, testFile1, "hello") writeTestFile(t, fs, testFile1, "hello")
opts := testOpts([]string{ // Generate manifest
testApp, cmdGenerate, "-q", "-o", testManifest, testDir, opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs)
}, fs) exitCode := runCLI(opts)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) require.Equal(t, 0, exitCode)
check := func(flags ...string) (int, string) { // Add extra file
args := append([]string{testApp, cmdCheck, testFlagBase, testDir}, flags...) writeTestFile(t, fs, "/testdir/extra.txt", "extra")
opts := testOpts(append(args, testManifest), fs)
return runCLI(opts), testStderr(t, opts)
}
exitCode, stderr := check()
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
assert.NotContains(t, stderr, "not in manifest")
writeTestFile(t, fs, filepath.Join(testDir, unlisted), "unlisted")
exitCode, stderr = check()
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
assert.Equal(t, 1, strings.Count(stderr, "not in manifest"), stderr)
assert.Contains(t, stderr, unlisted)
exitCode, stderr = check("-q")
assert.Equal(t, 0, exitCode, "stderr: %s", stderr)
assert.NotContains(t, stderr, "not in manifest")
exitCode, stderr = check(testFlagNoExtra)
assert.Equal(t, 1, exitCode, "stderr: %s", stderr)
assert.Contains(t, stderr, unlisted)
exitCode, _ = check("-q", testFlagNoExtra)
assert.Equal(t, 1, exitCode)
})
}
}
// TestCheckNeverReportsManifest keeps the manifest inside the checked tree,
// under several names and path spellings, and names the tree both directly
// and through a symlink: the manifest is never reported, even under
// --no-extra-files. The manifest is recognized by file identity, which needs
// the real filesystem.
func TestCheckNeverReportsManifest(t *testing.T) {
t.Parallel()
// Manifest paths are relative to the checked tree.
for name, manifest := range map[string]string{
"default name": defaultManifestName,
"hidden name": ".index.mf",
"other name in a subdirectory": "sub/listing.mf",
"path spelled through ..": "sub/../index.mf",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
// A temp dir holding data/tree and link, a symlink to data.
root := t.TempDir()
tree := filepath.Join(root, "data", "tree")
// Not filepath.Join, which would clean away a "..".
manifestPath := tree + "/" + manifest
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(tree, "sub"), 0o750))
require.NoError(t,
os.Symlink(filepath.Join(root, "data"), filepath.Join(root, "link")))
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))
for _, base := range []string{tree, filepath.Join(root, "link", "tree")} {
opts = testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, testFlagBase, base, manifestPath,
}, fs)
assert.Equal(t, 0, runCLI(opts),
"base %s, stderr: %s", base, testStderr(t, opts))
assert.NotContains(t, testStderr(t, opts), "not in manifest")
}
})
}
}
// unlistableDirFs is a filesystem on which one directory cannot be listed.
type unlistableDirFs struct {
afero.Fs
dir string
}
//nolint:ireturn // Open must return afero.File to satisfy afero.Fs.
func (f unlistableDirFs) Open(name string) (afero.File, error) {
if name == f.dir {
return nil, os.ErrPermission
}
return f.Fs.Open(name)
}
// TestCheckWithUnlistableDirectory has a directory under the base that
// cannot be listed, followed by an unlisted file: both are warned about and
// the check still passes, unless --no-extra-files is given.
func TestCheckWithUnlistableDirectory(t *testing.T) {
t.Parallel()
mem := afero.NewMemMapFs()
require.NoError(t, mem.MkdirAll("/testdir/locked", 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))
// Directories are searched in name order, so this comes after "locked".
writeTestFile(t, mem, "/testdir/unlisted.txt", "unlisted")
fs := unlistableDirFs{Fs: mem, dir: "/testdir/locked"}
opts = testOpts([]string{testApp, cmdCheck, testFlagBase, testDir, testManifest}, fs)
assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Contains(t, testStderr(t, opts), os.ErrPermission.Error())
assert.Contains(t, testStderr(t, opts), "unlisted.txt")
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
opts = testOpts([]string{ opts = testOpts([]string{
testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest, testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
}, fs) }, fs)
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts)) exitCode = runCLI(opts)
assert.Contains(t, testStderr(t, opts), "unlisted.txt") assert.Equal(t, 0, exitCode,
"check without --no-extra-files should ignore extra files")
} }
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
+1 -31
View File
@@ -91,10 +91,6 @@ var (
// errHashMismatch indicates a downloaded file whose hash matches no // errHashMismatch indicates a downloaded file whose hash matches no
// manifest hash. // manifest hash.
errHashMismatch = errors.New("hash mismatch") errHashMismatch = errors.New("hash mismatch")
// errManifestNameListed indicates a manifest that lists a file where
// fetch saves the manifest.
errManifestNameListed = errors.New(
"manifest lists a file where fetch saves the manifest")
) )
// DownloadProgress reports the progress of a single file download. // DownloadProgress reports the progress of a single file download.
@@ -411,8 +407,7 @@ func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
} }
// 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. It returns the manifest as
// that lists a file where it will be saved. It returns the manifest as
// downloaded, to be saved once the files are in place, and the files it // downloaded, to be saved once the files are in place, and the files it
// lists. // lists.
func fetchManifest( func fetchManifest(
@@ -451,36 +446,11 @@ func fetchManifest(
} }
files := manifest.Files() files := manifest.Files()
err = checkManifestNameUnlisted(files)
if err != nil {
return nil, nil, err
}
log.Infof("manifest contains %d files", len(files)) log.Infof("manifest contains %d files", len(files))
return manifestData, files, nil return manifestData, files, nil
} }
// checkManifestNameUnlisted returns an error if files lists a file or
// directory at the top of the tree under the name fetch saves the
// manifest as, or under that name's temp file. Saving the manifest would
// replace or remove it, or fail once every file was downloaded, leaving a
// tree check rejects. Names are compared ignoring case, since on a
// case-insensitive filesystem INDEX.MF and index.mf are one file.
func checkManifestNameUnlisted(files []*mfer.MFFilePath) error {
for _, f := range files {
top, _, _ := strings.Cut(filepath.Clean(f.GetPath()), string(filepath.Separator))
if strings.EqualFold(top, defaultManifestName) ||
strings.EqualFold(top, tempPathFor(defaultManifestName)) {
return fmt.Errorf("%w: %s", errManifestNameListed, f.GetPath())
}
}
return nil
}
// verifyFetchedSigner enforces --require-signature on the fetched manifest // verifyFetchedSigner enforces --require-signature on the fetched manifest
// exactly as check does. verifyRequiredSigner takes a Checker, which loads // exactly as check does. verifyRequiredSigner takes a Checker, which loads
// its manifest from a file, so the manifest is handed to it as a file in // its manifest from a file, so the manifest is handed to it as a file in
+32 -136
View File
@@ -13,7 +13,6 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -514,51 +513,24 @@ func TestFetchProgress(t *testing.T) {
assert.Equal(t, content, downloaded) assert.Equal(t, content, downloaded)
} }
// TestFetchRefusesSymlinks runs fetch with --dest naming a directory other // TestFetchRefusesSymlinks runs fetch into a destination directory that
// than the current one, which holds a symlink pointing outside it, in each // holds a symlink pointing outside it, in each of the three places fetch
// place fetch writes: a parent directory, the temp file, the file itself, // writes: a parent directory, the temp file, and the file itself, which
// which the temp file is renamed onto, and the saved manifest's temp file // the temp file is renamed onto; and once as a directory inside a plain
// and final name; and once as a directory inside a plain directory. The // directory. The fetch must fail and nothing outside may change.
// fetch must fail, and neither the outside directory nor the current one
// may change.
// //
//nolint:paralleltest // changes the process-global working directory //nolint:paralleltest // changes the process-global working directory
func TestFetchRefusesSymlinks(t *testing.T) { func TestFetchRefusesSymlinks(t *testing.T) {
// What a link standing for a file points to: a file outside that does
// not exist yet.
const newFile = "new.txt"
tests := []struct { tests := []struct {
name string name string
entry string // the manifest's only file entry string // the manifest's only file
link string // symlink placed in the destination directory link string // symlink placed in the destination directory
target string // what link points to, relative to the outside directory target string // what link points to, relative to the outside directory
failure string // what fetch reports it was doing when it found link
}{ }{
{ {"parent directory", "sub/deeper/file.txt", "sub", "."},
"parent directory", "sub/deeper/file.txt", "sub", ".", {"directory inside a plain directory", "docs/data/passwd", "docs/data", "."},
"failed to download sub/deeper/file.txt", {"temp file", testFileTxt, ".file.txt.tmp", "new.txt"},
}, {"file", testFileTxt, testFileTxt, "new.txt"},
{
"directory inside a plain directory", "docs/data/passwd", "docs/data", ".",
"failed to download docs/data/passwd",
},
{
"temp file", testFileTxt, ".file.txt.tmp", newFile,
"failed to download " + testFileTxt,
},
{
"file", testFileTxt, testFileTxt, newFile,
"failed to download " + testFileTxt,
},
{
"manifest temp file", testFileTxt, tempPathFor(defaultManifestName), newFile,
"failed to save manifest",
},
{
"manifest", testFileTxt, defaultManifestName, newFile,
"failed to save manifest",
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -573,61 +545,23 @@ func TestFetchRefusesSymlinks(t *testing.T) {
defer server.Close() defer server.Close()
outside := t.TempDir() outside := t.TempDir()
cwd := chdirTemp(t)
dest := t.TempDir()
link := filepath.Join(dest, tt.link)
require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o750)) chdirTemp(t)
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), link)) require.NoError(t, os.MkdirAll(filepath.Dir(tt.link), 0o750))
require.NoError(t, os.Symlink(filepath.Join(outside, tt.target), tt.link))
opts := testOpts([]string{ opts := testOpts([]string{testApp, cmdFetch, "-q", server.URL}, afero.NewOsFs())
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
}, afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts)) assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), assert.Contains(t, testStderr(t, opts), "failed to download "+tt.entry+
tt.failure+": symlink in path not allowed: "+link) ": symlink in path not allowed: "+tt.link)
written, err := os.ReadDir(outside) written, err := os.ReadDir(outside)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, written, "fetch wrote outside the destination") assert.Empty(t, written, "fetch wrote outside the destination")
written, err = os.ReadDir(cwd)
require.NoError(t, err)
assert.Empty(t, written, "fetch wrote to the current directory")
}) })
} }
} }
// TestFetchDoesNotSkipThroughSymlink runs fetch with --dest holding a
// symlink to a directory outside it, where the file the manifest lists
// through that symlink already sits with the listed content. fetch must
// not take that file as already present: it must fail on the symlink, as
// the download would, and leave the outside file alone.
func TestFetchDoesNotSkipThroughSymlink(t *testing.T) {
t.Parallel()
content := []byte("fetched")
files := map[string][]byte{"sub/" + testFileTxt: content}
server := httptest.NewServer(fetchTestHandler(manifestOf(t, files), files))
defer server.Close()
outside := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(outside, testFileTxt), content, 0o600))
dest := t.TempDir()
link := filepath.Join(dest, "sub")
require.NoError(t, os.Symlink(outside, link))
opts := testOpts([]string{
testApp, cmdFetch, "-q", "--" + flagDest, dest, server.URL,
}, afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts),
"failed to download sub/"+testFileTxt+": symlink in path not allowed: "+link)
assert.Equal(t, map[string][]byte{testFileTxt: content}, filesUnder(t, outside))
}
// TestFetchReplacesHardLinkAtTempName runs fetch into a destination // TestFetchReplacesHardLinkAtTempName runs fetch into a destination
// directory that holds, at the temp file's name, a hard link to a file // directory that holds, at the temp file's name, a hard link to a file
// outside it. To fetch that is an ordinary leftover from an interrupted // outside it. To fetch that is an ordinary leftover from an interrupted
@@ -1127,9 +1061,8 @@ func TestFetchRequireSignature(t *testing.T) {
files := map[string][]byte{testFileTxt: []byte("signed file")} files := map[string][]byte{testFileTxt: []byte("signed file")}
t.Run("unsigned", func(t *testing.T) { t.Run("unsigned", func(t *testing.T) {
assertFetchRefused(t, manifestOf(t, files), files, assertFetchRefused(t, manifestOf(t, files), files, msgFpA,
"manifest is not signed, but signature from "+msgFpA+" is required", "manifest is not signed, but signature from "+msgFpA+" is required")
"--"+flagRequireSignature, msgFpA)
}) })
t.Run("signed", func(t *testing.T) { t.Run("signed", func(t *testing.T) {
@@ -1139,9 +1072,8 @@ func TestFetchRequireSignature(t *testing.T) {
ExtractEmbeddedSigningKeyFP(context.Background()) ExtractEmbeddedSigningKeyFP(context.Background())
require.NoError(t, err) require.NoError(t, err)
assertFetchRefused(t, manifest, files, assertFetchRefused(t, manifest, files, msgFpB,
"embedded signing key fingerprint "+signer+" does not match required "+msgFpB, "embedded signing key fingerprint "+signer+" does not match required "+msgFpB)
"--"+flagRequireSignature, msgFpB)
server := httptest.NewServer(fetchTestHandler(manifest, files)) server := httptest.NewServer(fetchTestHandler(manifest, files))
defer server.Close() defer server.Close()
@@ -1157,49 +1089,12 @@ func TestFetchRequireSignature(t *testing.T) {
}) })
} }
// TestFetchRefusesListedManifestName fetches manifests that list, at the
// top of the tree, the name fetch saves the manifest under or that name's
// temp file: as a file, as a directory, in capitals, and with a leading
// "./". Saving the manifest would replace or remove what is listed there,
// so fetch must refuse the manifest before it creates the destination or
// requests any file.
func TestFetchRefusesListedManifestName(t *testing.T) {
t.Parallel()
for _, listed := range []string{
defaultManifestName,
tempPathFor(defaultManifestName),
defaultManifestName + "/" + testFileTxt,
"INDEX.MF",
"./" + defaultManifestName,
} {
t.Run(listed, func(t *testing.T) {
t.Parallel()
// Built directly rather than scanned, since a scan lists no
// hidden files and never a path starting with "./".
content := []byte("listed")
builder := mfer.NewBuilder()
_, err := builder.AddFile(mfer.RelFilePath(listed), mfer.FileSize(len(content)),
mfer.ModTime(time.Now()), bytes.NewReader(content), nil)
require.NoError(t, err)
var manifest bytes.Buffer
require.NoError(t, builder.Build(context.Background(), &manifest))
assertFetchRefused(t, manifest.Bytes(), map[string][]byte{listed: content},
"manifest lists a file where fetch saves the manifest: "+listed)
})
}
}
// assertFetchRefused serves manifest, a manifest of files, and fetches it // assertFetchRefused serves manifest, a manifest of files, and fetches it
// with flags into a directory that does not exist yet. fetch must fail // with --require-signature signer into a directory that does not exist
// with message after requesting only the manifest, and must not create // yet. fetch must fail with message after requesting only the manifest,
// the directory. // and must not create the directory.
func assertFetchRefused( func assertFetchRefused(
t *testing.T, manifest []byte, files map[string][]byte, t *testing.T, manifest []byte, files map[string][]byte, signer, message string,
message string, flags ...string,
) { ) {
t.Helper() t.Helper()
@@ -1224,12 +1119,13 @@ func assertFetchRefused(
dest := filepath.Join(t.TempDir(), "mirror") dest := filepath.Join(t.TempDir(), "mirror")
opts := testOpts(slices.Concat( opts := testOpts([]string{
[]string{testApp, cmdFetch, "-q", "--" + flagDest, dest}, flags, []string{server.URL}, testApp, cmdFetch, "-q", "--" + flagDest, dest,
), afero.NewOsFs()) "--" + flagRequireSignature, signer, server.URL,
}, afero.NewOsFs())
assert.Equal(t, 1, runCLI(opts)) assert.Equal(t, 1, runCLI(opts))
assert.Contains(t, testStderr(t, opts), message) assert.Contains(t, testStderr(t, opts), message)
assert.NoDirExists(t, dest, "fetch created the destination before refusing") assert.NoDirExists(t, dest, "fetch wrote before checking the signer")
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
+1 -1
View File
@@ -240,7 +240,7 @@ func (mfa *CLIApp) checkCommand() *cli.Command {
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "no-extra-files", Name: "no-extra-files",
Usage: "Fail, instead of warning, if files in base directory are not in manifest", Usage: "Fail if files exist in base directory that are not in manifest",
}, },
requireSignatureFlag(), requireSignatureFlag(),
), ),
+35 -50
View File
@@ -77,9 +77,9 @@ type Checker struct {
fs afero.Fs fs afero.Fs
// manifestPaths is a set of paths in the manifest for quick lookup // manifestPaths is a set of paths in the manifest for quick lookup
manifestPaths map[RelFilePath]struct{} manifestPaths map[RelFilePath]struct{}
// manifestInfo is the manifest file, which FindExtraFiles leaves out, // manifestRelPath is the relative path of the manifest file from
// matched with os.SameFile. // basePath (for exclusion)
manifestInfo os.FileInfo manifestRelPath RelFilePath
// signature info from the manifest // signature info from the manifest
signature []byte signature []byte
signer []byte signer []byte
@@ -133,20 +133,26 @@ func NewChecker(opts *CheckerOptions) (*Checker, error) {
manifestPaths[RelFilePath(f.GetPath())] = struct{}{} manifestPaths[RelFilePath(f.GetPath())] = struct{}{}
} }
manifestInfo, err := fs.Stat(opts.ManifestPath) // Compute manifest's relative path from basePath for exclusion in FindExtraFiles
absManifest, err := filepath.Abs(opts.ManifestPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
manifestRel, err := filepath.Rel(abs, absManifest)
if err != nil {
manifestRel = ""
}
return &Checker{ return &Checker{
basePath: AbsFilePath(abs), basePath: AbsFilePath(abs),
files: files, files: files,
fs: fs, fs: fs,
manifestPaths: manifestPaths, manifestPaths: manifestPaths,
manifestInfo: manifestInfo, manifestRelPath: RelFilePath(manifestRel),
signature: m.pbOuter.GetSignature(), signature: m.pbOuter.GetSignature(),
signer: m.pbOuter.GetSigner(), signer: m.pbOuter.GetSigner(),
signingPubKey: m.pbOuter.GetSigningPubKey(), signingPubKey: m.pbOuter.GetSigningPubKey(),
}, nil }, nil
} }
@@ -267,27 +273,20 @@ func (c *Checker) Check(
return nil return nil
} }
// FindExtraFiles walks the filesystem and reports files not in the manifest, // FindExtraFiles walks the filesystem and reports files not in the manifest.
// hidden files and directories included. The manifest file itself is not // Results are sent to the results channel. The channel is closed when done.
// reported. Anything the search cannot read, such as a directory that cannot // Hidden files/directories (starting with .) are skipped, as they are excluded
// be listed, is reported with StatusError and the search goes on. Results are // from manifests by default. The manifest file itself is also skipped.
// sent to the results channel. The channel is closed when done.
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error { func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
if results != nil { if results != nil {
defer close(results) defer close(results)
} }
// The search does not follow symlinks, so a base directory named walkFn := func(walkPath string, info os.FileInfo, err error) error {
// through one is resolved first. If that fails, the base is searched as if err != nil {
// named and the search reports the problem. return err
root := string(c.basePath) }
resolved, err := filepath.EvalSymlinks(root)
if err == nil {
root = resolved
}
walkFn := func(walkPath string, info os.FileInfo, walkErr error) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -295,22 +294,15 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
} }
// Get relative path // Get relative path
rel, err := filepath.Rel(root, walkPath) rel, err := filepath.Rel(string(c.basePath), walkPath)
if err != nil { if err != nil {
return err return err
} }
relPath := RelFilePath(rel) // Skip hidden files and directories (dotfiles)
if IsHiddenPath(filepath.ToSlash(rel)) {
// Report what cannot be read, such as a directory that cannot be if info.IsDir() {
// listed, and go on with the rest. return filepath.SkipDir
if walkErr != nil {
if results != nil {
results <- Result{
Path: relPath,
Status: StatusError,
Message: walkErr.Error(),
}
} }
return nil return nil
@@ -321,17 +313,10 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
return nil return nil
} }
// A symlink is compared by what it points to, so a manifest reached relPath := RelFilePath(rel)
// through one is not reported either.
if info.Mode()&os.ModeSymlink != 0 {
target, statErr := c.fs.Stat(walkPath)
if statErr == nil {
info = target
}
}
// Skip the manifest file itself, however its path is spelled // Skip the manifest file itself
if os.SameFile(info, c.manifestInfo) { if relPath == c.manifestRelPath {
return nil return nil
} }
@@ -349,7 +334,7 @@ func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) err
return nil return nil
} }
return afero.Walk(c.fs, root, walkFn) return afero.Walk(c.fs, string(c.basePath), walkFn)
} }
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result { func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
+97 -94
View File
@@ -21,6 +21,8 @@ const (
testExistsFile = "exists.txt" testExistsFile = "exists.txt"
testManifestPath = "/manifest.mf" testManifestPath = "/manifest.mf"
testDataDir = "/data" testDataDir = "/data"
// testDataManifestPath is a manifest kept inside the checked tree.
testDataManifestPath = testDataDir + "/index.mf"
) )
func TestStatusString(t *testing.T) { func TestStatusString(t *testing.T) {
@@ -457,120 +459,50 @@ func TestFindExtraFiles(t *testing.T) {
assert.Equal(t, "not in manifest", extras[0].Message) assert.Equal(t, "not in manifest", extras[0].Message)
} }
// TestFindExtraFilesReportsHiddenFilesButNotManifest keeps the manifest func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
// inside the checked tree: hidden files and directories are reported, the
// manifest is not. The manifest is recognized by file identity, which needs
// the real filesystem.
func TestFindExtraFilesReportsHiddenFilesButNotManifest(t *testing.T) {
t.Parallel() t.Parallel()
dir := t.TempDir() fs := afero.NewMemMapFs()
manifestPath := filepath.Join(dir, "index.mf") manifestFiles := map[string][]byte{
testFile1: []byte("in manifest"),
fs := afero.NewOsFs() }
createTestManifest(t, fs, manifestPath, map[string][]byte{ createTestManifest(t, fs, testDataManifestPath, manifestFiles)
createFilesOnDisk(t, fs, map[string][]byte{
testFile1: []byte("in manifest"), testFile1: []byte("in manifest"),
}) })
// Create dotfile and manifest that should be skipped
unlisted := []RelFilePath{"extra.txt", ".hidden", ".git/config"} require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("hidden"), 0o644))
for _, p := range append([]RelFilePath{testFile1}, unlisted...) { require.NoError(t, afero.WriteFile(fs, "/data/.config/settings", []byte("cfg"), 0o644))
path := filepath.Join(dir, string(p)) // Create a real extra file
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o750)) require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
require.NoError(t, afero.WriteFile(fs, path, []byte("x"), 0o600)) require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
}
chk, err := NewChecker(&CheckerOptions{ chk, err := NewChecker(&CheckerOptions{
ManifestPath: manifestPath, ManifestPath: testDataManifestPath,
BasePath: dir, BasePath: testDataDir,
Fs: fs, Fs: fs,
}) })
require.NoError(t, err) require.NoError(t, err)
results := make(chan Result, 10) results := make(chan Result, 10)
require.NoError(t, chk.FindExtraFiles(context.Background(), results)) err = chk.FindExtraFiles(context.Background(), results)
var extras []RelFilePath
for r := range results {
extras = append(extras, r.Path)
}
assert.ElementsMatch(t, unlisted, extras)
}
// TestFindExtraFilesSkipsManifestReachedThroughSymlink checks a tree whose
// index.mf is a symlink to the manifest kept outside the tree: the symlink is
// not reported.
func TestFindExtraFilesSkipsManifestReachedThroughSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
tree := filepath.Join(dir, "tree")
manifestPath := filepath.Join(dir, "real.mf")
linkPath := filepath.Join(tree, "index.mf")
fs := afero.NewOsFs()
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
require.NoError(t, fs.MkdirAll(tree, 0o750))
require.NoError(t,
afero.WriteFile(fs, filepath.Join(tree, testFile1), []byte("x"), 0o600))
require.NoError(t, os.Symlink(manifestPath, linkPath))
chk, err := NewChecker(&CheckerOptions{
ManifestPath: linkPath,
BasePath: tree,
Fs: fs,
})
require.NoError(t, err) require.NoError(t, err)
results := make(chan Result, 10) var extras []Result
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
var extras []RelFilePath
for r := range results { for r := range results {
extras = append(extras, r.Path) extras = append(extras, r)
} }
assert.Empty(t, extras) // Should only report extra.txt, not .hidden, .config/settings, or index.mf
} for _, e := range extras {
t.Logf("extra: %s", e.Path)
// TestFindExtraFilesSearchesBaseNamedThroughSymlink names the checked tree
// through a symlink to it: the files in the tree are searched, and the
// symlink itself is not reported.
func TestFindExtraFilesSearchesBaseNamedThroughSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
tree := filepath.Join(dir, "tree")
link := filepath.Join(dir, "link")
manifestPath := filepath.Join(dir, "index.mf")
fs := afero.NewOsFs()
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
require.NoError(t, fs.MkdirAll(tree, 0o750))
for _, name := range []string{testFile1, testFile2} {
require.NoError(t,
afero.WriteFile(fs, filepath.Join(tree, name), []byte("x"), 0o600))
} }
require.NoError(t, os.Symlink(tree, link)) assert.Len(t, extras, 1)
chk, err := NewChecker(&CheckerOptions{ if len(extras) > 0 {
ManifestPath: manifestPath, assert.Equal(t, RelFilePath("extra.txt"), extras[0].Path)
BasePath: link,
Fs: fs,
})
require.NoError(t, err)
results := make(chan Result, 10)
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
var extras []RelFilePath
for r := range results {
extras = append(extras, r.Path)
} }
assert.Equal(t, []RelFilePath{testFile2}, extras)
} }
func TestFindExtraFilesContextCancellation(t *testing.T) { func TestFindExtraFilesContextCancellation(t *testing.T) {
@@ -717,6 +649,77 @@ func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR") assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
} }
func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
t.Parallel()
// Regression test for #16: FindExtraFiles should not report dotfiles
// or the manifest file itself as extra files.
fs := afero.NewMemMapFs()
files := map[string][]byte{
testFile1: []byte("in manifest"),
}
createTestManifest(t, fs, testDataManifestPath, files)
createFilesOnDisk(t, fs, files)
// Add dotfiles and manifest file on disk
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("dotfile"), 0o644))
require.NoError(t, fs.MkdirAll("/data/.git", 0o755))
require.NoError(t,
afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
chk, err := NewChecker(&CheckerOptions{
ManifestPath: testDataManifestPath,
BasePath: testDataDir,
Fs: fs,
})
require.NoError(t, err)
results := make(chan Result, 10)
err = chk.FindExtraFiles(context.Background(), results)
require.NoError(t, err)
var extras []Result
for r := range results {
extras = append(extras, r)
}
// Should report NO extra files — dotfiles and manifest should be skipped
assert.Empty(t, extras,
"FindExtraFiles should not report dotfiles or manifest file as extra; got: %v",
extras)
}
func TestFindExtraFilesSkipsManifestFile(t *testing.T) {
t.Parallel()
// The manifest file itself should never be reported as extra
fs := afero.NewMemMapFs()
files := map[string][]byte{
testFile1: []byte("content"),
}
createTestManifest(t, fs, testDataManifestPath, files)
createFilesOnDisk(t, fs, files)
chk, err := NewChecker(&CheckerOptions{
ManifestPath: testDataManifestPath,
BasePath: testDataDir,
Fs: fs,
})
require.NoError(t, err)
results := make(chan Result, 10)
err = chk.FindExtraFiles(context.Background(), results)
require.NoError(t, err)
var extras []Result
for r := range results {
extras = append(extras, r)
}
assert.Empty(t, extras,
"manifest file should not be reported as extra; got: %v", extras)
}
func TestCheckEmptyManifest(t *testing.T) { func TestCheckEmptyManifest(t *testing.T) {
t.Parallel() t.Parallel()
+2 -3
View File
@@ -7,9 +7,8 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Go, then Markdown and JSON, through the same scripts script/fmt-check gofumpt -l -w mfer internal cmd
# uses, so both see the same files and the same tool versions. # Markdown and JSON, over the same file set script/fmt-check verifies.
"$SCRIPT_DIR/gofumpt" --write
"$SCRIPT_DIR/prettier" --write "$SCRIPT_DIR/prettier" --write
} }
+2 -2
View File
@@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as # script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing: Go via script/gofumpt, # script/fmt, but fails instead of writing: Go via script/fmt-check-go,
# Markdown and JSON via script/prettier. # Markdown and JSON via script/prettier.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/gofumpt" --check "$SCRIPT_DIR/fmt-check-go"
"$SCRIPT_DIR/prettier" --check "$SCRIPT_DIR/prettier" --check
} }
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# script/fmt-check-go: check Go formatting (read-only). Split out from
# script/fmt-check so the Docker lint stage, whose image has no node and
# therefore no prettier, can run the Go half on its own.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then
echo "gofmt: files need formatting:" >&2
gofmt -l . >&2
exit 1
fi
}
main "$@"
-44
View File
@@ -1,44 +0,0 @@
#!/bin/sh
# script/gofumpt: run gofumpt over this repo's Go files.
#
# Takes exactly one mode argument, --write or --check, and runs the same
# gofumpt version over the same files in both modes. script/fmt and
# script/fmt-check both go through here, and so does the Docker lint
# stage (make fmt-check-go), so what gets formatted and what gets
# verified cannot drift apart.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# gofumpt v0.12.0, 2026-10-04. `go run` fetches and builds exactly this
# version, so neither a developer machine nor the lint image needs
# gofumpt installed.
GOFUMPT="mvdan.cc/gofumpt@v0.12.0"
usage() {
echo "usage: script/gofumpt --write|--check" >&2
exit 2
}
main() {
[ "$#" -eq 1 ] || usage
cd "$ROOT"
# Every Go file in the repo. gofumpt holds generated files, such as
# mfer/mf.pb.go, to gofmt's rules only.
case "$1" in
--write) go run "$GOFUMPT" -l -w . ;;
--check)
# Own line: a failing command inside `[ -n "$(...)" ]` does
# not trip `set -e`, so a gofumpt that never ran would pass.
unformatted="$(go run "$GOFUMPT" -l .)"
if [ -n "$unformatted" ]; then
echo "gofumpt: files need formatting (run make fmt):" >&2
echo "$unformatted" >&2
exit 1
fi
;;
*) usage ;;
esac
}
main "$@"