check / check (push) Waiting to run
Without --output, gen given one directory now writes index.mf in it, and given one file writes index.mf beside it; with no path or several, it still writes index.mf in the current directory. An --output given with an empty value is refused. What gen lists depends only on its arguments and the tree, never on where the manifest is written, so gen DIR followed by check DIR passes. Scanner.EnumeratePaths lists a file argument by its name, as EnumerateFile does. Before, it listed the file under an empty path and gen stopped with "path cannot be empty". The --output help text and the README's Tool Examples state the default. Model: opus-5-5
470 lines
13 KiB
Go
470 lines
13 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestNewScanner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := NewScanner()
|
|
assert.NotNil(t, s)
|
|
assert.Equal(t, FileCount(0), s.FileCount())
|
|
assert.Equal(t, FileSize(0), s.TotalBytes())
|
|
}
|
|
|
|
func TestNewScannerWithOptions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil options", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := NewScannerWithOptions(nil)
|
|
assert.NotNil(t, s)
|
|
})
|
|
|
|
t.Run("with options", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
opts := &ScannerOptions{
|
|
IncludeDotfiles: true,
|
|
FollowSymLinks: true,
|
|
Fs: fs,
|
|
}
|
|
s := NewScannerWithOptions(opts)
|
|
assert.NotNil(t, s)
|
|
})
|
|
}
|
|
|
|
func TestScannerEnumerateFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumerateFile("/test.txt")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(1), s.FileCount())
|
|
assert.Equal(t, FileSize(11), s.TotalBytes())
|
|
|
|
files := s.Files()
|
|
require.Len(t, files, 1)
|
|
assert.Equal(t, RelFilePath("test.txt"), files[0].Path)
|
|
assert.Equal(t, FileSize(11), files[0].Size)
|
|
}
|
|
|
|
func TestScannerEnumerateFileMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumerateFile("/nonexistent.txt")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestScannerEnumeratePath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(3), s.FileCount())
|
|
assert.Equal(t, FileSize(3+3+5), s.TotalBytes())
|
|
}
|
|
|
|
func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
progress := make(chan EnumerateStatus, 10)
|
|
|
|
err := s.EnumeratePath("/testdir", progress)
|
|
require.NoError(t, err)
|
|
|
|
var updates []EnumerateStatus
|
|
for p := range progress {
|
|
updates = append(updates, p)
|
|
}
|
|
|
|
assert.NotEmpty(t, updates)
|
|
// Final update should show all files
|
|
final := updates[len(updates)-1]
|
|
assert.Equal(t, FileCount(2), final.FilesFound)
|
|
assert.Equal(t, FileSize(6), final.BytesFound)
|
|
}
|
|
|
|
func TestScannerEnumeratePaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
|
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/dir1/a.txt", []byte("aaa"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, "/dir2/b.txt", []byte("bbb"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumeratePaths(nil, "/dir1", "/dir2")
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(2), s.FileCount())
|
|
}
|
|
|
|
// TestScannerEnumeratePathsFile gives EnumeratePaths a directory and a
|
|
// file: the file is listed by its name, as EnumerateFile lists it.
|
|
func TestScannerEnumeratePathsFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/dir/sub", 0o755))
|
|
require.NoError(t, fs.MkdirAll("/other", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/dir/sub/one.txt", []byte("1"), 0o644))
|
|
require.NoError(t, afero.WriteFile(fs, "/other/two.txt", []byte("2"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
require.NoError(t, s.EnumeratePaths(nil, "/dir", "/other/two.txt"))
|
|
|
|
paths := make([]RelFilePath, 0, s.FileCount())
|
|
for _, f := range s.Files() {
|
|
paths = append(paths, f.Path)
|
|
}
|
|
|
|
assert.Equal(t, []RelFilePath{"sub/one.txt", "two.txt"}, paths)
|
|
}
|
|
|
|
func TestScannerExcludeDotfiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644))
|
|
|
|
t.Run("exclude by default", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(1), s.FileCount())
|
|
files := s.Files()
|
|
assert.Equal(t, RelFilePath("visible.txt"), files[0].Path)
|
|
})
|
|
|
|
t.Run("include when enabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(3), s.FileCount())
|
|
})
|
|
}
|
|
|
|
func TestScannerToManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = s.ToManifest(context.Background(), &buf, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Manifest should have magic bytes
|
|
assert.Positive(t, buf.Len())
|
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
|
}
|
|
|
|
func TestScannerToManifestWithProgress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
progress := make(chan ScanStatus, 10)
|
|
|
|
err = s.ToManifest(context.Background(), &buf, progress)
|
|
require.NoError(t, err)
|
|
|
|
var updates []ScanStatus
|
|
for p := range progress {
|
|
updates = append(updates, p)
|
|
}
|
|
|
|
assert.NotEmpty(t, updates)
|
|
// Final update should show completion
|
|
final := updates[len(updates)-1]
|
|
assert.Equal(t, FileCount(1), final.TotalFiles)
|
|
assert.Equal(t, FileCount(1), final.ScannedFiles)
|
|
assert.Equal(t, FileSize(1000), final.TotalBytes)
|
|
assert.Equal(t, FileSize(1000), final.ScannedBytes)
|
|
}
|
|
|
|
func TestScannerToManifestContextCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
// Create many files to ensure we have time to cancel
|
|
for i := range 100 {
|
|
name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt"
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0o644))
|
|
}
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumeratePath("/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = s.ToManifest(ctx, &buf, nil)
|
|
assert.ErrorIs(t, err, context.Canceled)
|
|
}
|
|
|
|
func TestScannerToManifestEmptyScanner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err := s.ToManifest(context.Background(), &buf, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Should still produce a valid manifest
|
|
assert.Positive(t, buf.Len())
|
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
|
}
|
|
|
|
func TestScannerFilesCopiesSlice(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
require.NoError(t, s.EnumerateFile("/test.txt"))
|
|
|
|
files1 := s.Files()
|
|
files2 := s.Files()
|
|
|
|
// Should be different slices
|
|
assert.NotSame(t, &files1[0], &files2[0])
|
|
}
|
|
|
|
func TestScannerEnumerateFS(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644))
|
|
|
|
// Create a basepath filesystem
|
|
baseFs := afero.NewBasePathFs(fs, "/testdir")
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
err := s.EnumerateFS(baseFs, "/testdir", nil)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, FileCount(2), s.FileCount())
|
|
}
|
|
|
|
func TestSendEnumerateStatusNonBlocking(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Nobody receives, so a blocking send would hang the test into its timeout.
|
|
ch := make(chan EnumerateStatus)
|
|
|
|
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
|
}
|
|
|
|
func TestSendScanStatusNonBlocking(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Nobody receives, so a blocking send would hang the test into its timeout.
|
|
ch := make(chan ScanStatus)
|
|
|
|
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
|
}
|
|
|
|
func TestSendStatusNilChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Should not panic with nil channel
|
|
sendEnumerateStatus(nil, EnumerateStatus{})
|
|
sendScanStatus(nil, ScanStatus{})
|
|
}
|
|
|
|
func TestScannerFileEntryFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
now := time.Now().Truncate(time.Second)
|
|
|
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644))
|
|
require.NoError(t, fs.Chtimes("/test.txt", now, now))
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
require.NoError(t, s.EnumerateFile("/test.txt"))
|
|
|
|
files := s.Files()
|
|
require.Len(t, files, 1)
|
|
|
|
entry := files[0]
|
|
assert.Equal(t, RelFilePath("test.txt"), entry.Path)
|
|
assert.Contains(t, string(entry.AbsPath), "test.txt")
|
|
assert.Equal(t, FileSize(7), entry.Size)
|
|
// Mtime should be set (within a second of now)
|
|
assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
|
|
}
|
|
|
|
// A manifest records every mode as 0000 unless the creator asks for
|
|
// permissions; then it records each file's permission bits and never the
|
|
// setuid, setgid or sticky bits.
|
|
func TestScannerRecordsModeOnlyWhenAsked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, afero.WriteFile(fs, "/"+testFile1, []byte("a"), 0o640))
|
|
require.NoError(t, afero.WriteFile(fs, "/run.sh", []byte("b"), 0o755))
|
|
require.NoError(t, afero.WriteFile(fs, "/su", []byte("c"), 0o755))
|
|
require.NoError(t, fs.Chmod("/su", 0o755|os.ModeSetuid|os.ModeSetgid|os.ModeSticky))
|
|
|
|
for _, tc := range []struct {
|
|
includePermissions bool
|
|
want map[string]uint32
|
|
}{
|
|
{false, map[string]uint32{testFile1: 0, "run.sh": 0, "su": 0}},
|
|
{true, map[string]uint32{testFile1: 0o640, "run.sh": 0o755, "su": 0o755}},
|
|
} {
|
|
s := NewScannerWithOptions(&ScannerOptions{
|
|
Fs: fs,
|
|
IncludePermissions: tc.includePermissions,
|
|
})
|
|
require.NoError(t, s.EnumerateFS(fs, "/", nil))
|
|
|
|
var buf bytes.Buffer
|
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
|
|
m, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
|
|
got := map[string]uint32{}
|
|
for _, f := range m.Files() {
|
|
got[f.GetPath()] = f.GetMode()
|
|
}
|
|
|
|
assert.Equal(t, tc.want, got, "IncludePermissions: %v", tc.includePermissions)
|
|
}
|
|
}
|
|
|
|
func TestScannerLargeFileEnumeration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
|
|
// Create 100 files
|
|
for i := range 100 {
|
|
name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt"
|
|
require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644))
|
|
}
|
|
|
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
|
progress := make(chan EnumerateStatus, 200)
|
|
|
|
err := s.EnumeratePath("/testdir", progress)
|
|
require.NoError(t, err)
|
|
|
|
// progress is fully buffered and closed; no draining needed
|
|
|
|
assert.Equal(t, FileCount(100), s.FileCount())
|
|
assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes
|
|
}
|
|
|
|
func TestIsHiddenPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
path string
|
|
hidden bool
|
|
}{
|
|
{testFileName, false},
|
|
{".hidden", true},
|
|
{"dir/file.txt", false},
|
|
{"dir/.hidden", true},
|
|
{".dir/file.txt", true},
|
|
{"/absolute/path", false},
|
|
{"/absolute/.hidden", true},
|
|
{"./relative", false}, // path.Clean removes leading ./
|
|
{"a/b/c/.d/e", true},
|
|
{".", false}, // current directory is not hidden (#14)
|
|
{"/", false}, // root is not hidden
|
|
{"./", false}, // current directory with trailing slash
|
|
{"./file.txt", false}, // file in current directory
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path)
|
|
})
|
|
}
|
|
}
|