From 97875a82bc36678cbdd01739f4be5d47306e0607 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 out of their own listing both the manifest they write and a temp file an interrupted run left beside it, by file identity (os.SameFile), so each is recognized however its path is spelled, 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 | 98 +++++++++++++++++++++++++++++++++ internal/cli/errmsg_test.go | 2 +- internal/cli/fetch.go | 5 +- internal/cli/freshen.go | 36 +++++++++--- internal/cli/freshen_test.go | 67 ++++++++++++++++++++-- internal/cli/gen.go | 1 + internal/cli/manifest_loader.go | 2 +- internal/cli/mfer.go | 7 ++- mfer/checker_test.go | 16 +++--- mfer/scanner.go | 36 +++++++++++- 13 files changed, 259 insertions(+), 44 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 a0db793..fb19e63 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 9459d4f..f9acf77 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" @@ -743,6 +744,103 @@ 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(&mfer.ManifestFromFileOptions{ + Path: path, + Fs: fs, + }) + 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")) +} + +// TestGenerateLeavesLeftoverTempFileOutOfListing runs gen where an +// interrupted run left its temp file, index.mf.tmp, beside the output: +// the leftover is not listed, and gen does not fail when it overwrites +// it. +func TestGenerateLeavesLeftoverTempFileOutOfListing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + output := filepath.Join(root, "index.mf") + + fs := afero.NewOsFs() + writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") + writeTestFile(t, fs, output+".tmp", "part of a manifest") + + opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", output, root}, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + assert.Equal(t, []string{testFileTxt}, manifestPaths(t, fs, output)) +} + func TestGenerateAtomicWriteUsesTemp(t *testing.T) { t.Parallel() diff --git a/internal/cli/errmsg_test.go b/internal/cli/errmsg_test.go index 66a7a0f..69c8585 100644 --- a/internal/cli/errmsg_test.go +++ b/internal/cli/errmsg_test.go @@ -78,7 +78,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 170179f..6ddf609 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,8 @@ type freshenEntry struct { type freshenScanner struct { fs afero.Fs absBase string - manifestBase string + manifestInfo fs.FileInfo // the manifest being freshened + tmpInfo fs.FileInfo // its leftover temp file; nil when there is none includeDotfiles bool followSymlinks bool showProgress bool @@ -156,11 +158,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 +182,13 @@ func (s *freshenScanner) walk(path string, info fs.FileInfo, walkErr error) erro info = realInfo } + // Skip the manifest file itself and a temp file left beside it by an + // interrupted run, however their paths are spelled; gen leaves out its + // output file the same way. os.SameFile never matches a nil FileInfo. + if os.SameFile(info, s.manifestInfo) || os.SameFile(info, s.tmpInfo) { + return nil + } + s.scanCount++ // Check against existing manifest @@ -364,17 +368,33 @@ 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) + } + + // The temp file writeFreshenedManifest writes, if an interrupted run + // left one. One that cannot be stat'd, normally because it does not + // exist, needs no leaving out. + var tmpInfo fs.FileInfo + + info, err := mfa.Fs.Stat(manifestPath + ".tmp") + if err == nil { + tmpInfo = info + } + scanner := &freshenScanner{ fs: mfa.Fs, absBase: absBase, - manifestBase: filepath.Base(manifestPath), + manifestInfo: manifestInfo, + tmpInfo: tmpInfo, 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 84d69ba..6411c62 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) { @@ -59,7 +60,7 @@ func TestFreshenUnchanged(t *testing.T) { // Parse manifest to verify manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{ - Path: "/testdir/.index.mf", + Path: "/testdir/index.mf", Fs: fs, }) require.NoError(t, err) @@ -74,7 +75,7 @@ func TestFreshenWithChanges(t *testing.T) { // Verify initial manifest has 2 files manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{ - Path: "/testdir/.index.mf", + Path: "/testdir/index.mf", Fs: fs, }) require.NoError(t, err) @@ -101,6 +102,64 @@ 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)) +} + +// TestFreshenLeavesLeftoverTempFileOutOfListing freshens a manifest where +// an interrupted run left its temp file, index.mf.tmp, beside it: the +// leftover is not listed. +func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "index.mf") + + fs := afero.NewOsFs() + writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") + + opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs) + require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) + + writeTestFile(t, fs, manifestPath+".tmp", "part of a manifest") + // 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, "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 57db67e..d00350d 100644 --- a/mfer/checker_test.go +++ b/mfer/checker_test.go @@ -21,6 +21,8 @@ const ( testExistsFile = "exists.txt" testManifestPath = "/manifest.mf" testDataDir = "/data" + // testDataManifestPath is a manifest kept inside the checked tree. + testDataManifestPath = testDataDir + "/index.mf" ) func TestStatusString(t *testing.T) { @@ -464,7 +466,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { manifestFiles := map[string][]byte{ testFile1: []byte("in manifest"), } - createTestManifest(t, fs, "/data/.index.mf", manifestFiles) + createTestManifest(t, fs, testDataManifestPath, manifestFiles) createFilesOnDisk(t, fs, map[string][]byte{ testFile1: []byte("in manifest"), }) @@ -476,7 +478,7 @@ func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644)) chk, err := NewChecker(&CheckerOptions{ - ManifestPath: "/data/.index.mf", + ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) @@ -491,7 +493,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) } @@ -656,7 +658,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) { files := map[string][]byte{ testFile1: []byte("in manifest"), } - createTestManifest(t, fs, "/data/.index.mf", files) + createTestManifest(t, fs, testDataManifestPath, files) createFilesOnDisk(t, fs, files) // Add dotfiles and manifest file on disk @@ -666,7 +668,7 @@ func TestFindExtraFilesSkipsDotfiles(t *testing.T) { afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644)) chk, err := NewChecker(&CheckerOptions{ - ManifestPath: "/data/.index.mf", + ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) @@ -695,11 +697,11 @@ func TestFindExtraFilesSkipsManifestFile(t *testing.T) { files := map[string][]byte{ testFile1: []byte("content"), } - createTestManifest(t, fs, "/data/index.mf", files) + createTestManifest(t, fs, testDataManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ - ManifestPath: "/data/index.mf", + ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) diff --git a/mfer/scanner.go b/mfer/scanner.go index 01b2770..c1e8a47 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,15 @@ 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 leaves out that file and OutputPath + ".tmp", the temp + // file mfer gen writes first, if they exist when the scanner is + // created, however their paths are spelled (through a symlink, a hard + // link, or in different letter case), so neither a manifest being + // replaced nor a temp file left by an interrupted run is ever 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 +85,8 @@ 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 + tmpInfo fs.FileInfo // its leftover temp file; nil when there is none } // NewScanner creates a new Scanner with default options. @@ -93,11 +105,27 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner { fs = afero.NewOsFs() } - return &Scanner{ + s := &Scanner{ files: make([]*FileEntry, 0), options: opts, fs: fs, } + + if opts.OutputPath != "" { + // A file that cannot be stat'd, normally because it does not + // exist, needs no leaving out. + info, err := s.fs.Stat(opts.OutputPath) + if err == nil { + s.outputInfo = info + } + + info, err = s.fs.Stat(opts.OutputPath + ".tmp") + if err == nil { + s.tmpInfo = info + } + } + + return s } // EnumerateFile adds a single file to the scanner, calling stat() to get metadata. @@ -435,6 +463,12 @@ func (s *Scanner) enumerateFileWithInfo( info = realInfo } + // Neither the manifest being written nor its temp file is one of the + // files it lists. os.SameFile never matches a nil FileInfo. + if os.SameFile(info, s.outputInfo) || os.SameFile(info, s.tmpInfo) { + return nil + } + entry := &FileEntry{ Path: RelFilePath(cleanPath), AbsPath: AbsFilePath(absPath),