From 73b97ac2e44be389fc024f7edba992d376472005 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 4 Oct 2026 08:45:55 +0000 Subject: [PATCH] Default the manifest to index.mf and keep it out of its own listing (closes #100) mfer gen now writes index.mf instead of .index.mf, the name fetch requests and the README calls the standard filename. Given a directory, check, freshen, list and export look only for index.mf; .index.mf is no longer recognized. Because index.mf is not hidden, the scanner now leaves the output file out of the listing by its path: gen --force over an existing manifest no longer lists the old one, while a file named index.mf in a subdirectory is still listed. Model: opus-5-5 --- .gitignore | 2 +- README.md | 4 ++-- internal/cli/check.go | 27 +++++++++++------------- internal/cli/entry_test.go | 37 +++++++++++++++++++++++++++++++++ internal/cli/errmsg_test.go | 2 +- internal/cli/fetch.go | 5 ++--- internal/cli/freshen.go | 2 +- internal/cli/gen.go | 1 + internal/cli/manifest_loader.go | 2 +- internal/cli/mfer.go | 7 ++++++- mfer/scanner.go | 20 +++++++++++++++++- 11 files changed, 83 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index fbdcb30..9f07cc8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ vendor.tzst modcache.tzst # Generated manifest files -.index.mf +/index.mf # Secrets .env diff --git a/README.md b/README.md index 7234ed5..4e0d4f1 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,12 @@ Generate a manifest for a directory tree, verify it later, and fetch a published tree by URL: ```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 . # Verify the files on disk against the manifest. Exits nonzero if any file # is missing or corrupted. -bin/mfer check .index.mf +bin/mfer check index.mf # Download and cryptographically verify a tree published over HTTP: mfer # fetches /index.mf, then downloads every file it lists. diff --git a/internal/cli/check.go b/internal/cli/check.go index 4375d32..1219f13 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -75,25 +75,22 @@ func safeRateUint64(rate float64) uint64 { return uint64(rate) } -// findManifest looks for a manifest file in the given directory. -// It checks for index.mf and .index.mf, returning the first one found. +// findManifest returns the path of the manifest with the default name in +// dir, or an error if there is none. func findManifest(fs afero.Fs, dir string) (string, error) { - candidates := []string{"index.mf", ".index.mf"} - for _, name := range candidates { - path := filepath.Join(dir, name) + path := filepath.Join(dir, defaultManifestName) - exists, err := afero.Exists(fs, path) - if err != nil { - return "", err - } - - if exists { - return path, nil - } + exists, err := afero.Exists(fs, path) + if err != nil { + return "", err } - return "", fmt.Errorf( - "%w in %s (looked for index.mf and .index.mf)", errNoManifestFound, dir) + if !exists { + return "", fmt.Errorf("%w in %s (looked for %s)", + errNoManifestFound, dir, defaultManifestName) + } + + return path, nil } // fetchManifestToTemp downloads a manifest URL to a temporary file and diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index 8169b8d..a989df7 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -638,6 +638,43 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) { assert.Equal(t, "existing", string(content), "original file should be preserved") } +// TestGenerateLeavesOutputOutOfListing overwrites an output file inside the +// scanned tree with --force: the old file is not listed, while a file named +// index.mf in a subdirectory still is. +func TestGenerateLeavesOutputOutOfListing(t *testing.T) { + t.Parallel() + + for name, output := range map[string]string{ + "default name": "/testdir/index.mf", + "other name in a subdirectory": "/testdir/sub/listing.mf", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755)) + writeTestFile(t, fs, "/testdir/readme.txt", "hello") + writeTestFile(t, fs, "/testdir/sub/index.mf", "an ordinary file") + writeTestFile(t, fs, output, "previous manifest") + + opts := testOpts([]string{ + testApp, cmdGenerate, "-q", "--force", "-o", output, testDir, + }, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + manifest, err := mfer.NewManifestFromFile(fs, output) + require.NoError(t, err) + + paths := make([]string, 0, len(manifest.Files())) + for _, f := range manifest.Files() { + paths = append(paths, f.GetPath()) + } + + assert.ElementsMatch(t, []string{"readme.txt", "sub/index.mf"}, paths) + }) + } +} + func TestGenerateAtomicWriteUsesTemp(t *testing.T) { t.Parallel() diff --git a/internal/cli/errmsg_test.go b/internal/cli/errmsg_test.go index 5683120..c0d74ac 100644 --- a/internal/cli/errmsg_test.go +++ b/internal/cli/errmsg_test.go @@ -74,7 +74,7 @@ func TestNoManifestFoundMessage(t *testing.T) { _, err := findManifest(afero.NewMemMapFs(), "/tmp/x") require.ErrorIs(t, err, errNoManifestFound) assert.EqualError(t, err, - "no manifest found in /tmp/x (looked for index.mf and .index.mf)") + "no manifest found in /tmp/x (looked for index.mf)") } func TestVerifyRequiredSignerMessages(t *testing.T) { diff --git a/internal/cli/fetch.go b/internal/cli/fetch.go index 3a2c5de..d6d9917 100644 --- a/internal/cli/fetch.go +++ b/internal/cli/fetch.go @@ -313,7 +313,7 @@ func checkNoSymlinks(p string) error { // resolveManifestURL takes a URL and returns the manifest URL. // If the URL already ends with .mf, it's returned as-is. -// Otherwise, index.mf is appended. +// Otherwise, the default manifest name is appended. func resolveManifestURL(inputURL string) (string, error) { parsed, err := url.Parse(inputURL) if err != nil { @@ -330,8 +330,7 @@ func resolveManifestURL(inputURL string) (string, error) { parsed.Path += "/" } - // Append index.mf - parsed.Path += "index.mf" + parsed.Path += defaultManifestName return parsed.String(), nil } diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index 0640b15..3cc0bfd 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -157,7 +157,7 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro } // Skip the manifest file itself - if relPath == s.manifestBase || relPath == "."+s.manifestBase { + if relPath == s.manifestBase { return nil } diff --git a/internal/cli/gen.go b/internal/cli/gen.go index 10da3da..0521b1e 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -94,6 +94,7 @@ func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions { FollowSymLinks: ctx.Bool("follow-symlinks"), IncludeTimestamps: ctx.Bool("include-timestamps"), Fs: mfa.Fs, + OutputPath: ctx.String("output"), } // Set seed for deterministic UUID if provided diff --git a/internal/cli/manifest_loader.go b/internal/cli/manifest_loader.go index a08e8ce..46edcc4 100644 --- a/internal/cli/manifest_loader.go +++ b/internal/cli/manifest_loader.go @@ -65,7 +65,7 @@ func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) { } // resolveManifestArg resolves the manifest path from CLI arguments. -// HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf. +// HTTP(S) URLs are returned as-is. Directories are searched for index.mf. // If no argument is given, the current directory is searched. func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) { if ctx.Args().Len() > 0 { diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index ccd878a..01e62c0 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -24,6 +24,11 @@ const ( flagProgress = "progress" 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. @@ -154,7 +159,7 @@ func (mfa *CLIApp) generateCommand() *cli.Command { }, &cli.StringFlag{ Name: "output", - Value: "./.index.mf", + Value: defaultManifestName, Aliases: []string{"o"}, Usage: "Specify output filename", }, diff --git a/mfer/scanner.go b/mfer/scanner.go index 01b2770..f9f67f7 100644 --- a/mfer/scanner.go +++ b/mfer/scanner.go @@ -57,6 +57,10 @@ type ScannerOptions struct { SigningOptions *SigningOptions // Seed, if set, derives a deterministic UUID from this seed. Seed string + // OutputPath, if set, is the file the manifest will be written to. + // Enumeration skips that one file, so a manifest written inside the + // scanned tree, or one it replaces there, is never listed. + OutputPath string } // FileEntry represents a file that has been enumerated. @@ -75,6 +79,7 @@ type Scanner struct { totalBytes FileSize // cached sum of all file sizes options *ScannerOptions fs afero.Fs + outputPath string // options.OutputPath made absolute; "" when unset } // NewScanner creates a new Scanner with default options. @@ -93,11 +98,19 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner { fs = afero.NewOsFs() } - return &Scanner{ + s := &Scanner{ files: make([]*FileEntry, 0), options: opts, fs: fs, } + + if opts.OutputPath != "" { + // Abs fails only when there is no working directory, and then a + // relative output path could not be written either. + s.outputPath, _ = filepath.Abs(opts.OutputPath) + } + + return s } // EnumerateFile adds a single file to the scanner, calling stat() to get metadata. @@ -408,6 +421,11 @@ func (s *Scanner) enumerateFileWithInfo( // Compute absolute path for file reading absPath := filepath.Join(basePath, cleanPath) + // The manifest being written is not one of the files it lists. + if absPath == s.outputPath { + return nil + } + // Handle symlinks if info.Mode()&fs.ModeSymlink != 0 { if !s.options.FollowSymLinks {