Files
mfer/internal/cli/freshen_test.go
T
clawbot 5e0b851003
check / check (push) Waiting to run
List a directory named through a symlink in gen and freshen (closes #185)
The walk in gen and freshen does not follow a symlink at its top. A
directory given as a symlink, or a working directory whose path names
one, was listed as empty and gen exited 0, while freshen removed every
entry from the manifest. Each walk now resolves its starting directory
with filepath.EvalSymlinks first, as check's search for extra files
does, and starts at the path as named if that fails. Symlinks inside the
tree are still skipped unless --follow-symlinks is given.

Model: opus-5-5
2026-10-08 02:45:47 +00:00

513 lines
16 KiB
Go

//nolint:testpackage // white-box tests exercise unexported internals
package cli
import (
"crypto/sha256"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"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())
}
}
// TestFreshenRecordsModeOnlyWhenAsked changes only the modes of a tree
// after gen made its manifest, which recorded every mode as 0000. freshen
// --include-permissions must record each file's permission bits, and a
// later freshen without it must record 0000 again, although no file's
// content or mtime changed.
func TestFreshenRecordsModeOnlyWhenAsked(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
root, manifestPath := setupFreshenDir(t, fs,
map[string]string{testFileTxt: "a file", testDirFile: "a file in dir"})
require.NoError(t, fs.Chmod(filepath.Join(root, testFileTxt), 0o640))
require.NoError(t, fs.Chmod(filepath.Join(root, testDirFile), 0o755))
recordedModes := func() map[string]uint32 {
modes := map[string]uint32{}
for _, f := range manifestFiles(t, fs, manifestPath) {
modes[f.GetPath()] = f.GetMode()
}
return modes
}
opts := testOpts([]string{
testApp, cmdFreshen, "-q", "--" + flagIncludePermissions,
testFlagBase, root, manifestPath,
}, fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.Equal(t, map[string]uint32{testFileTxt: 0o640, testDirFile: 0o755},
recordedModes())
runFreshen(t, fs, root, manifestPath)
assert.Equal(t, map[string]uint32{testFileTxt: 0, testDirFile: 0},
recordedModes())
}
// 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))
}
// TestFreshenResolvesEntriesAgainstManifestDirectory adds sub/c.txt, then
// runs freshen from the directory above sub, on the manifest in sub.
// Without --base, the manifest then lists the files in sub, whether freshen
// is given the manifest or sub. --base names the directory to list instead,
// the current one included.
//
//nolint:paralleltest // changes the process-global working directory
func TestFreshenResolvesEntriesAgainstManifestDirectory(t *testing.T) {
for _, tc := range []struct {
args []string
want []string // the paths the manifest lists afterwards
}{
{[]string{testSubdir}, []string{"b.txt", "c.txt"}},
{[]string{testSubdirManifest}, []string{"b.txt", "c.txt"}},
{
[]string{testFlagBase, ".", testSubdirManifest},
[]string{testFileTxt, "sub/b.txt", "sub/c.txt"},
},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
fs := afero.NewOsFs()
root := setupManifestInSubdir(t)
writeTestFile(t, fs, filepath.Join(root, testSubdir, "c.txt"), "added")
opts := testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, tc.args,
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assert.ElementsMatch(t, tc.want, manifestPaths(t, fs,
filepath.Join(root, testSubdirManifest)))
})
}
}
// TestFreshenDirectoryNamedThroughSymlink adds a file to a tree after gen
// made its manifest, then freshens it with the tree named through a symlink,
// given as the argument or as the working directory: the manifest lists the
// files in the tree, and leaves out a symlink inside it, as freshen does
// without --follow-symlinks.
//
//nolint:paralleltest // changes the process-global working directory
func TestFreshenDirectoryNamedThroughSymlink(t *testing.T) {
// Paths are relative to a temp dir holding link, a symlink to the tree.
for name, tc := range map[string]struct {
workDir string
args []string
}{
"argument": {".", []string{"link"}},
"working directory": {"link", nil},
} {
t.Run(name, func(t *testing.T) {
fs := afero.NewOsFs()
tree, manifestPath := setupFreshenDir(t, fs,
map[string]string{testFileTxt: "content1"})
writeTestFile(t, fs, filepath.Join(tree, "added.txt"), "added")
require.NoError(t,
os.Symlink(testFileTxt, filepath.Join(tree, "alias.txt")))
root := t.TempDir()
require.NoError(t, os.Symlink(tree, filepath.Join(root, "link")))
// t.Chdir sets PWD to the path it is given, as a shell does, and
// os.Getwd returns PWD when it names the working directory.
t.Chdir(filepath.Join(root, tc.workDir))
opts := testOpts(slices.Concat(
[]string{testApp, cmdFreshen, "-q"}, tc.args,
), fs)
require.Equal(t, 0, runCLI(opts), "stderr: %s", testStderr(t, opts))
assertManifestLists(t, fs, tree, manifestPath,
map[string]string{testFileTxt: "content1", "added.txt": "added"})
})
}
}
// 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)
}