check / check (push) Waiting to run
check given a URL wrote the manifest it downloaded to a file in the temp directory before loading it, so it failed wherever that directory is missing or cannot be written, such as the scratch image script/cibuild builds. It now keeps the manifest in memory, as fetch, list and export do. NewChecker reads a manifest handed to it as a reader through the new CheckerOptions.ManifestReader field, in place of ManifestPath, because it otherwise reads the manifest from the filesystem holding the files it checks. fetch now hands its downloaded manifest to the Checker the same way, not through an in-memory filesystem. Model: opus-5-5
875 lines
22 KiB
Go
875 lines
22 KiB
Go
//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"
|
|
)
|
|
|
|
func TestStatusString(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
status Status
|
|
expected string
|
|
}{
|
|
{StatusOK, "OK"},
|
|
{StatusMissing, "MISSING"},
|
|
{StatusSizeMismatch, "SIZE_MISMATCH"},
|
|
{StatusHashMismatch, "HASH_MISMATCH"},
|
|
{StatusModeMismatch, "MODE_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()), 0, 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)
|
|
}
|
|
|
|
// TestNewCheckerReadsManifestFromReader gives NewChecker the manifest as a
|
|
// reader and no manifest path, on a filesystem that holds only the files it
|
|
// lists: it checks those files and finds nothing unlisted.
|
|
func TestNewCheckerReadsManifestFromReader(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
files := map[string][]byte{testFile1: []byte("hello")}
|
|
|
|
manifestFs := afero.NewMemMapFs()
|
|
createTestManifest(t, manifestFs, testManifestPath, files)
|
|
manifestData, err := afero.ReadFile(manifestFs, testManifestPath)
|
|
require.NoError(t, err)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
createFilesOnDisk(t, fs, files)
|
|
|
|
chk, err := NewChecker(&CheckerOptions{
|
|
ManifestReader: bytes.NewReader(manifestData),
|
|
BasePath: testDataDir,
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
results := make(chan Result, 1)
|
|
require.NoError(t, chk.Check(context.Background(), results, nil))
|
|
assert.Equal(t, Result{Path: testFile1, Status: StatusOK}, <-results)
|
|
|
|
extras := make(chan Result, len(files))
|
|
require.NoError(t, chk.FindExtraFiles(context.Background(), extras))
|
|
assert.Empty(t, extras)
|
|
}
|
|
|
|
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, StatusModeMismatch,
|
|
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)
|
|
}
|
|
|
|
// A recorded mode other than 0000 that differs from the file's permission
|
|
// bits fails the check; a recorded 0000 is never checked.
|
|
func TestCheckMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
recorded os.FileMode
|
|
onDisk os.FileMode
|
|
want Status
|
|
}{
|
|
{"recorded mode matches", 0o640, 0o640, StatusOK},
|
|
{"recorded mode differs", 0o640, 0o600, StatusModeMismatch},
|
|
{"0000 is not checked", 0, 0o600, StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
content := []byte("content")
|
|
|
|
b := NewBuilder()
|
|
_, err := b.AddFile(testFileName, FileSize(len(content)), ModTime{},
|
|
tc.recorded, bytes.NewReader(content), nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
require.NoError(t, b.Build(context.Background(), &buf))
|
|
require.NoError(t, afero.WriteFile(fs, testManifestPath, buf.Bytes(), 0o644))
|
|
require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
|
|
require.NoError(t, afero.WriteFile(fs,
|
|
testDataDir+"/"+testFileName, content, tc.onDisk))
|
|
|
|
chk, err := NewChecker(&CheckerOptions{
|
|
ManifestPath: testManifestPath,
|
|
BasePath: testDataDir,
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
results := make(chan Result, 1)
|
|
require.NoError(t, chk.Check(context.Background(), results, nil))
|
|
|
|
assert.Equal(t, tc.want, (<-results).Status)
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// TestFindExtraFilesReportsHiddenFilesButNotManifest keeps the manifest
|
|
// inside the checked tree: hidden files and directories are reported, the
|
|
// manifest is not. The manifest is recognized by file identity, which needs
|
|
// the real filesystem.
|
|
func TestFindExtraFilesReportsHiddenFilesButNotManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
manifestPath := filepath.Join(dir, "index.mf")
|
|
|
|
fs := afero.NewOsFs()
|
|
createTestManifest(t, fs, manifestPath, map[string][]byte{
|
|
testFile1: []byte("in manifest"),
|
|
})
|
|
|
|
unlisted := []RelFilePath{"extra.txt", ".hidden", ".git/config"}
|
|
for _, p := range append([]RelFilePath{testFile1}, unlisted...) {
|
|
path := filepath.Join(dir, string(p))
|
|
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o750))
|
|
require.NoError(t, afero.WriteFile(fs, path, []byte("x"), 0o600))
|
|
}
|
|
|
|
chk, err := NewChecker(&CheckerOptions{
|
|
ManifestPath: manifestPath,
|
|
BasePath: dir,
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
results := make(chan Result, 10)
|
|
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
|
|
|
var extras []RelFilePath
|
|
for r := range results {
|
|
extras = append(extras, r.Path)
|
|
}
|
|
|
|
assert.ElementsMatch(t, unlisted, extras)
|
|
}
|
|
|
|
// TestFindExtraFilesSkipsManifestReachedThroughSymlink checks a tree whose
|
|
// index.mf is a symlink to the manifest kept outside the tree: the symlink is
|
|
// not reported.
|
|
func TestFindExtraFilesSkipsManifestReachedThroughSymlink(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
tree := filepath.Join(dir, "tree")
|
|
manifestPath := filepath.Join(dir, "real.mf")
|
|
linkPath := filepath.Join(tree, "index.mf")
|
|
|
|
fs := afero.NewOsFs()
|
|
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
|
|
require.NoError(t, fs.MkdirAll(tree, 0o750))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, filepath.Join(tree, testFile1), []byte("x"), 0o600))
|
|
require.NoError(t, os.Symlink(manifestPath, linkPath))
|
|
|
|
chk, err := NewChecker(&CheckerOptions{
|
|
ManifestPath: linkPath,
|
|
BasePath: tree,
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
results := make(chan Result, 10)
|
|
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
|
|
|
var extras []RelFilePath
|
|
for r := range results {
|
|
extras = append(extras, r.Path)
|
|
}
|
|
|
|
assert.Empty(t, extras)
|
|
}
|
|
|
|
// TestFindExtraFilesSearchesBaseNamedThroughSymlink names the checked tree
|
|
// through a symlink to it: the files in the tree are searched, and the
|
|
// symlink itself is not reported.
|
|
func TestFindExtraFilesSearchesBaseNamedThroughSymlink(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
tree := filepath.Join(dir, "tree")
|
|
link := filepath.Join(dir, "link")
|
|
manifestPath := filepath.Join(dir, "index.mf")
|
|
|
|
fs := afero.NewOsFs()
|
|
createTestManifest(t, fs, manifestPath, map[string][]byte{testFile1: []byte("x")})
|
|
require.NoError(t, fs.MkdirAll(tree, 0o750))
|
|
|
|
for _, name := range []string{testFile1, testFile2} {
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, filepath.Join(tree, name), []byte("x"), 0o600))
|
|
}
|
|
|
|
require.NoError(t, os.Symlink(tree, link))
|
|
|
|
chk, err := NewChecker(&CheckerOptions{
|
|
ManifestPath: manifestPath,
|
|
BasePath: link,
|
|
Fs: fs,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
results := make(chan Result, 10)
|
|
require.NoError(t, chk.FindExtraFiles(context.Background(), results))
|
|
|
|
var extras []RelFilePath
|
|
for r := range results {
|
|
extras = append(extras, r.Path)
|
|
}
|
|
|
|
assert.Equal(t, []RelFilePath{testFile2}, extras)
|
|
}
|
|
|
|
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 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")
|
|
}
|