From 0efa3a401511a4f94c1ce3264f8bf7cc2caaaf81 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, gen and freshen now leave the manifest they write out of its own listing by file identity (os.SameFile), so it is recognized however its path is spelled, through a symlink included, while an ordinary file of the same name elsewhere in the tree is still listed. Model: opus-5-5 --- .gitignore | 2 +- README.md | 4 +- internal/cli/check.go | 27 ++++++------ internal/cli/entry_test.go | 75 +++++++++++++++++++++++++++++++++ internal/cli/errmsg_test.go | 2 +- internal/cli/fetch.go | 5 +-- internal/cli/freshen.go | 23 ++++++---- internal/cli/freshen_test.go | 39 +++++++++++++++-- internal/cli/gen.go | 1 + internal/cli/manifest_loader.go | 2 +- internal/cli/mfer.go | 7 ++- mfer/checker_test.go | 10 ++--- mfer/scanner.go | 28 +++++++++++- 13 files changed, 183 insertions(+), 42 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 1489606..76b92dc 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -8,6 +8,7 @@ import ( "io" "math/rand" "os" + "path/filepath" "slices" "strings" "sync" @@ -737,6 +738,80 @@ func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) { 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, testFileTxt), "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{testFileTxt, "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, testFileTxt, "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{testFileTxt}, manifestPaths(t, fs, "index.mf")) +} + 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..53f8056 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/fs" + "os" "path/filepath" "time" @@ -56,7 +57,7 @@ type freshenEntry struct { type freshenScanner struct { fs afero.Fs absBase string - manifestBase string + manifestInfo fs.FileInfo // the manifest being freshened includeDotfiles bool followSymlinks bool showProgress bool @@ -156,11 +157,6 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro "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 if !s.includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) { if info.IsDir() { @@ -185,6 +181,12 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro 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++ // Check against existing manifest @@ -364,17 +366,22 @@ func (mfa *CLIApp) freshenScan( startScan := time.Now() showProgress := ctx.Bool("progress") + manifestInfo, err := mfa.Fs.Stat(manifestPath) + if err != nil { + return nil, 0, fmt.Errorf("freshen: %w", err) + } + scanner := &freshenScanner{ fs: mfa.Fs, absBase: absBase, - manifestBase: filepath.Base(manifestPath), + manifestInfo: manifestInfo, includeDotfiles: ctx.Bool("include-dotfiles"), followSymlinks: ctx.Bool("follow-symlinks"), showProgress: showProgress, existingByPath: existingByPath, } - err := afero.Walk(mfa.Fs, absBase, scanner.walk) + err = afero.Walk(mfa.Fs, absBase, scanner.walk) if showProgress { log.ProgressDone() diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index 0679729..ee269d6 100644 --- a/internal/cli/freshen_test.go +++ b/internal/cli/freshen_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "os" + "path/filepath" "testing" "time" @@ -29,7 +30,7 @@ func (s stubFileInfo) IsDir() bool { return false } func (s stubFileInfo) Sys() any { return nil } // 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) { t.Helper() @@ -48,7 +49,7 @@ func setupFreshenDir(t *testing.T, fs afero.Fs) { // Write manifest to filesystem 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) { @@ -58,7 +59,7 @@ func TestFreshenUnchanged(t *testing.T) { setupFreshenDir(t, fs) // Parse manifest to verify - manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf") + manifest, err := mfer.NewManifestFromFile(fs, "/testdir/index.mf") require.NoError(t, err) assert.Len(t, manifest.Files(), 2) } @@ -70,7 +71,7 @@ func TestFreshenWithChanges(t *testing.T) { setupFreshenDir(t, fs) // 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) assert.Len(t, manifest.Files(), 2) @@ -95,6 +96,36 @@ func TestFreshenWithChanges(t *testing.T) { 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, testFileTxt), "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, "added.txt"), "added") + + 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{testFileTxt, "listing.mf", "added.txt"}, + manifestPaths(t, fs, manifestPath)) +} + // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // with respect to MFFilePath.Mtime, which is a message pointer with // proto3 field presence and may legitimately be absent. 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 a2f8739..5aea0fe 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -25,6 +25,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. @@ -156,7 +161,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/checker_test.go b/mfer/checker_test.go index febc428..9fa6000 100644 --- a/mfer/checker_test.go +++ b/mfer/checker_test.go @@ -360,7 +360,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { manifestFiles := map[string][]byte{ testFile1: []byte("in manifest"), } - createTestManifest(t, fs, "/data/.index.mf", manifestFiles) + createTestManifest(t, fs, "/data/index.mf", manifestFiles) createFilesOnDisk(t, fs, map[string][]byte{ testFile1: []byte("in manifest"), }) @@ -371,7 +371,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { require.NoError(t, fs.MkdirAll("/data", 0o755)) 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) results := make(chan Result, 10) @@ -383,7 +383,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { 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 { t.Logf("extra: %s", e.Path) } @@ -528,7 +528,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) { files := map[string][]byte{ testFile1: []byte("in manifest"), } - createTestManifest(t, fs, "/data/.index.mf", files) + createTestManifest(t, fs, "/data/index.mf", files) createFilesOnDisk(t, fs, files) // Add dotfiles and manifest file on disk @@ -537,7 +537,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) { require.NoError(t, 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) results := make(chan Result, 10) diff --git a/mfer/scanner.go b/mfer/scanner.go index 01b2770..a9c6e2a 100644 --- a/mfer/scanner.go +++ b/mfer/scanner.go @@ -4,6 +4,7 @@ import ( "context" "io" "io/fs" + "os" "path" "path/filepath" "strings" @@ -57,6 +58,14 @@ 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. + // 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. @@ -75,6 +84,7 @@ type Scanner struct { totalBytes FileSize // cached sum of all file sizes options *ScannerOptions fs afero.Fs + outputInfo fs.FileInfo // the existing output file; nil when there is none } // NewScanner creates a new Scanner with default options. @@ -93,11 +103,22 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner { fs = afero.NewOsFs() } - return &Scanner{ + s := &Scanner{ files: make([]*FileEntry, 0), options: opts, 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. @@ -435,6 +456,11 @@ func (s *Scanner) enumerateFileWithInfo( 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{ Path: RelFilePath(cleanPath), AbsPath: AbsFilePath(absPath),