check / check (push) Failing after 2s
check now always looks under the base directory for files the manifest does not list, hidden files and directories included, and prints one warning per file. The result still depends only on the listed files; --no-extra-files turns each unlisted file into a failure, and --quiet hides the warnings but not the failures. A directory that cannot be searched is likewise only a warning unless that flag is given. The manifest itself is left out by file identity, as gen and freshen do, so no name or spelling of its path or of the base gets it reported. Model: opus-5-5
1272 lines
38 KiB
Go
1272 lines
38 KiB
Go
//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.
|
|
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))
|
|
|
|
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)
|
|
assert.Less(t, lastProgress, progressDone,
|
|
"progress cleared before its last line in %q", got)
|
|
assert.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: the check still passes, with a warning, 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))
|
|
|
|
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())
|
|
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, testFlagNoExtra, testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
assert.Equal(t, 1, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|