//nolint:testpackage // white-box tests exercise unexported internals package mfer import ( "bytes" "context" "fmt" "os" "path/filepath" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( testFile1 = "file1.txt" testFile2 = "file2.txt" testExistsFile = "exists.txt" testManifestPath = "/manifest.mf" testDataDir = "/data" // testDataManifestPath is a manifest kept inside the checked tree. testDataManifestPath = testDataDir + "/index.mf" ) func TestStatusString(t *testing.T) { t.Parallel() tests := []struct { status Status expected string }{ {StatusOK, "OK"}, {StatusMissing, "MISSING"}, {StatusSizeMismatch, "SIZE_MISMATCH"}, {StatusHashMismatch, "HASH_MISMATCH"}, {StatusExtra, "EXTRA"}, {StatusError, "ERROR"}, {Status(99), "UNKNOWN"}, } for _, tt := range tests { t.Run(tt.expected, func(t *testing.T) { t.Parallel() assert.Equal(t, tt.expected, tt.status.String()) }) } } // createTestManifest creates a manifest file in the filesystem with the given files. func createTestManifest( t *testing.T, fs afero.Fs, manifestPath string, files map[string][]byte, ) { t.Helper() builder := NewBuilder() for path, content := range files { reader := bytes.NewReader(content) _, err := builder.AddFile( RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), reader, nil, ) require.NoError(t, err) } var buf bytes.Buffer require.NoError(t, builder.Build(context.Background(), &buf)) require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644)) } // createFilesOnDisk creates the given files on the filesystem under // testDataDir. func createFilesOnDisk(t *testing.T, fs afero.Fs, files map[string][]byte) { t.Helper() for path, content := range files { fullPath := testDataDir + "/" + path require.NoError(t, fs.MkdirAll(testDataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644)) } } func TestNewChecker(t *testing.T) { t.Parallel() t.Run("valid manifest", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ testFile1: []byte("hello"), testFile2: []byte("world"), } createTestManifest(t, fs, testManifestPath, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: "/", Fs: fs, }) require.NoError(t, err) assert.NotNil(t, chk) assert.Equal(t, FileCount(2), chk.FileCount()) }) t.Run("missing manifest", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() _, err := NewChecker(&CheckerOptions{ ManifestPath: "/nonexistent.mf", BasePath: "/", Fs: fs, }) assert.Error(t, err) }) t.Run("invalid manifest", func(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644)) _, err := NewChecker(&CheckerOptions{ ManifestPath: "/bad.mf", BasePath: "/", Fs: fs, }) assert.Error(t, err) }) } func TestNewCheckerRequiredPaths(t *testing.T) { t.Parallel() for _, tc := range []struct { name string opts *CheckerOptions want string is error }{ { name: "nil options", opts: nil, want: "manifest path cannot be empty", is: errManifestPathEmpty, }, { name: "empty manifest path", opts: &CheckerOptions{BasePath: testDataDir}, want: "manifest path cannot be empty", is: errManifestPathEmpty, }, { name: "empty base path", opts: &CheckerOptions{ManifestPath: testManifestPath}, want: "base path cannot be empty", is: errBasePathEmpty, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() chk, err := NewChecker(tc.opts) require.ErrorIs(t, err, tc.is) require.EqualError(t, err, tc.want) assert.Nil(t, chk) }) } } func TestNewCheckerNilFsUsesOsFs(t *testing.T) { t.Parallel() dir := t.TempDir() manifestPath := filepath.Join(dir, "index.mf") content := []byte("hello") createTestManifest(t, afero.NewOsFs(), manifestPath, map[string][]byte{ testFile1: content, }) require.NoError(t, os.WriteFile(filepath.Join(dir, testFile1), content, 0o600)) chk, err := NewChecker(&CheckerOptions{ManifestPath: manifestPath, BasePath: dir}) require.NoError(t, err) results := make(chan Result, 1) require.NoError(t, chk.Check(context.Background(), results, nil)) assert.Equal(t, StatusOK, (<-results).Status) } func TestCheckerFileCountAndTotalBytes(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ "small.txt": []byte("hi"), "medium.txt": []byte("hello world"), "large.txt": bytes.Repeat([]byte("x"), 1000), } createTestManifest(t, fs, testManifestPath, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: "/", Fs: fs, }) require.NoError(t, err) assert.Equal(t, FileCount(3), chk.FileCount()) assert.Equal(t, FileSize(2+11+1000), chk.TotalBytes()) } func TestCheckAllFilesOK(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ testFile1: []byte("content one"), testFile2: []byte("content two"), } createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) var resultList []Result for r := range results { resultList = append(resultList, r) } assert.Len(t, resultList, 2) for _, r := range resultList { assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path) } } func TestCheckMissingFile(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ testExistsFile: []byte("I exist"), "missing.txt": []byte("I don't exist on disk"), } createTestManifest(t, fs, testManifestPath, files) // Only create one file createFilesOnDisk(t, fs, map[string][]byte{ testExistsFile: []byte("I exist"), }) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) var okCount, missingCount int for r := range results { switch r.Status { case StatusOK: okCount++ case StatusMissing: missingCount++ assert.Equal(t, RelFilePath("missing.txt"), r.Path) case StatusSizeMismatch, StatusHashMismatch, StatusExtra, StatusError: // Not expected in this test; counted assertions below will fail. } } assert.Equal(t, 1, okCount) assert.Equal(t, 1, missingCount) } func TestCheckSizeMismatch(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ testFileName: []byte("original content"), } createTestManifest(t, fs, testManifestPath, files) // Create file with different size createFilesOnDisk(t, fs, map[string][]byte{ testFileName: []byte("short"), }) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) r := <-results assert.Equal(t, StatusSizeMismatch, r.Status) assert.Equal(t, RelFilePath(testFileName), r.Path) } func TestCheckHashMismatch(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() originalContent := []byte("original content") files := map[string][]byte{ testFileName: originalContent, } createTestManifest(t, fs, testManifestPath, files) // Create file with same size but different content differentContent := []byte("different contnt") // same length (16 bytes) but different require.Len(t, differentContent, len(originalContent), "test requires same length") createFilesOnDisk(t, fs, map[string][]byte{ testFileName: differentContent, }) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) r := <-results assert.Equal(t, StatusHashMismatch, r.Status) assert.Equal(t, RelFilePath(testFileName), r.Path) } func TestCheckWithProgress(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ testFile1: bytes.Repeat([]byte("a"), 100), testFile2: bytes.Repeat([]byte("b"), 200), } createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) progress := make(chan CheckStatus, 10) err = chk.Check(context.Background(), results, progress) require.NoError(t, err) // results is fully buffered and closed; no draining needed // Check progress was sent var progressUpdates []CheckStatus for p := range progress { progressUpdates = append(progressUpdates, p) } assert.NotEmpty(t, progressUpdates) // Final progress should show all files checked final := progressUpdates[len(progressUpdates)-1] assert.Equal(t, FileCount(2), final.TotalFiles) assert.Equal(t, FileCount(2), final.CheckedFiles) assert.Equal(t, FileSize(300), final.TotalBytes) assert.Equal(t, FileSize(300), final.CheckedBytes) assert.Equal(t, FileCount(0), final.Failures) } func TestCheckContextCancellation(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create many files to ensure we have time to cancel files := make(map[string][]byte) for i := range 100 { files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000) } createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately results := make(chan Result, 200) err = chk.Check(ctx, results, nil) assert.ErrorIs(t, err, context.Canceled) } func TestFindExtraFiles(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Manifest only contains file1 manifestFiles := map[string][]byte{ testFile1: []byte("in manifest"), } createTestManifest(t, fs, testManifestPath, manifestFiles) // Disk has file1 and file2 createFilesOnDisk(t, fs, map[string][]byte{ testFile1: []byte("in manifest"), testFile2: []byte("extra file"), }) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.FindExtraFiles(context.Background(), results) require.NoError(t, err) var extras []Result for r := range results { extras = append(extras, r) } assert.Len(t, extras, 1) assert.Equal(t, RelFilePath(testFile2), extras[0].Path) assert.Equal(t, StatusExtra, extras[0].Status) assert.Equal(t, "not in manifest", extras[0].Message) } func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() manifestFiles := map[string][]byte{ testFile1: []byte("in manifest"), } createTestManifest(t, fs, testDataManifestPath, manifestFiles) createFilesOnDisk(t, fs, map[string][]byte{ testFile1: []byte("in manifest"), }) // Create dotfile and manifest that should be skipped require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("hidden"), 0o644)) require.NoError(t, afero.WriteFile(fs, "/data/.config/settings", []byte("cfg"), 0o644)) // Create a real extra file require.NoError(t, fs.MkdirAll(testDataDir, 0o755)) require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644)) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.FindExtraFiles(context.Background(), results) require.NoError(t, err) var extras []Result for r := range results { extras = append(extras, r) } // Should only report extra.txt, not .hidden, .config/settings, or index.mf for _, e := range extras { t.Logf("extra: %s", e.Path) } assert.Len(t, extras, 1) if len(extras) > 0 { assert.Equal(t, RelFilePath("extra.txt"), extras[0].Path) } } func TestFindExtraFilesContextCancellation(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{testFileName: []byte("data")} createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately results := make(chan Result, 10) err = chk.FindExtraFiles(ctx, results) assert.ErrorIs(t, err, context.Canceled) } func TestCheckNilChannels(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{testFileName: []byte("data")} createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) // Should not panic with nil channels err = chk.Check(context.Background(), nil, nil) assert.NoError(t, err) } func TestFindExtraFilesNilChannel(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{testFileName: []byte("data")} createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) // Should not panic with nil channel err = chk.FindExtraFiles(context.Background(), nil) assert.NoError(t, err) } func TestCheckSubdirectories(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() files := map[string][]byte{ "dir1/file1.txt": []byte("content1"), "dir1/dir2/file2.txt": []byte("content2"), "dir1/dir2/dir3/deep.txt": []byte("deep content"), } createTestManifest(t, fs, testManifestPath, files) // Create files with full directory structure for path, content := range files { fullPath := "/data/" + path require.NoError(t, fs.MkdirAll("/data/dir1/dir2/dir3", 0o755)) require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644)) } chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) var okCount int for r := range results { assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path) okCount++ } assert.Equal(t, 3, okCount) } func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) { t.Parallel() // Regression test: errors.Is(err, errors.New("...")) never matches because // errors.New creates a new value each time. The fix uses os.ErrNotExist instead. fs := afero.NewMemMapFs() files := map[string][]byte{ testExistsFile: []byte("here"), "missing.txt": []byte("not on disk"), } createTestManifest(t, fs, testManifestPath, files) // Only create one file on disk createFilesOnDisk(t, fs, map[string][]byte{ testExistsFile: []byte("here"), }) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) statusCounts := map[Status]int{} for r := range results { statusCounts[r.Status]++ if r.Status == StatusMissing { assert.Equal(t, RelFilePath("missing.txt"), r.Path) } } assert.Equal(t, 1, statusCounts[StatusOK], "one file should be OK") assert.Equal(t, 1, statusCounts[StatusMissing], "one file should be MISSING") assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR") } func TestFindExtraFilesSkipsDotfiles(t *testing.T) { t.Parallel() // Regression test for #16: FindExtraFiles should not report dotfiles // or the manifest file itself as extra files. fs := afero.NewMemMapFs() files := map[string][]byte{ testFile1: []byte("in manifest"), } createTestManifest(t, fs, testDataManifestPath, files) createFilesOnDisk(t, fs, files) // Add dotfiles and manifest file on disk require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("dotfile"), 0o644)) require.NoError(t, fs.MkdirAll("/data/.git", 0o755)) require.NoError(t, afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644)) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.FindExtraFiles(context.Background(), results) require.NoError(t, err) var extras []Result for r := range results { extras = append(extras, r) } // Should report NO extra files — dotfiles and manifest should be skipped assert.Empty(t, extras, "FindExtraFiles should not report dotfiles or manifest file as extra; got: %v", extras) } func TestFindExtraFilesSkipsManifestFile(t *testing.T) { t.Parallel() // The manifest file itself should never be reported as extra fs := afero.NewMemMapFs() files := map[string][]byte{ testFile1: []byte("content"), } createTestManifest(t, fs, testDataManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testDataManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 10) err = chk.FindExtraFiles(context.Background(), results) require.NoError(t, err) var extras []Result for r := range results { extras = append(extras, r) } assert.Empty(t, extras, "manifest file should not be reported as extra; got: %v", extras) } func TestCheckEmptyManifest(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() // Create manifest with no files createTestManifest(t, fs, testManifestPath, map[string][]byte{}) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) assert.Equal(t, FileCount(0), chk.FileCount()) assert.Equal(t, FileSize(0), chk.TotalBytes()) results := make(chan Result, 10) err = chk.Check(context.Background(), results, nil) require.NoError(t, err) var count int for range results { count++ } assert.Equal(t, 0, count) } func TestCheckProgressRateLimited(t *testing.T) { t.Parallel() // Create many small files - progress should be rate-limited, not one per file. // With rate-limiting to once per second, we should get far fewer progress // updates than files (plus one final update). fs := afero.NewMemMapFs() files := make(map[string][]byte, 100) for i := range 100 { name := fmt.Sprintf("file%03d.txt", i) files[name] = []byte("content") } createTestManifest(t, fs, testManifestPath, files) createFilesOnDisk(t, fs, files) chk, err := NewChecker(&CheckerOptions{ ManifestPath: testManifestPath, BasePath: testDataDir, Fs: fs, }) require.NoError(t, err) results := make(chan Result, 200) progress := make(chan CheckStatus, 200) err = chk.Check(context.Background(), results, progress) require.NoError(t, err) // results is fully buffered and closed; no draining needed // Count progress updates var progressCount int for range progress { progressCount++ } // Should be far fewer than 100 (rate-limited to once per second) // At minimum we get the final update assert.GreaterOrEqual(t, progressCount, 1, "should get at least the final progress update") assert.Less(t, progressCount, 100, "progress should be rate-limited, not one per file") }