From 0afcbae783511b3f833a6babac48abfe011e2538 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 leave out of their own listing the manifest they write and a temp file an interrupted run left beside it, matched by file identity (os.SameFile) however the path is spelled. The scanner takes these as a plain ExcludePaths list; manifestTempPath alone names the temp file, for both writes, both skips and the tests. Model: opus-5-5 --- .gitignore | 2 +- README.md | 4 +- internal/cli/check.go | 27 ++++---- internal/cli/entry_test.go | 106 ++++++++++++++++++++++++++++++-- internal/cli/errmsg_test.go | 2 +- internal/cli/fetch.go | 5 +- internal/cli/freshen.go | 30 ++++++--- internal/cli/freshen_test.go | 67 ++++++++++++++++++-- internal/cli/gen.go | 6 +- internal/cli/manifest_loader.go | 2 +- internal/cli/mfer.go | 13 +++- mfer/checker_test.go | 16 ++--- mfer/scanner.go | 23 ++++++- 13 files changed, 253 insertions(+), 50 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..359c66f 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" @@ -27,7 +28,6 @@ const ( testFile1 = "/testdir/file1.txt" testMF = "/testdir/test.mf" testOutput = "/output.mf" - testOutputTmp = "/output.mf.tmp" testManifest = "/manifest.mf" testFlagBase = "--base" testFlagNoExtra = "--no-extra-files" @@ -682,7 +682,7 @@ func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { assert.True(t, exists, "output file should exist") // Verify temp file does NOT exist - tmpExists, err := afero.Exists(fs, testOutputTmp) + tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput)) require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") @@ -714,7 +714,7 @@ func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) { "manifest should be overwritten") // Verify temp file does NOT exist - tmpExists, err := afero.Exists(fs, testOutputTmp) + tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput)) require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") @@ -743,6 +743,102 @@ 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 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, manifestTempPath(output), "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() @@ -765,7 +861,7 @@ func TestGenerateAtomicWriteUsesTemp(t *testing.T) { exists, _ := afero.Exists(fs, testOutput) assert.True(t, exists, "output file should exist") - tmpExists, _ := afero.Exists(fs, testOutputTmp) + tmpExists, _ := afero.Exists(fs, manifestTempPath(testOutput)) assert.False(t, tmpExists, "temp file should be cleaned up") // Verify manifest is valid (not empty) @@ -831,7 +927,7 @@ func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) { "output file should not exist after failed generation (atomic write)") // Temp file should also not exist - tmpExists, _ := afero.Exists(baseFs, testOutputTmp) + tmpExists, _ := afero.Exists(baseFs, manifestTempPath(testOutput)) assert.False(t, tmpExists, "temp file should be cleaned up after failed generation") } 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..72b9f7d 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 + excluded []fs.FileInfo // files left out of the listing 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 } + for _, excluded := range s.excluded { + if os.SameFile(info, excluded) { + return nil + } + } + s.scanCount++ // Check against existing manifest @@ -307,7 +309,7 @@ func (h *freshenHasher) processEntry(e *freshenEntry) error { func writeFreshenedManifest( ctx context.Context, afs afero.Fs, builder *mfer.Builder, manifestPath string, ) error { - tmpPath := manifestPath + ".tmp" + tmpPath := manifestTempPath(manifestPath) outFile, err := afs.Create(tmpPath) if err != nil { @@ -364,10 +366,22 @@ func (mfa *CLIApp) freshenScan( startScan := time.Now() showProgress := ctx.Bool("progress") + // Leave out the manifest and a temp file left by an interrupted run, + // as gen does. A path that cannot be stat'd, normally because no file + // is there, needs no leaving out. + var excluded []fs.FileInfo + + for _, p := range []string{manifestPath, manifestTempPath(manifestPath)} { + info, err := mfa.Fs.Stat(p) + if err == nil { + excluded = append(excluded, info) + } + } + scanner := &freshenScanner{ fs: mfa.Fs, absBase: absBase, - manifestBase: filepath.Base(manifestPath), + excluded: excluded, includeDotfiles: ctx.Bool("include-dotfiles"), followSymlinks: ctx.Bool("follow-symlinks"), showProgress: showProgress, diff --git a/internal/cli/freshen_test.go b/internal/cli/freshen_test.go index 84d69ba..cd83e15 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 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, manifestTempPath(manifestPath), "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..68c2292 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -89,11 +89,15 @@ func (mfa *CLIApp) collectInputPaths(args cli.Args) ([]string, error) { // buildScannerOptions constructs scanner options from the CLI flags. func (mfa *CLIApp) buildScannerOptions(ctx *cli.Context) *mfer.ScannerOptions { + output := ctx.String("output") opts := &mfer.ScannerOptions{ IncludeDotfiles: ctx.Bool("include-dotfiles"), FollowSymLinks: ctx.Bool("follow-symlinks"), IncludeTimestamps: ctx.Bool("include-timestamps"), Fs: mfa.Fs, + // Neither a manifest being replaced nor a temp file left by an + // interrupted run belongs in the new manifest. + ExcludePaths: []string{output, manifestTempPath(output)}, } // Set seed for deterministic UUID if provided @@ -217,7 +221,7 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error { } // Create temp file for atomic write - tmpPath := outputPath + ".tmp" + tmpPath := manifestTempPath(outputPath) outFile, err := mfa.Fs.Create(tmpPath) if err != nil { 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..171a1da 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -25,8 +25,19 @@ 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" ) +// manifestTempPath returns the temp file gen and freshen write a manifest +// to before renaming it to out. +func manifestTempPath(out string) string { + return out + ".tmp" +} + // errUnknownCommand indicates an unrecognized command argument. var errUnknownCommand = errors.New("unknown command") @@ -156,7 +167,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..f04c7c6 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,8 @@ type ScannerOptions struct { SigningOptions *SigningOptions // Seed, if set, derives a deterministic UUID from this seed. Seed string + // ExcludePaths lists files to leave out of the listing, matched with os.SameFile. + ExcludePaths []string } // FileEntry represents a file that has been enumerated. @@ -75,6 +78,7 @@ type Scanner struct { totalBytes FileSize // cached sum of all file sizes options *ScannerOptions fs afero.Fs + excluded []fs.FileInfo // the files named in ExcludePaths that exist } // NewScanner creates a new Scanner with default options. @@ -93,11 +97,22 @@ func NewScannerWithOptions(opts *ScannerOptions) *Scanner { fs = afero.NewOsFs() } - return &Scanner{ + s := &Scanner{ files: make([]*FileEntry, 0), options: opts, fs: fs, } + + // A path that cannot be stat'd, normally because no file is there, + // needs no leaving out. + for _, p := range opts.ExcludePaths { + info, err := s.fs.Stat(p) + if err == nil { + s.excluded = append(s.excluded, info) + } + } + + return s } // EnumerateFile adds a single file to the scanner, calling stat() to get metadata. @@ -435,6 +450,12 @@ func (s *Scanner) enumerateFileWithInfo( info = realInfo } + for _, excluded := range s.excluded { + if os.SameFile(info, excluded) { + return nil + } + } + entry := &FileEntry{ Path: RelFilePath(cleanPath), AbsPath: AbsFilePath(absPath),