//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "bytes" "context" "os" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/mfer/mfer" ) // stubFileInfo is a minimal fs.FileInfo for exercising recordEntry // without touching a filesystem. type stubFileInfo struct { size int64 mtime time.Time } func (s stubFileInfo) Name() string { return "stub" } func (s stubFileInfo) Size() int64 { return s.size } func (s stubFileInfo) Mode() os.FileMode { return 0 } func (s stubFileInfo) ModTime() time.Time { return s.mtime } func (s stubFileInfo) IsDir() bool { return false } func (s stubFileInfo) Sys() any { return nil } // setupFreshenDir populates /testdir with two files, scans it, and // writes the resulting manifest to /testdir/.index.mf. func setupFreshenDir(t *testing.T, fs afero.Fs) { t.Helper() require.NoError(t, fs.MkdirAll(testDir, 0o755)) writeTestFile(t, fs, testFile1, "content1") writeTestFile(t, fs, "/testdir/file2.txt", "content2") // Generate initial manifest opts := &mfer.ScannerOptions{Fs: fs} s := mfer.NewScannerWithOptions(opts) require.NoError(t, s.EnumeratePath(testDir, nil)) var manifestBuf bytes.Buffer require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil)) // Write manifest to filesystem require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644)) } func TestFreshenUnchanged(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() setupFreshenDir(t, fs) // Parse manifest to verify manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf") require.NoError(t, err) assert.Len(t, manifest.Files(), 2) } func TestFreshenWithChanges(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() setupFreshenDir(t, fs) // Verify initial manifest has 2 files manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf") require.NoError(t, err) assert.Len(t, manifest.Files(), 2) // Add a new file writeTestFile(t, fs, "/testdir/file3.txt", "content3") // Modify file2 (change content and size) writeTestFile(t, fs, "/testdir/file2.txt", "modified content2") // Remove file1 require.NoError(t, fs.Remove(testFile1)) // Note: The freshen operation would need to be run here // For now, we just verify the test setup is correct exists, _ := afero.Exists(fs, testFile1) assert.False(t, exists) exists, _ = afero.Exists(fs, "/testdir/file3.txt") assert.True(t, exists) content, _ := afero.ReadFile(fs, "/testdir/file2.txt") assert.Equal(t, "modified content2", string(content)) } // TestFreshenRecordEntryMtimePresence pins the behavior of recordEntry // with respect to MFFilePath.Mtime, which is a message pointer with // proto3 field presence and may legitimately be absent. // // An absent mtime must never be read as time.Unix(0, 0): that value // never equals a real modification time, so every entry would be // classified as changed, re-hashed, and the manifest rewritten // unconditionally - the exact inverse of what freshen is for, and // silent. An entry with no mtime is therefore "changed" because it // cannot be compared, not because it looks like it dates from 1970. func TestFreshenRecordEntryMtimePresence(t *testing.T) { t.Parallel() const relPath = "file1.txt" mtime := time.Unix(1_700_000_000, 0) info := stubFileInfo{size: 8, mtime: mtime} for _, tc := range []struct { name string entry *mfer.MFFilePath needsHash bool changed int64 unchanged int64 }{ { name: "matching mtime and size is unchanged", entry: &mfer.MFFilePath{ Path: relPath, Size: 8, Mtime: &mfer.Timestamp{Seconds: mtime.Unix()}, }, needsHash: false, changed: 0, unchanged: 1, }, { name: "absent mtime is changed, not epoch", entry: &mfer.MFFilePath{ Path: relPath, Size: 8, Mtime: nil, }, needsHash: true, changed: 1, unchanged: 0, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() s := &freshenScanner{ existingByPath: map[string]*mfer.MFFilePath{relPath: tc.entry}, } s.recordEntry(relPath, info) require.Len(t, s.entries, 1) assert.Equal(t, tc.needsHash, s.entries[0].needsHash) assert.Equal(t, tc.changed, s.changed) assert.Equal(t, tc.unchanged, s.unchanged) assert.Zero(t, s.added) }) } } // TestFreshenAddExistingRejectsMissingMtime pins that an entry with no // mtime is never carried forward into a rebuilt manifest with a // fabricated epoch timestamp. func TestFreshenAddExistingRejectsMissingMtime(t *testing.T) { t.Parallel() b := mfer.NewBuilder() entry := &mfer.MFFilePath{ Path: "file1.txt", Size: 8, Mtime: nil, Hashes: []*mfer.MFFileChecksum{ {MultiHash: []byte{0x12, 0x20}}, }, } err := addExistingToBuilder(b, entry) require.ErrorIs(t, err, errEntryMissingMtime) assert.Contains(t, err.Error(), "file1.txt") } // TestEntryMtime pins the presence semantics the callers depend on. func TestEntryMtime(t *testing.T) { t.Parallel() got, ok := entryMtime(&mfer.MFFilePath{Mtime: nil}) assert.False(t, ok) assert.True(t, got.IsZero()) got, ok = entryMtime(&mfer.MFFilePath{ Mtime: &mfer.Timestamp{Seconds: 1_700_000_000, Nanos: 500}, }) assert.True(t, ok) assert.Equal(t, time.Unix(1_700_000_000, 500), got) }