FindExtraFiles now skips hidden files/directories (dotfiles) and the manifest file itself when walking the filesystem. The manifest's relative path is computed at Checker construction time.
531 lines
15 KiB
Go
531 lines
15 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestStatusString(t *testing.T) {
|
|
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) {
|
|
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(&buf))
|
|
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
|
}
|
|
|
|
// createFilesOnDisk creates the given files on the filesystem.
|
|
func createFilesOnDisk(t *testing.T, fs afero.Fs, basePath string, files map[string][]byte) {
|
|
t.Helper()
|
|
|
|
for path, content := range files {
|
|
fullPath := basePath + "/" + path
|
|
require.NoError(t, fs.MkdirAll(basePath, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
|
}
|
|
}
|
|
|
|
func TestNewChecker(t *testing.T) {
|
|
t.Run("valid manifest", func(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file1.txt": []byte("hello"),
|
|
"file2.txt": []byte("world"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/", fs)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, chk)
|
|
assert.Equal(t, FileCount(2), chk.FileCount())
|
|
})
|
|
|
|
t.Run("missing manifest", func(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
_, err := NewChecker("/nonexistent.mf", "/", fs)
|
|
assert.Error(t, err)
|
|
})
|
|
|
|
t.Run("invalid manifest", func(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
|
_, err := NewChecker("/bad.mf", "/", fs)
|
|
assert.Error(t, err)
|
|
})
|
|
}
|
|
|
|
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
|
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, "/manifest.mf", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file1.txt": []byte("content one"),
|
|
"file2.txt": []byte("content two"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"exists.txt": []byte("I exist"),
|
|
"missing.txt": []byte("I don't exist on disk"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
// Only create one file
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"exists.txt": []byte("I exist"),
|
|
})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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)
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, 1, okCount)
|
|
assert.Equal(t, 1, missingCount)
|
|
}
|
|
|
|
func TestCheckSizeMismatch(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file.txt": []byte("original content"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
// Create file with different size
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"file.txt": []byte("short"),
|
|
})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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("file.txt"), r.Path)
|
|
}
|
|
|
|
func TestCheckHashMismatch(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
originalContent := []byte("original content")
|
|
files := map[string][]byte{
|
|
"file.txt": originalContent,
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
// Create file with same size but different content
|
|
differentContent := []byte("different contnt") // same length (16 bytes) but different
|
|
require.Equal(t, len(originalContent), len(differentContent), "test requires same length")
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"file.txt": differentContent,
|
|
})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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("file.txt"), r.Path)
|
|
}
|
|
|
|
func TestCheckWithProgress(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file1.txt": bytes.Repeat([]byte("a"), 100),
|
|
"file2.txt": bytes.Repeat([]byte("b"), 200),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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)
|
|
|
|
// Drain results
|
|
for range results {
|
|
}
|
|
|
|
// 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) {
|
|
fs := afero.NewMemMapFs()
|
|
// Create many files to ensure we have time to cancel
|
|
files := make(map[string][]byte)
|
|
for i := 0; i < 100; i++ {
|
|
files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000)
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
// Manifest only contains file1
|
|
manifestFiles := map[string][]byte{
|
|
"file1.txt": []byte("in manifest"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", manifestFiles)
|
|
|
|
// Disk has file1 and file2
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"file1.txt": []byte("in manifest"),
|
|
"file2.txt": []byte("extra file"),
|
|
})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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("file2.txt"), extras[0].Path)
|
|
assert.Equal(t, StatusExtra, extras[0].Status)
|
|
assert.Equal(t, "not in manifest", extras[0].Message)
|
|
}
|
|
|
|
func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
manifestFiles := map[string][]byte{
|
|
"file1.txt": []byte("in manifest"),
|
|
}
|
|
createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"file1.txt": []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("/data", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
|
|
|
|
chk, err := NewChecker("/data/.index.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{"file.txt": []byte("data")}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{"file.txt": []byte("data")}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{"file.txt": []byte("data")}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
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, "/manifest.mf", 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("/manifest.mf", "/data", 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) {
|
|
// 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{
|
|
"exists.txt": []byte("here"),
|
|
"missing.txt": []byte("not on disk"),
|
|
}
|
|
createTestManifest(t, fs, "/manifest.mf", files)
|
|
// Only create one file on disk
|
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
|
"exists.txt": []byte("here"),
|
|
})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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) {
|
|
// Regression test for #16: FindExtraFiles should not report dotfiles
|
|
// or the manifest file itself as extra files.
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file1.txt": []byte("in manifest"),
|
|
}
|
|
createTestManifest(t, fs, "/data/.index.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", 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("/data/.index.mf", "/data", 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) {
|
|
// The manifest file itself should never be reported as extra
|
|
fs := afero.NewMemMapFs()
|
|
files := map[string][]byte{
|
|
"file1.txt": []byte("content"),
|
|
}
|
|
createTestManifest(t, fs, "/data/index.mf", files)
|
|
createFilesOnDisk(t, fs, "/data", files)
|
|
|
|
chk, err := NewChecker("/data/index.mf", "/data", 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) {
|
|
fs := afero.NewMemMapFs()
|
|
// Create manifest with no files
|
|
createTestManifest(t, fs, "/manifest.mf", map[string][]byte{})
|
|
|
|
chk, err := NewChecker("/manifest.mf", "/data", 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)
|
|
}
|