Compare commits

Author SHA1 Message Date
sneak 114ba0a29f Disable the deprecated gomodguard linter (closes #116)
check / check (push) Failing after 2s
golangci-lint v2.12 deprecates gomodguard in favour of gomodguard_v2.
With `default: all` both ran, and every lint run printed the
deprecation warning. Disable the old name, the same way wsl is
disabled for wsl_v5; gomodguard_v2 stays enabled. The old linter had
no settings here, so there is nothing to carry over.

Model: opus-5-5
2026-10-04 08:06:44 +00:00
15 changed files with 43 additions and 182 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ vendor.tzst
modcache.tzst modcache.tzst
# Generated manifest files # Generated manifest files
/index.mf .index.mf
# Secrets # Secrets
.env .env
+1
View File
@@ -16,6 +16,7 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
-2
View File
@@ -75,7 +75,5 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
RUN ldd /mfer 2>&1 | grep -q 'not a dynamic executable' RUN ldd /mfer 2>&1 | grep -q 'not a dynamic executable'
FROM scratch FROM scratch
# scratch has no CA certificates; fetch needs them to verify HTTPS servers.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /mfer /mfer COPY --from=builder /mfer /mfer
ENTRYPOINT ["/mfer"] ENTRYPOINT ["/mfer"]
+2 -2
View File
@@ -36,12 +36,12 @@ Generate a manifest for a directory tree, verify it later, and fetch a published
tree by URL: tree by URL:
```sh ```sh
# Write index.mf, a manifest of the files under the current directory. # Write .index.mf, a manifest of the files under the current directory.
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
# is missing or corrupted. # is missing or corrupted.
bin/mfer check index.mf bin/mfer check .index.mf
# Download and cryptographically verify a tree published over HTTP: mfer # Download and cryptographically verify a tree published over HTTP: mfer
# fetches <url>/index.mf, then downloads every file it lists. # fetches <url>/index.mf, then downloads every file it lists.
+15 -12
View File
@@ -75,22 +75,25 @@ func safeRateUint64(rate float64) uint64 {
return uint64(rate) return uint64(rate)
} }
// findManifest returns the path of the manifest with the default name in // findManifest looks for a manifest file in the given directory.
// dir, or an error if there is none. // It checks for index.mf and .index.mf, returning the first one found.
func findManifest(fs afero.Fs, dir string) (string, error) { func findManifest(fs afero.Fs, dir string) (string, error) {
path := filepath.Join(dir, defaultManifestName) candidates := []string{"index.mf", ".index.mf"}
for _, name := range candidates {
path := filepath.Join(dir, name)
exists, err := afero.Exists(fs, path) exists, err := afero.Exists(fs, path)
if err != nil { if err != nil {
return "", err return "", err
}
if exists {
return path, nil
}
} }
if !exists { return "", fmt.Errorf(
return "", fmt.Errorf("%w in %s (looked for %s)", "%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir)
errNoManifestFound, dir, defaultManifestName)
}
return path, nil
} }
// fetchManifestToTemp downloads a manifest URL to a temporary file and // fetchManifestToTemp downloads a manifest URL to a temporary file and
-74
View File
@@ -8,7 +8,6 @@ import (
"io" "io"
"math/rand" "math/rand"
"os" "os"
"path/filepath"
"sync" "sync"
"testing" "testing"
@@ -639,79 +638,6 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
assert.Equal(t, "existing", string(content), "original file should be preserved") assert.Equal(t, "existing", string(content), "original file should be preserved")
} }
// manifestPaths returns the file paths listed by the manifest at path.
func manifestPaths(t *testing.T, fs afero.Fs, path string) []string {
t.Helper()
manifest, err := mfer.NewManifestFromFile(fs, path)
require.NoError(t, err)
paths := make([]string, 0, len(manifest.Files()))
for _, f := range manifest.Files() {
paths = append(paths, f.GetPath())
}
return paths
}
// TestGenerateLeavesOutputOutOfListing overwrites an output file inside the
// scanned tree with --force: the old file is not listed, even when the tree
// is named through a symlink, while a file named index.mf in a subdirectory
// still is. The output file is recognized by file identity, which needs
// the real filesystem.
func TestGenerateLeavesOutputOutOfListing(t *testing.T) {
t.Parallel()
// Paths are relative to a temp dir holding data/tree and link, a
// symlink to data.
for name, tc := range map[string]struct{ input, output string }{
"default name": {"data/tree", "data/tree/index.mf"},
"other name in a subdirectory": {"data/tree", "data/tree/sub/listing.mf"},
"tree named through a symlink": {"link/tree", "data/tree/index.mf"},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
root := t.TempDir()
tree := filepath.Join(root, "data", "tree")
output := filepath.Join(root, tc.output)
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, "readme.txt"), "hello")
writeTestFile(t, fs, filepath.Join(tree, "sub", "index.mf"), "an ordinary file")
writeTestFile(t, fs, output, "previous manifest")
opts := testOpts([]string{
testApp, cmdGenerate, "-q", "--force", "-o", output, filepath.Join(root, tc.input),
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{"readme.txt", "sub/index.mf"},
manifestPaths(t, fs, output))
})
}
}
// TestGenerateDefaultOutputLeftOutOfListing runs gen with --force and no
// other arguments, so it scans the current directory and writes the
// relative path index.mf: the index.mf already there is not listed.
//
//nolint:paralleltest // changes the process-global working directory
func TestGenerateDefaultOutputLeftOutOfListing(t *testing.T) {
chdirTemp(t)
fs := afero.NewOsFs()
writeTestFile(t, fs, "readme.txt", "hello")
writeTestFile(t, fs, "index.mf", "previous manifest")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "--force"}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Equal(t, []string{"readme.txt"}, manifestPaths(t, fs, "index.mf"))
}
func TestGenerateAtomicWriteUsesTemp(t *testing.T) { func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -74,7 +74,7 @@ func TestNoManifestFoundMessage(t *testing.T) {
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x") _, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
require.ErrorIs(t, err, errNoManifestFound) require.ErrorIs(t, err, errNoManifestFound)
assert.EqualError(t, err, assert.EqualError(t, err,
"no manifest found in /tmp/x (looked for index.mf)") "no manifest found in /tmp/x (looked for index.mf and .index.mf)")
} }
func TestVerifyRequiredSignerMessages(t *testing.T) { func TestVerifyRequiredSignerMessages(t *testing.T) {
+3 -2
View File
@@ -313,7 +313,7 @@ func checkNoSymlinks(p string) error {
// resolveManifestURL takes a URL and returns the manifest URL. // resolveManifestURL takes a URL and returns the manifest URL.
// If the URL already ends with .mf, it's returned as-is. // If the URL already ends with .mf, it's returned as-is.
// Otherwise, the default manifest name is appended. // Otherwise, index.mf is appended.
func resolveManifestURL(inputURL string) (string, error) { func resolveManifestURL(inputURL string) (string, error) {
parsed, err := url.Parse(inputURL) parsed, err := url.Parse(inputURL)
if err != nil { if err != nil {
@@ -330,7 +330,8 @@ func resolveManifestURL(inputURL string) (string, error) {
parsed.Path += "/" parsed.Path += "/"
} }
parsed.Path += defaultManifestName // Append index.mf
parsed.Path += "index.mf"
return parsed.String(), nil return parsed.String(), nil
} }
+8 -15
View File
@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"time" "time"
@@ -57,7 +56,7 @@ type freshenEntry struct {
type freshenScanner struct { type freshenScanner struct {
fs afero.Fs fs afero.Fs
absBase string absBase string
manifestInfo fs.FileInfo // the manifest being freshened manifestBase string
includeDotfiles bool includeDotfiles bool
followSymlinks bool followSymlinks bool
showProgress bool showProgress bool
@@ -157,6 +156,11 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
"freshen: failed to compute relative path for %s: %w", path, err) "freshen: failed to compute relative path for %s: %w", path, err)
} }
// Skip the manifest file itself
if relPath == s.manifestBase || relPath == "."+s.manifestBase {
return nil
}
// Handle dotfiles // Handle dotfiles
if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) { if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
if info.IsDir() { if info.IsDir() {
@@ -181,12 +185,6 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro
info = realInfo info = realInfo
} }
// Skip the manifest file itself, however its path is spelled; gen
// leaves out its output file the same way.
if os.SameFile(info, s.manifestInfo) {
return nil
}
s.scanCount++ s.scanCount++
// Check against existing manifest // Check against existing manifest
@@ -366,22 +364,17 @@ func (mfa *CLIApp) freshenScan(
startScan := time.Now() startScan := time.Now()
showProgress := ctx.Bool("progress") showProgress := ctx.Bool("progress")
manifestInfo, err := mfa.Fs.Stat(manifestPath)
if err != nil {
return nil, 0, fmt.Errorf("freshen: %w", err)
}
scanner := &freshenScanner{ scanner := &freshenScanner{
fs: mfa.Fs, fs: mfa.Fs,
absBase: absBase, absBase: absBase,
manifestInfo: manifestInfo, manifestBase: filepath.Base(manifestPath),
includeDotfiles: ctx.Bool("include-dotfiles"), includeDotfiles: ctx.Bool("include-dotfiles"),
followSymlinks: ctx.Bool("follow-symlinks"), followSymlinks: ctx.Bool("follow-symlinks"),
showProgress: showProgress, showProgress: showProgress,
existingByPath: existingByPath, existingByPath: existingByPath,
} }
err = afero.Walk(mfa.Fs, absBase, scanner.walk) err := afero.Walk(mfa.Fs, absBase, scanner.walk)
if showProgress { if showProgress {
log.ProgressDone() log.ProgressDone()
+4 -33
View File
@@ -5,7 +5,6 @@ import (
"bytes" "bytes"
"context" "context"
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -30,7 +29,7 @@ func (s stubFileInfo) IsDir() bool { return false }
func (s stubFileInfo) Sys() any { return nil } func (s stubFileInfo) Sys() any { return nil }
// setupFreshenDir populates /testdir with two files, scans it, and // setupFreshenDir populates /testdir with two files, scans it, and
// writes the resulting manifest to /testdir/index.mf. // writes the resulting manifest to /testdir/.index.mf.
func setupFreshenDir(t *testing.T, fs afero.Fs) { func setupFreshenDir(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
@@ -49,7 +48,7 @@ func setupFreshenDir(t *testing.T, fs afero.Fs) {
// Write manifest to filesystem // Write manifest to filesystem
require.NoError(t, require.NoError(t,
afero.WriteFile(fs, "/testdir/index.mf", manifestBuf.Bytes(), 0o644)) afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
} }
func TestFreshenUnchanged(t *testing.T) { func TestFreshenUnchanged(t *testing.T) {
@@ -59,7 +58,7 @@ func TestFreshenUnchanged(t *testing.T) {
setupFreshenDir(t, fs) setupFreshenDir(t, fs)
// Parse manifest to verify // Parse manifest to verify
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/index.mf") manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, manifest.Files(), 2) assert.Len(t, manifest.Files(), 2)
} }
@@ -71,7 +70,7 @@ func TestFreshenWithChanges(t *testing.T) {
setupFreshenDir(t, fs) setupFreshenDir(t, fs)
// Verify initial manifest has 2 files // Verify initial manifest has 2 files
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/index.mf") manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, manifest.Files(), 2) assert.Len(t, manifest.Files(), 2)
@@ -96,34 +95,6 @@ func TestFreshenWithChanges(t *testing.T) {
assert.Equal(t, "modified content2", string(content)) assert.Equal(t, "modified content2", string(content))
} }
// TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a
// subdirectory of the tree it lists: the manifest is not listed, while an
// ordinary file of the same name at the top of the tree is. The manifest
// is recognized by file identity, which needs the real filesystem.
func TestFreshenLeavesManifestOutOfListing(t *testing.T) {
t.Parallel()
root := t.TempDir()
manifestPath := filepath.Join(root, "sub", "listing.mf")
fs := afero.NewOsFs()
require.NoError(t, fs.MkdirAll(filepath.Join(root, "sub"), 0o750))
writeTestFile(t, fs, filepath.Join(root, "readme.txt"), "hello")
writeTestFile(t, fs, filepath.Join(root, "listing.mf"), "an ordinary file")
opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
// A new file gives freshen something to write.
writeTestFile(t, fs, filepath.Join(root, "new.txt"), "new")
opts = testOpts([]string{testApp, "freshen", "-q", testFlagBase, root, manifestPath}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, []string{"readme.txt", "listing.mf", "new.txt"},
manifestPaths(t, fs, manifestPath))
}
// TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry
// with respect to MFFilePath.Mtime, which is a message pointer with // with respect to MFFilePath.Mtime, which is a message pointer with
// proto3 field presence and may legitimately be absent. // proto3 field presence and may legitimately be absent.
-1
View File
@@ -94,7 +94,6 @@ func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions {
FollowSymLinks: ctx.Bool("follow-symlinks"), FollowSymLinks: ctx.Bool("follow-symlinks"),
IncludeTimestamps: ctx.Bool("include-timestamps"), IncludeTimestamps: ctx.Bool("include-timestamps"),
Fs: mfa.Fs, Fs: mfa.Fs,
OutputPath: ctx.String("output"),
} }
// Set seed for deterministic UUID if provided // Set seed for deterministic UUID if provided
+1 -1
View File
@@ -65,7 +65,7 @@ func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
} }
// resolveManifestArg resolves the manifest path from CLI arguments. // resolveManifestArg resolves the manifest path from CLI arguments.
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf. // HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf.
// If no argument is given, the current directory is searched. // If no argument is given, the current directory is searched.
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) { func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
if ctx.Args().Len() > 0 { if ctx.Args().Len() > 0 {
+1 -6
View File
@@ -24,11 +24,6 @@ const (
flagProgress = "progress" flagProgress = "progress"
manifestArgsUsage = "[manifest file]" manifestArgsUsage = "[manifest file]"
// defaultManifestName is the filename gen writes by default, the one
// looked for when a command is given a directory, and the one fetch
// appends to a directory URL.
defaultManifestName = "index.mf"
) )
// errUnknownCommand indicates an unrecognized command argument. // errUnknownCommand indicates an unrecognized command argument.
@@ -159,7 +154,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command {
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "output", Name: "output",
Value: defaultManifestName, Value: "./.index.mf",
Aliases: []string{"o"}, Aliases: []string{"o"},
Usage: "Specify output filename", Usage: "Specify output filename",
}, },
+5 -5
View File
@@ -360,7 +360,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
manifestFiles := map[string][]byte{ manifestFiles := map[string][]byte{
testFile1: []byte("in manifest"), testFile1: []byte("in manifest"),
} }
createTestManifest(t, fs, "/data/index.mf", manifestFiles) createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
createFilesOnDisk(t, fs, map[string][]byte{ createFilesOnDisk(t, fs, map[string][]byte{
testFile1: []byte("in manifest"), testFile1: []byte("in manifest"),
}) })
@@ -371,7 +371,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
require.NoError(t, fs.MkdirAll("/data", 0o755)) require.NoError(t, fs.MkdirAll("/data", 0o755))
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
chk, err := NewChecker("/data/index.mf", "/data", fs) chk, err := NewChecker("/data/.index.mf", "/data", fs)
require.NoError(t, err) require.NoError(t, err)
results := make(chan Result, 10) results := make(chan Result, 10)
@@ -383,7 +383,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
extras = append(extras, r) extras = append(extras, r)
} }
// Should only report extra.txt, not .hidden, .config/settings, or index.mf // Should only report extra.txt, not .hidden, .config/settings, or .index.mf
for _, e := range extras { for _, e := range extras {
t.Logf("extra: %s", e.Path) t.Logf("extra: %s", e.Path)
} }
@@ -528,7 +528,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
files := map[string][]byte{ files := map[string][]byte{
testFile1: []byte("in manifest"), testFile1: []byte("in manifest"),
} }
createTestManifest(t, fs, "/data/index.mf", files) createTestManifest(t, fs, "/data/.index.mf", files)
createFilesOnDisk(t, fs, files) createFilesOnDisk(t, fs, files)
// Add dotfiles and manifest file on disk // Add dotfiles and manifest file on disk
@@ -537,7 +537,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
require.NoError(t, require.NoError(t,
afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644)) afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
chk, err := NewChecker("/data/index.mf", "/data", fs) chk, err := NewChecker("/data/.index.mf", "/data", fs)
require.NoError(t, err) require.NoError(t, err)
results := make(chan Result, 10) results := make(chan Result, 10)
+1 -27
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"io" "io"
"io/fs" "io/fs"
"os"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -58,14 +57,6 @@ type ScannerOptions struct {
SigningOptions *SigningOptions SigningOptions *SigningOptions
// Seed, if set, derives a deterministic UUID from this seed. // Seed, if set, derives a deterministic UUID from this seed.
Seed string Seed string
// OutputPath, if set, is the file the manifest will be written to.
// If that file exists when the scanner is created, enumeration leaves
// it out however its path is spelled (through a symlink, a hard link,
// or in different letter case), so a manifest being replaced is never
// listed.
// Files are matched with os.SameFile, which only recognizes files on
// the operating system's filesystem.
OutputPath string
} }
// FileEntry represents a file that has been enumerated. // FileEntry represents a file that has been enumerated.
@@ -84,7 +75,6 @@ type Scanner struct {
totalBytes FileSize // cached sum of all file sizes totalBytes FileSize // cached sum of all file sizes
options *ScannerOptions options *ScannerOptions
fs afero.Fs fs afero.Fs
outputInfo fs.FileInfo // the existing output file; nil when there is none
} }
// NewScanner creates a new Scanner with default options. // NewScanner creates a new Scanner with default options.
@@ -103,22 +93,11 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
fs = afero.NewOsFs() fs = afero.NewOsFs()
} }
s := &Scanner{ return &Scanner{
files: make([]*FileEntry, 0), files: make([]*FileEntry, 0),
options: opts, options: opts,
fs: fs, fs: fs,
} }
if opts.OutputPath != "" {
// If the output file cannot be stat'd, normally because it does
// not exist yet, there is nothing to leave out.
info, err := s.fs.Stat(opts.OutputPath)
if err == nil {
s.outputInfo = info
}
}
return s
} }
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata. // EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
@@ -456,11 +435,6 @@ func (s *Scanner) enumerateFileWithInfo(
info = realInfo info = realInfo
} }
// The manifest being written is not one of the files it lists.
if s.outputInfo != nil && os.SameFile(info, s.outputInfo) {
return nil
}
entry := &FileEntry{ entry := &FileEntry{
Path: RelFilePath(cleanPath), Path: RelFilePath(cleanPath),
AbsPath: AbsFilePath(absPath), AbsPath: AbsFilePath(absPath),