All checks were successful
check / check (push) Successful in 53s
- Add canonical .golangci.yml (v2 schema, default: all, project thresholds for lll/funlen/cyclop/dupl) - Bump golangci-lint pins from v2.0.2 to v2.12.2 in Makefile (go install, new /v2 module path) and Dockerfile (tagged+digest Debian image pin) - Fix all lint findings surfaced by the new linter set across cmd/mfer, internal/bork, internal/cli, internal/log, and mfer: static sentinel errors (err113), context-aware HTTP and exec (noctx), guarded integer conversions and stricter permissions (gosec), named constants (mnd, goconst), function decomposition (funlen, cyclop, gocognit, nestif), declaration ordering (funcorder), t.Parallel/t.TempDir/t.Setenv adoption in tests (paralleltest, usetesting), protobuf getters (protogetter), plus formatting and style cleanups (wsl_v5, nlreturn, lll, revive, testifylint, and others) - Serialize CLI runs in tests behind a mutex so parallel tests do not cross-wire the process-global logger's captured output The decompositions are behavior-preserving. In particular: - REPO_POLICIES.md is untouched and stays byte-identical to the authoritative copy in the prompts repo - the mfer.manifest type stays unexported; whether to export it is an open owner design question (README question 13) - directories created by fetch keep mode 0755, because fetched trees are content meant to be readable by other uids - an absent MFFilePath.Mtime is handled explicitly and identically in freshen, list, and export rather than being read as the Unix epoch, which would classify every entry as changed and rewrite the manifest on every freshen - every user-visible error message renders byte-identically to what it did before, with the err113 sentinels wrapped mid-sentence where needed; the rendered strings are now pinned by tests Also fixes an argument-injection defect the lint pass surfaced: key IDs reach gpg as bare positional arguments, so a key ID beginning with "-" was parsed by gpg as an option. All positional arguments now follow an explicit "--" end-of-options marker. The symlink-escape gap in fetch's path handling, which sanitizePath does not and cannot address, is filed separately as #86.
765 lines
21 KiB
Go
765 lines
21 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
urfcli "github.com/urfave/cli/v2"
|
|
"sneak.berlin/go/mfer/mfer"
|
|
)
|
|
|
|
const (
|
|
testApp = "mfer"
|
|
testDir = "/testdir"
|
|
testFile1 = "/testdir/file1.txt"
|
|
testMF = "/testdir/test.mf"
|
|
testOutput = "/output.mf"
|
|
testOutputTmp = "/output.mf.tmp"
|
|
testManifest = "/manifest.mf"
|
|
testFlagBase = "--base"
|
|
testFlagNoExtra = "--no-extra-files"
|
|
)
|
|
|
|
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.
|
|
func runCLI(opts *RunOptions) int {
|
|
runMu.Lock()
|
|
defer runMu.Unlock()
|
|
|
|
return RunWithOptions(opts)
|
|
}
|
|
|
|
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, "version"}, fs)
|
|
|
|
exitCode := runCLI(opts)
|
|
|
|
assert.Equal(t, 0, exitCode)
|
|
|
|
stdout := testStdout(t, opts)
|
|
assert.Contains(t, stdout, mfer.Version)
|
|
assert.Contains(t, stdout, "abc123")
|
|
}
|
|
|
|
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, "fetch")
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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(fs, testMF)
|
|
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(fs, testMF)
|
|
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")
|
|
}
|
|
|
|
func TestCheckWithoutNoExtraFilesIgnoresExtra(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", testManifest, testDir}, fs)
|
|
exitCode := runCLI(opts)
|
|
require.Equal(t, 0, exitCode)
|
|
|
|
// Add extra file
|
|
writeTestFile(t, fs, "/testdir/extra.txt", "extra")
|
|
|
|
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
|
|
opts = testOpts([]string{
|
|
testApp, cmdCheck, "-q", testFlagBase, testDir, testManifest,
|
|
}, fs)
|
|
exitCode = runCLI(opts)
|
|
assert.Equal(t, 0, exitCode,
|
|
"check without --no-extra-files should ignore extra files")
|
|
}
|
|
|
|
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, testOutputTmp)
|
|
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, testOutputTmp)
|
|
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")
|
|
}
|
|
|
|
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, testOutputTmp)
|
|
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, testOutputTmp)
|
|
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 to generate a ~1MB manifest
|
|
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
|
require.NoError(t, fs.MkdirAll(testDir, 0o755))
|
|
|
|
numFiles := 20000
|
|
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's approximately 1MB
|
|
validManifest, err := afero.ReadFile(fs, testManifest)
|
|
require.NoError(t, err)
|
|
require.GreaterOrEqual(t, len(validManifest), 1024*1024,
|
|
"manifest should be at least 1MB, 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 500 random corruption iterations
|
|
for i := range 500 {
|
|
// 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))
|
|
}
|
|
}
|