All checks were successful
check / check (push) Successful in 35s
- 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.
434 lines
11 KiB
Go
434 lines
11 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"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())
|
|
}
|
|
|
|
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()
|
|
|
|
// Channel with no buffer - send should not block
|
|
ch := make(chan EnumerateStatus)
|
|
|
|
// This should not block
|
|
done := make(chan bool)
|
|
|
|
go func() {
|
|
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
|
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Success - did not block
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("sendEnumerateStatus blocked on full channel")
|
|
}
|
|
}
|
|
|
|
func TestSendScanStatusNonBlocking(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Channel with no buffer - send should not block
|
|
ch := make(chan ScanStatus)
|
|
|
|
done := make(chan bool)
|
|
|
|
go func() {
|
|
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
|
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Success - did not block
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("sendScanStatus blocked on full channel")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|