All checks were successful
check / check (push) Successful in 35s
- Add canonical .golangci.yml (v2 schema, default: all, project thresholds for lll/funlen/cyclop/dupl) - Bump golangci-lint pins from v2.0.2 to v2.12.2 in Makefile (go install, new /v2 module path) and Dockerfile (tagged+digest Debian image pin) - Fix all lint findings surfaced by the new linter set across cmd/mfer, internal/bork, internal/cli, internal/log, and mfer: static sentinel errors (err113), context-aware HTTP and exec (noctx), guarded integer conversions and stricter permissions (gosec), named constants (mnd, goconst), function decomposition (funlen, cyclop, gocognit, nestif), declaration ordering (funcorder), t.Parallel/t.TempDir/t.Setenv adoption in tests (paralleltest, usetesting), protobuf getters (protogetter), plus formatting and style cleanups (wsl_v5, nlreturn, lll, revive, testifylint, and others) - Serialize CLI runs in tests behind a mutex so parallel tests do not cross-wire the process-global logger's captured output The decompositions are behavior-preserving. In particular: - REPO_POLICIES.md is untouched and stays byte-identical to the authoritative copy in the prompts repo - the mfer.manifest type stays unexported; whether to export it is an open owner design question (README question 13) - directories created by fetch keep mode 0755, because fetched trees are content meant to be readable by other uids - an absent MFFilePath.Mtime is handled explicitly and identically in freshen, list, and export rather than being read as the Unix epoch, which would classify every entry as changed and rewrite the manifest on every freshen - every user-visible error message renders byte-identically to what it did before, with the err113 sentinels wrapped mid-sentence where needed; the rendered strings are now pinned by tests Also fixes an argument-injection defect the lint pass surfaced: key IDs reach gpg as bare positional arguments, so a key ID beginning with "-" was parsed by gpg as an option. All positional arguments now follow an explicit "--" end-of-options marker. The symlink-escape gap in fetch's path handling, which sanitizePath does not and cannot address, is filed separately as #86.
198 lines
5.3 KiB
Go
198 lines
5.3 KiB
Go
//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)
|
|
}
|