//nolint:testpackage // white-box tests exercise unexported internals package cli import ( "crypto/sha256" "maps" "os" "path/filepath" "slices" "testing" "time" "github.com/multiformats/go-multihash" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "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 writes files, by path and content, into a fresh temp // dir and runs gen on it. It returns the temp dir and the path of the // manifest gen wrote there. func setupFreshenDir( t *testing.T, fs afero.Fs, files map[string]string, ) (string, string) { t.Helper() root := t.TempDir() manifestPath := filepath.Join(root, defaultManifestName) for path, content := range files { require.NoError(t, fs.MkdirAll(filepath.Dir(filepath.Join(root, path)), 0o750)) writeTestFile(t, fs, filepath.Join(root, path), content) } opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) return root, manifestPath } // runFreshen runs freshen on the manifest at manifestPath for the tree // at root and requires it to succeed. func runFreshen(t *testing.T, fs afero.Fs, root, manifestPath string) { t.Helper() opts := testOpts([]string{ testApp, cmdFreshen, "-q", testFlagBase, root, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) } // manifestFiles returns the file entries of the manifest at path. func manifestFiles(t *testing.T, fs afero.Fs, path string) []*mfer.MFFilePath { t.Helper() manifest, err := mfer.NewManifestFromFile(&mfer.ManifestFromFileOptions{ Path: path, Fs: fs, }) require.NoError(t, err) return manifest.Files() } // TestFreshenUnchanged freshens a tree that has not changed since gen // made its manifest: the manifest must list the same entries as before. func TestFreshenUnchanged(t *testing.T) { t.Parallel() fs := afero.NewOsFs() root, manifestPath := setupFreshenDir(t, fs, map[string]string{testFileTxt: "content1", testDirFile: "content2"}) before := manifestFiles(t, fs, manifestPath) runFreshen(t, fs, root, manifestPath) after := manifestFiles(t, fs, manifestPath) require.Len(t, after, len(before)) for i := range before { assert.True(t, proto.Equal(before[i], after[i]), "entry for %s changed", before[i].GetPath()) } } // assertManifestLists asserts that the manifest at manifestPath lists // exactly the files in want, each with the size and SHA-256 hash of its // content in want and the mtime of the file of that name under root. func assertManifestLists( t *testing.T, fs afero.Fs, root, manifestPath string, want map[string]string, ) { t.Helper() files := manifestFiles(t, fs, manifestPath) listed := make([]string, 0, len(files)) for _, f := range files { listed = append(listed, f.GetPath()) content, ok := want[f.GetPath()] if !ok { continue // reported by the ElementsMatch below } digest := sha256.Sum256([]byte(content)) hash, err := multihash.Encode(digest[:], multihash.SHA2_256) require.NoError(t, err) assert.Equal(t, int64(len(content)), f.GetSize(), f.GetPath()) require.NotEmpty(t, f.GetHashes(), f.GetPath()) assert.Equal(t, hash, f.GetHashes()[0].GetMultiHash(), f.GetPath()) info, err := fs.Stat(filepath.Join(root, f.GetPath())) require.NoError(t, err) mtime, ok := entryMtime(f) assert.True(t, ok && mtime.Equal(info.ModTime()), "%s: manifest has mtime %v, file has %v", f.GetPath(), mtime, info.ModTime()) } assert.ElementsMatch(t, slices.Collect(maps.Keys(want)), listed) } // TestFreshenWithChanges makes one change to a tree after gen made its // manifest, then freshens the manifest. The rewritten manifest must list // exactly the files now in the tree, and check must pass on the tree. func TestFreshenWithChanges(t *testing.T) { t.Parallel() tree := map[string]string{testFileTxt: "content1", testDirFile: "content2"} // Every file a case writes gets this mtime, which differs from the one // gen recorded, so an edit that keeps the size is told apart by its // mtime whatever the filesystem's clock resolution. writtenMtime := time.Unix(1_700_000_000, 0) for _, tc := range []struct { name string write map[string]string // files to write, by path and content remove string // file to delete, if any }{ { name: "modified file", write: map[string]string{testDirFile: "modified content2"}, }, { name: "modified file, same size", write: map[string]string{testDirFile: "CONTENT2"}, }, { name: "new file", write: map[string]string{"dir/new.txt": "content3"}, }, { name: "deleted file", remove: testFileTxt, }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() fs := afero.NewOsFs() root, manifestPath := setupFreshenDir(t, fs, tree) // want is the tree as it is after the change. want := maps.Clone(tree) for path, content := range tc.write { writeTestFile(t, fs, filepath.Join(root, path), content) require.NoError(t, fs.Chtimes( filepath.Join(root, path), writtenMtime, writtenMtime)) want[path] = content } if tc.remove != "" { require.NoError(t, fs.Remove(filepath.Join(root, tc.remove))) delete(want, tc.remove) } runFreshen(t, fs, root, manifestPath) assertManifestLists(t, fs, root, manifestPath, want) opts := testOpts([]string{ testApp, cmdCheck, "-q", testFlagNoExtra, testFlagBase, root, manifestPath, }, fs) assert.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) }) } } // TestFreshenLeavesManifestOutOfListing freshens a manifest kept in a // subdirectory of the tree it lists: the manifest is not listed, while an // ordinary file of the same name at the top of the tree is. The manifest // is recognized by file identity, which needs the real filesystem. func TestFreshenLeavesManifestOutOfListing(t *testing.T) { t.Parallel() root := t.TempDir() manifestPath := filepath.Join(root, "sub", "listing.mf") fs := afero.NewOsFs() require.NoError(t, fs.MkdirAll(filepath.Join(root, "sub"), 0o750)) writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") writeTestFile(t, fs, filepath.Join(root, "listing.mf"), "an ordinary file") opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) // A new file gives freshen something to write. writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added") opts = testOpts([]string{ testApp, "freshen", "-q", testFlagBase, root, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.ElementsMatch(t, []string{testFileTxt, "listing.mf", "added.txt"}, manifestPaths(t, fs, manifestPath)) } // TestFreshenLeavesLeftoverTempFileOutOfListing freshens a manifest where // an interrupted run left its temp file beside it: the leftover is not // listed. func TestFreshenLeavesLeftoverTempFileOutOfListing(t *testing.T) { t.Parallel() root := t.TempDir() manifestPath := filepath.Join(root, "index.mf") fs := afero.NewOsFs() writeTestFile(t, fs, filepath.Join(root, testFileTxt), "hello") opts := testOpts([]string{testApp, cmdGenerate, "-q", "-o", manifestPath, root}, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) writeTestFile(t, fs, manifestTempPath(manifestPath), "part of a manifest") // A new file gives freshen something to write. writeTestFile(t, fs, filepath.Join(root, "added.txt"), "added") opts = testOpts([]string{ testApp, "freshen", "-q", testFlagBase, root, manifestPath, }, fs) require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts)) assert.ElementsMatch(t, []string{testFileTxt, "added.txt"}, manifestPaths(t, fs, manifestPath)) } // 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" // The scanned file's mtime is the Unix epoch. If recordEntry ever misreads // an absent manifest mtime as the epoch, the "absent" case below would // compare equal to this and be classified unchanged, so the test fails. mtime := time.Unix(0, 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() hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256) require.NoError(t, err) b := mfer.NewBuilder() entry := &mfer.MFFilePath{ Path: "file1.txt", Size: 8, Mtime: nil, Hashes: []*mfer.MFFileChecksum{ {MultiHash: hash}, }, } err = addExistingToBuilder(b, entry) require.ErrorIs(t, err, errEntryMissingMtime) assert.Contains(t, err.Error(), "file1.txt") } // TestFreshenAddExistingRejectsShortHash pins that an existing manifest // entry whose hash the builder refuses is reported as a problem with the // manifest, with the command that fixes it. func TestFreshenAddExistingRejectsShortHash(t *testing.T) { t.Parallel() sha1Hash, err := multihash.Encode(make([]byte, 20), multihash.SHA1) require.NoError(t, err) b := mfer.NewBuilder() entry := &mfer.MFFilePath{ Path: "old.txt", Size: 8, Mtime: &mfer.Timestamp{Seconds: 1_700_000_000}, Hashes: []*mfer.MFFileChecksum{ {MultiHash: sha1Hash}, }, } err = addExistingToBuilder(b, entry) require.Error(t, err) assert.Contains(t, err.Error(), "manifest entry old.txt") assert.Contains(t, err.Error(), "mfer generate") assert.Zero(t, b.FileCount()) } // 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) }