check / check (push) Waiting to run
MFFilePath gains mode (field 304): a file's permission bits, 0777 at most, or 0000, meaning none recorded. gen and freshen record real modes only with --include-permissions (ScannerOptions.IncludePermissions); the builder keeps only mode.Perm(), so setuid, setgid and sticky are never written. list -l and export show the mode in octal. check reports MODE_MISMATCH for a recorded mode other than 0000 the file lacks. fetch refuses a manifest with a mode above 0777 before requesting any file, sets only the permission bits of each recorded mode on the files it writes, and downloads again a present file whose mode differs. The decoding-cost bound counts a file entry at 176 bytes, up from 160. Model: opus-5-5
448 lines
12 KiB
Go
448 lines
12 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())
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
}
|