check / check (push) Successful in 1m0s
Every gpg invocation went through runGPG, which built its command with exec.CommandContext(context.Background(), ...). That is the right call with the wrong context: context.Background() never expires, so no deadline was ever enforced on any of the five gpg call sites. runGPG now takes a context and derives a gpgTimeout deadline from it, honouring an earlier caller deadline when there is one. The gpg-touching library entry points take a ctx as their first argument so cancellation propagates from above: Builder.Build, NewManifestFromReader, NewManifestFromFile, NewChecker, Checker.ExtractEmbeddedSigningKeyFP. Scanner.ToManifest already had a ctx and now passes it down, which withdraws the //nolint:contextcheck claiming signing was "not cancellable by design" -- it is, and now it is. A deadline alone is not enough, and the added test proves it. gpg delegates to helpers (gpg-agent, pinentry) that inherit the captured stdout and stderr pipes. Go's default cancellation kills only the direct child, so the helper keeps the pipes open and Cmd.Wait blocks on the output-copying goroutines forever -- a dead process and a call that still never returns. Two additions fix that: the child runs in its own process group and cancellation kills the group, and Cmd.WaitDelay caps how long Wait will hold on for the pipes if something escapes the group anyway. Measured with the stand-in gpg from the new test: neither mechanism, hangs until `go test` gives up; WaitDelay only, returns in 2.2s; both, returns in 0.20s. Timeout errors now name the operation and how long gpg ran instead of surfacing a bare "signal: killed" or "context deadline exceeded", and a cancellation from above is reported as a cancellation rather than a timeout, so an abort is distinguishable from a stall. The test helper's own keygen invocations had the same unbounded context.Background() and the same pipe-inheriting agent problem, which makes them the actual mechanism behind the intermittent suite timeout noted in the issue: keygen starts gpg-agent, and a stalled agent hung the suite rather than failing it. They now run under a deadline with the same hardening, so a broken gpg environment skips instead of hanging. Verified with a cold `docker buildx build --no-cache`: prettier, gofmt, `make lint` (0 issues) and `make test` all executed and passed. The golang:1.23 image ships gpg, so the real signing, export, fingerprint, import and verify tests run against real gpg there, not skipped. The process-group kill is unix-only and lives in a build-tagged file; on other platforms the deadline is still enforced via cancellation plus WaitDelay, only the group kill of helpers is unavailable.
200 lines
5.3 KiB
Go
200 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(
|
|
context.Background(), 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(
|
|
context.Background(), 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)
|
|
}
|