//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "bytes" "errors" "fmt" "io" "math/rand" "os" "path/filepath" "slices" "strings" "sync" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" urfcli "github.com/urfave/cli/v2" "sneak.berlin/go/mfer/internal/log" "sneak.berlin/go/mfer/mfer" ) const ( testApp = "mfer" testDir = "/testdir" testFile1 = "/testdir/file1.txt" testMF = "/testdir/test.mf" testOutput = "/output.mf" testManifest = "/manifest.mf" testFlagBase = "--base" testFlagNoExtra = "--no-extra-files" testFlagVersion = "--version" testFlagVerbose = "--verbose" ) var errSimulatedWrite = errors.New("simulated write failure") // runMu serializes CLI runs: RunWithOptions wires the process-global // logger to the run's I/O streams, so parallel runs would cross-wire // captured output between tests. // //nolint:gochecknoglobals // guards process-global logger state in tests var runMu sync.Mutex // runCLI invokes RunWithOptions while holding runMu so parallel tests // capture their own output, and returns its exit code. func runCLI(opts *RunOptions) int { exitCode, _ := runCLIWithLevel(opts) return exitCode } // runCLIWithLevel is runCLI that also returns the log level the run left // set, read while runMu still keeps other runs from changing it. Each run // starts at the default level, as a new process does. Before releasing the // lock it points the process-global logger at io.Discard: other tests log // outside the lock (manifest loads, scans), and those lines must not land in // this run's buffers once it has returned and its test is reading them. func runCLIWithLevel(opts *RunOptions) (int, log.Level) { runMu.Lock() defer runMu.Unlock() log.SetLevel(log.InfoLevel) exitCode := RunWithOptions(opts) level := log.GetLevel() log.SetOutput(io.Discard, io.Discard) log.Init() return exitCode, level } func TestMain(m *testing.M) { // Prevent urfave/cli from calling os.Exit during tests urfcli.OsExiter = func(_ int) {} os.Exit(m.Run()) } func TestBuild(t *testing.T) { t.Parallel() m := &CLIApp{} assert.NotNil(t, m) } func testOpts(args []string, fs afero.Fs) *RunOptions { return &RunOptions{ Appname: testApp, Version: "1.0.0", Gitrev: "abc123", Args: args, Stdin: &bytes.Buffer{}, Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}, Fs: fs, } } func testStdout(t *testing.T, opts *RunOptions) string { t.Helper() buf, ok := opts.Stdout.(*bytes.Buffer) require.True(t, ok) return buf.String() } func testStderr(t *testing.T, opts *RunOptions) string { t.Helper() buf, ok := opts.Stderr.(*bytes.Buffer) require.True(t, ok) return buf.String() } func writeTestFile(t *testing.T, fs afero.Fs, path, content string) { t.Helper() require.NoError(t, afero.WriteFile(fs, path, []byte(content), 0o644)) } func TestVersionCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() opts := testOpts([]string{testApp, cmdVersion}, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode) stdout := testStdout(t, opts) assert.Contains(t, stdout, mfer.Version) assert.Contains(t, stdout, "abc123") } // TestVFlagCollision covers the -v/--verbose vs --version flag interaction // (issue #64). Verbose owns -v; version answers to --version and -V. None of // these invocations may produce a parser error, and the two ways of asking // for the version must print the same thing. func TestVFlagCollision(t *testing.T) { t.Parallel() // Invocations that must print the version and exit 0. versionCases := map[string][]string{ "long version flag": {testApp, testFlagVersion}, "short version flag": {testApp, "-V"}, "verbose then version": {testApp, "-v", testFlagVersion}, "long verbose and version": {testApp, "--verbose", testFlagVersion}, } for name, args := range versionCases { t.Run(name, func(t *testing.T) { t.Parallel() opts := testOpts(args, afero.NewMemMapFs()) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts)) assert.Contains(t, testStdout(t, opts), mfer.Version) assert.NotContains(t, testStderr(t, opts), "two forms of the same flag") }) } // Invocations that must enable verbose and exit 0 without a parser error. verboseCases := map[string][]string{ "short verbose flag": {testApp, "-v"}, "long verbose flag": {testApp, "--verbose"}, } for name, args := range verboseCases { t.Run(name, func(t *testing.T) { t.Parallel() opts := testOpts(args, afero.NewMemMapFs()) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts)) assert.Contains(t, testStdout(t, opts), cmdGenerate, "root should show help listing subcommands") }) } } // TestVersionFlagAndCommandMatch asserts that "mfer --version" and // "mfer version" produce identical output (issue #64). func TestVersionFlagAndCommandMatch(t *testing.T) { t.Parallel() flagOpts := testOpts([]string{testApp, testFlagVersion}, afero.NewMemMapFs()) require.Equal(t, 0, runCLI(flagOpts)) cmdOpts := testOpts([]string{testApp, cmdVersion}, afero.NewMemMapFs()) require.Equal(t, 0, runCLI(cmdOpts)) assert.Equal(t, testStdout(t, flagOpts), testStdout(t, cmdOpts)) } // TestRootVerbosityFlags asserts that -q and -v given before any subcommand // name take effect: in the root itself, and in the fetch and export // subcommands (issue #64). It does not run in parallel: other tests log // outside runMu (manifest loads, scans), and a line logged while one of these // runs is in progress lands in its output. // //nolint:paralleltest // see above func TestRootVerbosityFlags(t *testing.T) { t.Run("quiet with no subcommand hides the banner", func(t *testing.T) { loud := testOpts([]string{testApp}, afero.NewMemMapFs()) require.Equal(t, 0, runCLI(loud)) assert.Contains(t, testStdout(t, loud), banner) quiet := testOpts([]string{testApp, "-q"}, afero.NewMemMapFs()) require.Equal(t, 0, runCLI(quiet)) assert.Contains(t, testStdout(t, quiet), cmdGenerate) assert.NotContains(t, testStdout(t, quiet), banner) }) t.Run("quiet before fetch hides the banner", func(t *testing.T) { opts := testOpts([]string{testApp, "-q", cmdFetch}, afero.NewMemMapFs()) assert.Equal(t, 1, runCLI(opts)) assert.Contains(t, testStderr(t, opts), errURLRequired.Error()) assert.NotContains(t, testStdout(t, opts), banner) }) t.Run("verbose twice before fetch enables debug logging", func(t *testing.T) { opts := testOpts([]string{testApp, "-v", "-v", cmdFetch}, afero.NewMemMapFs()) assert.Equal(t, 1, runCLI(opts)) assert.Contains(t, testStderr(t, opts), "fetchManifestOperation()") }) t.Run("quiet before export hides the manifest summary", func(t *testing.T) { fs := afero.NewMemMapFs() manifest := buildTestManifest(t, map[string][]byte{"a.txt": []byte("a")}) require.NoError(t, afero.WriteFile(fs, testManifest, manifest, 0o644)) loud := testOpts([]string{testApp, cmdExport, testManifest}, fs) require.Equal(t, 0, runCLI(loud)) assert.Contains(t, testStderr(t, loud), "loaded manifest") quiet := testOpts([]string{testApp, "-q", cmdExport, testManifest}, fs) require.Equal(t, 0, runCLI(quiet)) assert.NotContains(t, testStderr(t, quiet), "loaded manifest") }) } // commandsTakingVerbose returns the command lines -v can follow: the root and // the generate, check, freshen and fetch subcommands. func commandsTakingVerbose() [][]string { return [][]string{ {testApp}, {testApp, cmdGenerate}, {testApp, cmdCheck}, {testApp, cmdFreshen}, {testApp, cmdFetch}, } } // TestVerboseCount asserts that one -v or --verbose gives verbose output and // two -v give debug output (issue #125). urfave/cli before v2.25.5 counted a // flag given by its alias twice, so one -v gave debug output. func TestVerboseCount(t *testing.T) { t.Parallel() cases := []struct { flags []string want log.Level }{ {[]string{"-v"}, log.VerboseLevel}, {[]string{testFlagVerbose}, log.VerboseLevel}, {[]string{"-v", "-v"}, log.DebugLevel}, } for _, command := range commandsTakingVerbose() { for _, tc := range cases { args := slices.Concat(command, tc.flags) t.Run(strings.Join(args, " "), func(t *testing.T) { t.Parallel() _, level := runCLIWithLevel(testOpts(args, afero.NewMemMapFs())) assert.Equal(t, tc.want, level) }) } } } // TestCombinedShortVerboseRefused asserts that -vv is refused (issue #125): // single-letter flags do not combine, so the -v help text says -v -v. func TestCombinedShortVerboseRefused(t *testing.T) { t.Parallel() for _, command := range commandsTakingVerbose() { args := slices.Concat(command, []string{"-vv"}) t.Run(strings.Join(args, " "), func(t *testing.T) { t.Parallel() opts := testOpts(args, afero.NewMemMapFs()) exitCode, level := runCLIWithLevel(opts) assert.Equal(t, 1, exitCode) assert.Contains(t, testStderr(t, opts), "flag provided but not defined: -vv") assert.Equal(t, log.InfoLevel, level) }) } } // TestShortAndLongVerboseRefused asserts that -v and --verbose given together // are refused (issue #125): urfave/cli v2 refuses a flag given under two of its // names, and one flag with an alias keeps help and parsing simple. func TestShortAndLongVerboseRefused(t *testing.T) { t.Parallel() for _, command := range commandsTakingVerbose() { args := slices.Concat(command, []string{"-v", testFlagVerbose}) t.Run(strings.Join(args, " "), func(t *testing.T) { t.Parallel() opts := testOpts(args, afero.NewMemMapFs()) exitCode, level := runCLIWithLevel(opts) assert.Equal(t, 1, exitCode) assert.Contains(t, testStderr(t, opts), "Cannot use two forms of the same flag") assert.Equal(t, log.InfoLevel, level) }) } } func TestHelpCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() opts := testOpts([]string{testApp, "--help"}, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode) stdout := testStdout(t, opts) assert.Contains(t, stdout, cmdGenerate) assert.Contains(t, stdout, cmdCheck) assert.Contains(t, stdout, cmdFetch) } func TestGenerateCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files in memory filesystem require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello world") writeTestFile(t, fs, "/testdir/file2.txt", "test content") opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts)) // Verify manifest was created exists, err := afero.Exists(fs, testMF) require.NoError(t, err) assert.True(t, exists) } func TestGenerateAndCheckCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files with subdirectory require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755)) writeTestFile(t, fs, testFile1, "hello world") writeTestFile(t, fs, "/testdir/subdir/file2.txt", "test content") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts)) // Check manifest opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs) exitCode = runCLI(opts) assert.Equal(t, 0, exitCode, "check failed: %s", testStderr(t, opts)) } // sharedWriter appends to a buffer shared with other sharedWriters, so // output written to stdout and stderr is kept in the order it was written. // Each write first waits for delay. type sharedWriter struct { mu *sync.Mutex buf *bytes.Buffer delay time.Duration } func (w sharedWriter) Write(p []byte) (int, error) { time.Sleep(w.delay) w.mu.Lock() defer w.mu.Unlock() return w.buf.Write(p) } // TestCheckClearsProgressBeforeSummary asserts that check --progress writes // its last progress line and clears it before it logs the summary. Progress // writes are slowed down, so a progress goroutine that check did not wait for // would write after the summary, or after the run has returned. // // Progress lines and log lines take the logger's lock, and when the progress // goroutine gets it first the clear lands before the summary even without the // wait. Which goroutine gets it first changes from run to run, so check runs // ten times. func TestCheckClearsProgressBeforeSummary(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello world") opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) require.Equal(t, 0, runCLI(opts), "generate failed: %s", testStderr(t, opts)) for range 10 { var ( mu sync.Mutex output bytes.Buffer ) opts = testOpts([]string{ testApp, cmdCheck, "--progress", testFlagBase, testDir, testMF, }, fs) opts.Stdout = sharedWriter{mu: &mu, buf: &output, delay: 100 * time.Millisecond} opts.Stderr = sharedWriter{mu: &mu, buf: &output} require.Equal(t, 0, runCLI(opts)) mu.Lock() got := output.String() mu.Unlock() lastProgress := strings.Index(got, "Checking: 1/1 files") progressDone := strings.Index(got, "\r\033[K") summary := strings.Index(got, "checked 1 files") require.NotEqual(t, -1, lastProgress, "no last progress line in %q", got) require.NotEqual(t, -1, progressDone, "progress line never cleared in %q", got) require.NotEqual(t, -1, summary, "no summary in %q", got) require.Less(t, lastProgress, progressDone, "progress cleared before its last line in %q", got) require.Less(t, progressDone, summary, "summary logged before progress was cleared in %q", got) } } func TestCheckCommandWithMissingFile(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello world") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts)) // Delete the file require.NoError(t, fs.Remove(testFile1)) // Check manifest - should fail opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, "check should have failed for missing file") } func runCheckAfterRewrite(t *testing.T, rewritten, msg string) { t.Helper() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello world") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode, "generate failed: %s", testStderr(t, opts)) // Rewrite the file, then check the manifest - it must fail writeTestFile(t, fs, testFile1, rewritten) opts = testOpts([]string{testApp, cmdCheck, "-q", testFlagBase, testDir, testMF}, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, msg) } func TestCheckCommandWithCorruptedFile(t *testing.T) { t.Parallel() // Corrupt the file (change content but keep same size) runCheckAfterRewrite(t, "HELLO WORLD", "check should have failed for corrupted file") } func TestCheckCommandWithSizeMismatch(t *testing.T) { t.Parallel() // Change file size runCheckAfterRewrite(t, "different size content here", "check should have failed for size mismatch") } func TestBannerOutput(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Run without -q to see banner opts := testOpts([]string{testApp, cmdGenerate, "-o", testMF, testDir}, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode) // Banner ASCII art should be in stdout stdout := testStdout(t, opts) assert.Contains(t, stdout, "___") assert.Contains(t, stdout, "\\") } func TestUnknownCommand(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() opts := testOpts([]string{testApp, "unknown"}, fs) exitCode := runCLI(opts) assert.Equal(t, 1, exitCode) } func TestGenerateExcludesDotfilesByDefault(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files including dotfiles require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") writeTestFile(t, fs, "/testdir/.hidden", "secret") // Generate manifest without --include-dotfiles (default excludes dotfiles) opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testMF, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Check that manifest exists exists, _ := afero.Exists(fs, testMF) assert.True(t, exists) // Verify manifest only has 1 file (the non-dotfile) manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{ Path: testMF, Fs: fs, }) require.NoError(t, err) assert.Len(t, manifest.Files(), 1) assert.Equal(t, "file1.txt", manifest.Files()[0].GetPath()) } func TestGenerateWithIncludeDotfiles(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files including dotfiles require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") writeTestFile(t, fs, "/testdir/.hidden", "secret") // Generate manifest with --include-dotfiles opts := testOpts([]string{ testApp, cmdGenerate, "-q", "--include-dotfiles", "-o", testMF, testDir, }, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Verify manifest has 2 files (including dotfile) manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{ Path: testMF, Fs: fs, }) require.NoError(t, err) assert.Len(t, manifest.Files(), 2) } func TestMultipleInputPaths(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files in multiple directories require.NoError(t, fs.MkdirAll("/dir1", 0o755)) require.NoError(t, fs.MkdirAll("/dir2", 0o755)) writeTestFile(t, fs, "/dir1/file1.txt", "content1") writeTestFile(t, fs, "/dir2/file2.txt", "content2") // Generate manifest from multiple paths opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testOutput, "/dir1", "/dir2", }, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode, "stderr: %s", testStderr(t, opts)) exists, _ := afero.Exists(fs, testOutput) assert.True(t, exists) } func TestNoExtraFilesPass(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") writeTestFile(t, fs, "/testdir/file2.txt", "world") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Check with --no-extra-files (should pass - no extra files) opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) assert.Equal(t, 0, exitCode) } func TestNoExtraFilesFail(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Add an extra file after manifest generation writeTestFile(t, fs, "/testdir/extra.txt", "extra") // Check with --no-extra-files (should fail - extra file exists) opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, "check should fail when extra files exist") } func TestNoExtraFilesWithSubdirectory(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test files with subdirectory require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755)) writeTestFile(t, fs, testFile1, "hello") writeTestFile(t, fs, "/testdir/subdir/file2.txt", "world") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Add extra file in subdirectory writeTestFile(t, fs, "/testdir/subdir/extra.txt", "extra") // Check with --no-extra-files (should fail) opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, "check should fail when extra files exist in subdirectory") } // TestCheckWarnsAboutUnlistedFiles adds one file the manifest does not list // to a tree that had none: it gets one warning and the check passes, unless // --no-extra-files makes it a failure. --quiet hides the warning, not the // failure. func TestCheckWarnsAboutUnlistedFiles(t *testing.T) { t.Parallel() for name, unlisted := range map[string]string{ "regular file": "extra.txt", "dotfile": ".hidden", "file in hidden directory": ".git/config", } { t.Run(name, func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testManifest, testDir, }, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) check := func(flags ...string) (int, string) { args := append([]string{testApp, cmdCheck, testFlagBase, testDir}, flags...) opts := testOpts(append(args, testManifest), fs) return runCLI(opts), testStderr(t, opts) } exitCode, stderr := check() assert.Equal(t, 0, exitCode, "stderr: %s", stderr) assert.NotContains(t, stderr, "not in manifest") writeTestFile(t, fs, filepath.Join(testDir, unlisted), "unlisted") exitCode, stderr = check() assert.Equal(t, 0, exitCode, "stderr: %s", stderr) assert.Equal(t, 1, strings.Count(stderr, "not in manifest"), stderr) assert.Contains(t, stderr, unlisted) exitCode, stderr = check("-q") assert.Equal(t, 0, exitCode, "stderr: %s", stderr) assert.NotContains(t, stderr, "not in manifest") exitCode, stderr = check(testFlagNoExtra) assert.Equal(t, 1, exitCode, "stderr: %s", stderr) assert.Contains(t, stderr, unlisted) exitCode, _ = check("-q", testFlagNoExtra) assert.Equal(t, 1, exitCode) }) } } // TestCheckNeverReportsManifest keeps the manifest inside the checked tree, // under several names and path spellings, and names the tree both directly // and through a symlink: the manifest is never reported, even under // --no-extra-files. The manifest is recognized by file identity, which needs // the real filesystem. func TestCheckNeverReportsManifest(t *testing.T) { t.Parallel() // Manifest paths are relative to the checked tree. for name, manifest := range map[string]string{ "default name": defaultManifestName, "hidden name": ".index.mf", "other name in a subdirectory": "sub/listing.mf", "path spelled through ..": "sub/../index.mf", } { t.Run(name, func(t *testing.T) { t.Parallel() // A temp dir holding data/tree and link, a symlink to data. root := t.TempDir() tree := filepath.Join(root, "data", "tree") // Not filepath.Join, which would clean away a "..". manifestPath := tree + "/" + manifest 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") opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", manifestPath, tree, }, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) for _, base := range []string{tree, filepath.Join(root, "link", "tree")} { opts = testOpts([]string{ testApp, cmdCheck, testFlagNoExtra, testFlagBase, base, manifestPath, }, fs) assert.Equal(t, 0, runCLI(opts), "base %s, stderr: %s", base, testStderr(t, opts)) assert.NotContains(t, testStderr(t, opts), "not in manifest") } }) } } // unlistableDirFs is a filesystem on which one directory cannot be listed. type unlistableDirFs struct { afero.Fs dir string } //nolint:ireturn // Open must return afero.File to satisfy afero.Fs. func (f unlistableDirFs) Open(name string) (afero.File, error) { if name == f.dir { return nil, os.ErrPermission } return f.Fs.Open(name) } // TestCheckWithUnlistableDirectory has a directory under the base that // cannot be listed, followed by an unlisted file: both are warned about and // the check still passes, unless --no-extra-files is given. func TestCheckWithUnlistableDirectory(t *testing.T) { t.Parallel() mem := afero.NewMemMapFs() require.NoError(t, mem.MkdirAll("/testdir/locked", 0o755)) writeTestFile(t, mem, testFile1, "hello") opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testManifest, testDir, }, mem) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) // Directories are searched in name order, so this comes after "locked". writeTestFile(t, mem, "/testdir/unlisted.txt", "unlisted") fs := unlistableDirFs{Fs: mem, dir: "/testdir/locked"} opts = testOpts([]string{testApp, cmdCheck, testFlagBase, testDir, testManifest}, fs) assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.Contains(t, testStderr(t, opts), os.ErrPermission.Error()) assert.Contains(t, testStderr(t, opts), "unlisted.txt") opts = testOpts([]string{ testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest, }, fs) assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.Contains(t, testStderr(t, opts), "unlisted.txt") } func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Verify output file exists exists, err := afero.Exists(fs, testOutput) require.NoError(t, err) assert.True(t, exists, "output file should exist") // Verify temp file does NOT exist tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput)) require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") } func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Create existing manifest with different content writeTestFile(t, fs, testOutput, "old content") // Generate manifest with --force opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-f", "-o", testOutput, testDir, }, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Verify output file exists and was overwritten content, err := afero.ReadFile(fs, testOutput) require.NoError(t, err) assert.NotEqual(t, "old content", string(content), "manifest should be overwritten") // Verify temp file does NOT exist tmpExists, err := afero.Exists(fs, manifestTempPath(testOutput)) require.NoError(t, err) assert.False(t, tmpExists, "temp file should not exist after successful generation") } func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Create existing manifest writeTestFile(t, fs, testOutput, "existing") // Generate manifest WITHOUT --force (should fail) opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs) exitCode := runCLI(opts) assert.Equal(t, 1, exitCode, "should fail when output exists without --force") // Verify original content is preserved content, err := afero.ReadFile(fs, testOutput) require.NoError(t, err) 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() // This test verifies that generate uses a temp file by checking // that the output file doesn't exist until generation completes. // We do this by generating to a path and verifying the temp file // pattern is used (output.mf.tmp -> output.mf) fs := afero.NewMemMapFs() // Create test file require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "hello") // Generate manifest opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode) // Both output file should exist and temp should not exists, _ := afero.Exists(fs, testOutput) assert.True(t, exists, "output file should exist") tmpExists, _ := afero.Exists(fs, manifestTempPath(testOutput)) assert.False(t, tmpExists, "temp file should be cleaned up") // Verify manifest is valid (not empty) content, err := afero.ReadFile(fs, testOutput) require.NoError(t, err) assert.NotEmpty(t, content, "manifest should not be empty") } // failingWriterFs wraps a filesystem and makes writes fail after N bytes type failingWriterFs struct { afero.Fs failAfter int64 written int64 } type failingFile struct { afero.File fs *failingWriterFs } func (f *failingFile) Write(p []byte) (int, error) { f.fs.written += int64(len(p)) if f.fs.written > f.fs.failAfter { return 0, errSimulatedWrite } return f.File.Write(p) } //nolint:ireturn // Create must return afero.File to satisfy afero.Fs. func (fs *failingWriterFs) Create(name string) (afero.File, error) { f, err := fs.Fs.Create(name) if err != nil { return nil, err } return &failingFile{File: f, fs: fs}, nil } func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) { t.Parallel() baseFs := afero.NewMemMapFs() // Create test files - need enough content to trigger the write failure require.NoError(t, baseFs.MkdirAll(testDir, 0o755)) writeTestFile(t, baseFs, testFile1, "hello world this is a test file") // Wrap with failing writer that fails after writing some bytes fs := &failingWriterFs{Fs: baseFs, failAfter: 10} // Generate manifest - should fail during write opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testOutput, testDir}, fs) exitCode := runCLI(opts) assert.Equal(t, 1, exitCode, "should fail due to write error") // With atomic writes: output.mf should NOT exist (temp was cleaned up) // With non-atomic writes: output.mf WOULD exist (partial/empty) exists, _ := afero.Exists(baseFs, testOutput) assert.False(t, exists, "output file should not exist after failed generation (atomic write)") // Temp file should also not exist tmpExists, _ := afero.Exists(baseFs, manifestTempPath(testOutput)) assert.False(t, tmpExists, "temp file should be cleaned up after failed generation") } func TestGenerateValidatesInputPaths(t *testing.T) { t.Parallel() seedValidDir := func(t *testing.T, fs afero.Fs) { t.Helper() require.NoError(t, fs.MkdirAll("/validdir", 0o755)) writeTestFile(t, fs, "/validdir/file.txt", "content") } t.Run("nonexistent path fails fast", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() seedValidDir(t, fs) opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testOutput, "/nonexistent", }, fs) exitCode := runCLI(opts) assert.Equal(t, 1, exitCode) stderr := testStderr(t, opts) assert.Contains(t, stderr, "path does not exist") assert.Contains(t, stderr, "/nonexistent") }) t.Run("mix of valid and invalid paths fails fast", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() seedValidDir(t, fs) opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testOutput, "/validdir", "/alsononexistent", }, fs) exitCode := runCLI(opts) assert.Equal(t, 1, exitCode) stderr := testStderr(t, opts) assert.Contains(t, stderr, "path does not exist") assert.Contains(t, stderr, "/alsononexistent") // Output file should not have been created exists, _ := afero.Exists(fs, testOutput) assert.False(t, exists, "output file should not exist when path validation fails") }) t.Run("valid paths succeed", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() seedValidDir(t, fs) opts := testOpts([]string{ testApp, cmdGenerate, "-q", "-o", testOutput, "/validdir", }, fs) exitCode := runCLI(opts) assert.Equal(t, 0, exitCode) }) } func TestCheckDetectsManifestCorruption(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() rng := rand.New(rand.NewSource(42)) //nolint:gosec // deterministic test data // Create many small files with random names so the manifest has many // entries and random single-byte flips land at varied offsets. Each // manifest entry is roughly 50-60 bytes. Kept modest so the suite stays // within its wall-clock budget under -race. require.NoError(t, fs.MkdirAll(testDir, 0o755)) numFiles := 1500 for range numFiles { // Generate random filename filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat", rng.Uint32(), rng.Uint32(), rng.Uint32()) // Small random content content := make([]byte, 16+rng.Intn(48)) _, _ = rng.Read(content) require.NoError(t, afero.WriteFile(fs, filename, content, 0o644)) } // Generate manifest outside of testdir opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", testManifest, testDir}, fs) exitCode := runCLI(opts) require.Equal(t, 0, exitCode, "generate should succeed") // Read the valid manifest and verify it has real size. validManifest, err := afero.ReadFile(fs, testManifest) require.NoError(t, err) require.GreaterOrEqual(t, len(validManifest), 64*1024, "manifest should be at least 64KB, got %d bytes", len(validManifest)) t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles) // First corruption: truncate the manifest require.NoError(t, afero.WriteFile(fs, testManifest, validManifest[:len(validManifest)/2], 0o644)) // Check should fail with truncated manifest opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, "check should fail with truncated manifest") // Verify check passes with valid manifest require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644)) opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) require.Equal(t, 0, exitCode, "check should pass with valid manifest") // Now do 100 random corruption iterations for i := range 100 { // Corrupt: write a random byte at a random offset corrupted := make([]byte, len(validManifest)) copy(corrupted, validManifest) offset := rng.Intn(len(corrupted)) originalByte := corrupted[offset] // Make sure we actually change the byte buf := make([]byte, 1) newByte := originalByte for newByte == originalByte { _, _ = rng.Read(buf) newByte = buf[0] } corrupted[offset] = newByte require.NoError(t, afero.WriteFile(fs, testManifest, corrupted, 0o644)) // Check should fail with corrupted manifest opts = testOpts([]string{ testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest, }, fs) exitCode = runCLI(opts) assert.Equal(t, 1, exitCode, "iteration %d: check should fail with corrupted manifest "+ "(offset %d, 0x%02x -> 0x%02x)", i, offset, originalByte, newByte) // Restore valid manifest for next iteration require.NoError(t, afero.WriteFile(fs, testManifest, validManifest, 0o644)) } }