check / check (push) Waiting to run
Go 1.27.1 in go.mod and in the Dockerfile's test and build images. Every module go.mod requires is at its current release; protoc-gen-go follows protobuf to v1.36.12 and mf.pb.go is regenerated. The new standard library uuid package replaces github.com/google/uuid; the FromBytes call could only fail on a length validateUUID already checks, so it and its unreachable error are gone. make vulncheck runs govulncheck v1.8.0, installed with go install at its release commit, in a vulncheck stage of the Dockerfile on the digest-pinned golang image; script/check does not run it. A new test pins the bytes of a seeded manifest written by an mfer built before this change. Model: opus-5-5
1406 lines
43 KiB
Go
1406 lines
43 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"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/v3"
|
|
"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, or -v and --verbose, 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},
|
|
{[]string{"-v", testFlagVerbose}, 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// TestGenerateSeededManifestBytes pins the exact bytes `gen --seed` writes
|
|
// for a fixed tree, so that a Go or dependency update that changes what
|
|
// mfer writes fails here. testdata/seeded.mf was written by an mfer built
|
|
// before such an update. The tree has enough files that zstd compresses
|
|
// the manifest instead of storing it as it is.
|
|
func TestGenerateSeededManifestBytes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
start := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
for i := range 200 {
|
|
path := fmt.Sprintf("/testdir/d%d/f%03d.txt", i%10, i)
|
|
mtime := start.Add(time.Duration(i) * time.Second)
|
|
|
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
|
writeTestFile(t, fs, path, fmt.Sprintf("file %d\n", i))
|
|
require.NoError(t, fs.Chtimes(path, mtime, mtime))
|
|
}
|
|
|
|
opts := testOpts([]string{
|
|
testApp, cmdGenerate, "-q", "--seed", "mfer", "-o", testOutput, testDir,
|
|
}, fs)
|
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
|
|
got, err := afero.ReadFile(fs, testOutput)
|
|
require.NoError(t, err)
|
|
|
|
want, err := os.ReadFile("testdata/seeded.mf")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// TestGenerateRecordsModeOnlyWhenAsked runs gen with and without
|
|
// --include-permissions: without it every mode is recorded as 0000, with
|
|
// it each file's permission bits. list -l and export show the recorded
|
|
// modes.
|
|
func TestGenerateRecordsModeOnlyWhenAsked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, "/testdir/notes.txt", "hello world")
|
|
writeTestFile(t, fs, "/testdir/run.sh", "#!/bin/sh\n")
|
|
require.NoError(t, fs.Chmod("/testdir/run.sh", 0o755))
|
|
|
|
for _, tc := range []struct {
|
|
flags []string
|
|
want map[string]string // recorded mode by path
|
|
}{
|
|
{nil, map[string]string{"notes.txt": "0000", "run.sh": "0000"}},
|
|
{
|
|
[]string{"--" + flagIncludePermissions},
|
|
map[string]string{"notes.txt": "0644", "run.sh": "0755"},
|
|
},
|
|
} {
|
|
opts := testOpts(slices.Concat(
|
|
[]string{testApp, cmdGenerate, "-q", "-f", "-o", testOutput}, tc.flags,
|
|
[]string{testDir},
|
|
), fs)
|
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
|
|
opts = testOpts([]string{testApp, cmdList, "-l", testOutput}, fs)
|
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
|
|
listed := map[string]string{}
|
|
out := strings.TrimSuffix(testStdout(t, opts), "\n")
|
|
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Split(line, "\t") // mode, size, mtime, path
|
|
require.Len(t, fields, 4, line)
|
|
listed[fields[3]] = fields[0]
|
|
}
|
|
|
|
assert.Equal(t, tc.want, listed, "list -l %v", tc.flags)
|
|
|
|
opts = testOpts([]string{testApp, cmdExport, testOutput}, fs)
|
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
|
|
var entries []ExportEntry
|
|
require.NoError(t, json.Unmarshal([]byte(testStdout(t, opts)), &entries))
|
|
|
|
exported := map[string]string{}
|
|
for _, e := range entries {
|
|
exported[e.Path] = e.Mode
|
|
}
|
|
|
|
assert.Equal(t, tc.want, exported, "export %v", tc.flags)
|
|
}
|
|
}
|
|
|
|
// TestCheckComparesRecordedMode changes a file's mode after gen: check
|
|
// must fail on it when gen recorded the file's mode, and pass when gen
|
|
// recorded 0000.
|
|
func TestCheckComparesRecordedMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
flags []string
|
|
exitCode int
|
|
}{
|
|
{nil, 0},
|
|
{[]string{"--" + flagIncludePermissions}, 1},
|
|
} {
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "hello world")
|
|
|
|
opts := testOpts(slices.Concat(
|
|
[]string{testApp, cmdGenerate, "-q", "-o", testMF}, tc.flags,
|
|
[]string{testDir},
|
|
), fs)
|
|
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
|
|
|
|
require.NoError(t, fs.Chmod(testFile1, 0o600))
|
|
|
|
opts = testOpts([]string{testApp, cmdCheck, testFlagBase, testDir, testMF}, fs)
|
|
assert.Equal(t, tc.exitCode, runCLI(opts), "%v: %s", tc.flags, testStderr(t, opts))
|
|
|
|
if tc.exitCode != 0 {
|
|
assert.Contains(t, testStderr(t, opts), "MODE_MISMATCH: file1.txt")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
// TestFlagAfterArgumentIsArgument asserts that flags are read only before a
|
|
// command's first argument: after it, -v is a path, not the verbose flag.
|
|
func TestFlagAfterArgumentIsArgument(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
writeTestFile(t, fs, testFile1, "content")
|
|
|
|
opts := testOpts([]string{testApp, cmdGenerate, testDir, "-v"}, fs)
|
|
exitCode := runCLI(opts)
|
|
|
|
assert.Equal(t, 1, exitCode)
|
|
assert.Contains(t, testStderr(t, opts), "path does not exist: -v")
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|