check / check (push) Failing after 3s
The freshen tests built a fixture and never ran freshen. They now run gen and freshen through the command entry point on a temp dir: an unchanged tree leaves the manifest's entries as they were, and a modified file (also one edited without changing its size), a new or a deleted file gives a manifest that lists exactly the tree, with sizes, hashes and mtimes from disk, on which check passes. New fetch tests run the command against an httptest server: a nested tree lands complete, a hash mismatch exits non-zero and leaves no file, and a partly filled destination gets every listed file downloaded again and replaced, while a file the manifest does not list is left alone. Model: opus-5-5
399 lines
12 KiB
Go
399 lines
12 KiB
Go
//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)
|
|
}
|