Files
mfer/mfer/gpg_timeout_unix_test.go
T
user 5b238e740b
check / check (push) Successful in 1m0s
Enforce real timeouts on gpg subprocess calls (closes #62)
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.
2026-09-03 14:50:59 +00:00

110 lines
3.7 KiB
Go

//go:build unix
// The deadline tests need an executable stand-in for gpg first on PATH,
// which is written as a POSIX shell script, and they exercise the
// process-group kill that only exists on unix. There is no equivalent
// coverage on other platforms.
//nolint:testpackage // white-box tests exercise unexported internals
package mfer
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// gpgTestDeadline is the deadline the deadline tests give gpg. It is
// short so the suite stays fast; gpgTimeout itself is far too long to
// wait out in a test, and passing an earlier deadline is exactly the
// caller-propagation path under test.
gpgTestDeadline = 200 * time.Millisecond
// fakeGPGSleepSeconds is how long the stand-in gpg blocks. It must be
// long enough that only cancellation can end it -- if the deadline is
// not enforced, the test hangs until `go test` times out, which is the
// loud failure we want rather than a silently passing race.
fakeGPGSleepSeconds = 30
// fakeGPGPerms makes the stand-in script executable.
fakeGPGPerms os.FileMode = 0o700
)
// fakeSlowGPGOnPath puts a stand-in for gpg first on PATH that blocks
// until it is killed, so a deadline is the only thing that can end the
// invocation.
//
// The stand-in is a shell script that spawns a child, which means the
// child inherits the write end of the captured stdout and stderr pipes.
// That mirrors a real gpg delegating to a helper process, and it is the
// case that hangs Cmd.Wait if only the direct child is killed.
func fakeSlowGPGOnPath(t *testing.T) {
t.Helper()
dir := t.TempDir()
script := "#!/bin/sh\nsleep " + strconv.Itoa(fakeGPGSleepSeconds) + "\n"
// fakeGPGPerms is 0700 rather than 0600 because the stand-in has to
// be executable to be run at all; it lives in a test-controlled
// t.TempDir().
require.NoError(t,
os.WriteFile(filepath.Join(dir, "gpg"), []byte(script), fakeGPGPerms))
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestGPGDeadlineIsEnforced pins that a gpg invocation which outlives its
// deadline returns a timeout error naming the operation, rather than
// hanging or surfacing a bare context error.
//
//nolint:paralleltest // fakeSlowGPGOnPath mutates the process-global PATH
func TestGPGDeadlineIsEnforced(t *testing.T) {
fakeSlowGPGOnPath(t)
ctx, cancel := context.WithTimeout(context.Background(), gpgTestDeadline)
defer cancel()
started := time.Now()
sig, err := gpgSign(ctx, []byte("data to sign"), GPGKeyID("KEYID"))
elapsed := time.Since(started)
require.Error(t, err)
require.ErrorIs(t, err, errGPGTimeout)
assert.Empty(t, sig)
// The error must say which invocation stalled. "context deadline
// exceeded" alone tells an operator nothing.
assert.Contains(t, err.Error(), gpgOpSign)
// Returning at all is the headline, but it has to return promptly:
// if the stand-in's child kept the output pipes open, Cmd.Wait would
// sit there until gpgWaitDelay elapsed.
assert.Less(t, elapsed, gpgWaitDelay,
"Run should return on cancellation, not wait out gpgWaitDelay")
}
// TestGPGCancellationPropagates pins that a caller's cancellation reaches
// gpg and is reported as a cancellation rather than as a timeout.
//
//nolint:paralleltest // fakeSlowGPGOnPath mutates the process-global PATH
func TestGPGCancellationPropagates(t *testing.T) {
fakeSlowGPGOnPath(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := gpgSign(ctx, []byte("data to sign"), GPGKeyID("KEYID"))
require.Error(t, err)
require.ErrorIs(t, err, context.Canceled)
require.NotErrorIs(t, err, errGPGTimeout)
assert.Contains(t, err.Error(), gpgOpSign)
}