Compare commits

..
1 Commits
Author SHA1 Message Date
sneak ee49371459 Enforce real timeouts on gpg subprocess calls (closes #62)
check / check (push) Successful in 55s
Every gpg run now has a one-minute deadline (gpgTimeout) on top of its
caller's context and is killed when either ends. A timeout is reported
as "gpg timed out" under the failing operation instead of "signal:
killed". Builder.Build and Checker.ExtractEmbeddedSigningKeyFP take a
context, so ToManifest's context now reaches signing and the
contextcheck suppression calling signing non-cancellable is gone.
Manifest loading takes no context, so its signature check is bounded by
the timeout alone. Killing gpg itself is enough: the gpg-agent it starts
runs detached and holds none of its output.

Model: opus-5-5
2026-10-03 15:37:23 +00:00
7 changed files with 153 additions and 397 deletions
+4 -5
View File
@@ -23,9 +23,8 @@ javascript library is planned.
# Build Status # Build Status
CI runs `script/cibuild`, which builds the Docker image with `--no-cache`, so CI runs via `script/cibuild` (`docker build .`), which executes `make check`
the formatting, lint and test steps in the `Dockerfile` run on every build. The (formatting, linting, tests). The `main` branch must always be green.
`main` branch must always be green.
# Entrypoints # Entrypoints
@@ -57,8 +56,8 @@ provide:
Docker lint stage, whose image has no node Docker lint stage, whose image has no node
- `script/check` — run `script/test`, `script/lint`, and `script/fmt-check` - `script/check` — run `script/test`, `script/lint`, and `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: builds the image with the same command as - `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile runs the
`script/docker`, uncached, so the checks in the Dockerfile run every time checks)
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then - `script/precommit` — pre-commit checks: `go mod tidy` verification, then
`script/check` `script/check`
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that runs
-6
View File
@@ -28,12 +28,6 @@ only thing left of the `chore/align-repo-policies` branch is the list below.
context ends, and a timeout reads as "gpg timed out" under the failing context ends, and a timeout reads as "gpg timed out" under the failing
operation; `Builder.Build` and `Checker.ExtractEmbeddedSigningKeyFP` take a operation; `Builder.Build` and `Checker.ExtractEmbeddedSigningKeyFP` take a
context, which reaches gpg (#62) context, which reaches gpg (#62)
- 2026-10-03: pinned the CLI error messages by driving the functions that emit
them in `internal/cli/errmsg_test.go`, and made the freshen mtime-presence
test distinguish an absent mtime from the epoch (#87)
- 2026-10-03: `script/cibuild` builds the image with the same command as
`script/docker`, `--no-cache` included, so the checks in the Dockerfile run on
every build, also on an unchanged tree (#89)
- 2026-10-03: `fetch` removes whatever sits at a file's temp name and then - 2026-10-03: `fetch` removes whatever sits at a file's temp name and then
creates the temp file only if that name is free, so a hard link left there creates the temp file only if that name is free, so a hard link left there
cannot make it write into a file outside the destination directory (#115) cannot make it write into a file outside the destination directory (#115)
+135 -322
View File
@@ -2,354 +2,167 @@
package cli package cli
import ( import (
"bytes" "fmt"
"context"
"flag"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"testing" "testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
urfcli "github.com/urfave/cli/v2"
"sneak.berlin/go/mfer/mfer"
) )
// These tests pin the exact rendered text of the CLI's user-visible error // errMsgCase is one pinned user-visible error message.
// messages. The messages are grepped for in CI pipelines and quoted in bug type errMsgCase struct {
// reports, so a reword is a deliberate change, never a refactoring side name string
// effect. err error
// want string
// Every case drives the real function that emits the message and asserts on }
// what it returns. No production format string is restated here: a test that
// only re-rendered a copied format string would keep passing after the real
// message changed, which is exactly the regression these tests exist to
// catch.
// Full 40-hex fingerprints used where a message embeds one.
const ( const (
msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" msgFpA = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" msgFpB = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
) )
// runLocked runs fn while holding runMu, so operations that write to the func checkErrMsgCases(t *testing.T, cases []errMsgCase) {
// process-global logger do not race the other CLI runs.
func runLocked(fn func() error) error {
runMu.Lock()
defer runMu.Unlock()
return fn()
}
// unsignedChecker builds a Checker over a freshly scanned, unsigned manifest.
func unsignedChecker(t *testing.T) *mfer.Checker {
t.Helper() t.Helper()
fs := afero.NewMemMapFs() for _, tc := range cases {
require.NoError(t, fs.MkdirAll("/d", 0o755)) t.Run(tc.name, func(t *testing.T) {
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644)) t.Parallel()
assert.Equal(t, tc.want, tc.err.Error())
s := mfer.NewScannerWithOptions(&mfer.ScannerOptions{Fs: fs}) })
require.NoError(t, s.EnumeratePath("/d", nil)) }
var buf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
require.NoError(t, afero.WriteFile(fs, "/d/index.mf", buf.Bytes(), 0o644))
chk, err := mfer.NewChecker("/d/index.mf", "/d", fs)
require.NoError(t, err)
require.False(t, chk.IsSigned())
return chk
} }
func TestNoManifestFoundMessage(t *testing.T) { // TestErrorMessagesVerbatim pins the exact rendered text of the CLI's
t.Parallel() // user-visible error messages.
_, err := findManifest(afero.NewMemMapFs(), "/tmp/x")
require.ErrorIs(t, err, errNoManifestFound)
assert.EqualError(t, err,
"no manifest found in /tmp/x (looked for index.mf and .index.mf)")
}
func TestVerifyRequiredSignerMessages(t *testing.T) {
t.Parallel()
t.Run("invalid fingerprint length", func(t *testing.T) {
t.Parallel()
err := verifyRequiredSigner(context.Background(),
unsignedChecker(t), "12345678")
require.ErrorIs(t, err, errInvalidFingerprint)
assert.EqualError(t, err,
"invalid fingerprint: must be exactly 40 hex characters, got 8")
})
t.Run("manifest not signed", func(t *testing.T) {
t.Parallel()
err := verifyRequiredSigner(context.Background(),
unsignedChecker(t), msgFpA)
require.ErrorIs(t, err, errManifestNotSigned)
assert.EqualError(t, err,
"manifest is not signed, but signature from "+msgFpA+" is required")
})
}
// TestSignerMismatchMessage drives verifyRequiredSigner against a real signed
// manifest. The embedded fingerprint is whatever the generated key produced,
// so it is read back from the checker and substituted into the expected
// string; the required signer is a fixed value that cannot match it. Requires
// gpg and is skipped where it is absent, as the other signing tests are.
// //
//nolint:paralleltest // signedChecker calls t.Setenv, which bars t.Parallel // These strings are an interface: they are grepped for in CI pipelines
func TestSignerMismatchMessage(t *testing.T) { // and quoted in bug reports. The messages are assembled by wrapping
chk := signedChecker(t) // static sentinels, and it is easy to change what a user sees while
// only meaning to make an error matchable with errors.Is - which is
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP(context.Background()) // precisely what happened once already. Any change to a string below is
require.NoError(t, err) // therefore a deliberate, separately stated change, never a side effect
// of a refactor.
err = verifyRequiredSigner(context.Background(), chk, msgFpB) func TestErrorMessagesVerbatim(t *testing.T) {
require.ErrorIs(t, err, errSignerMismatch)
assert.EqualError(t, err,
"embedded signing key fingerprint "+embeddedFP+
" does not match required "+msgFpB)
}
// signedChecker builds a Checker over a manifest signed by a throwaway GPG
// key generated in a temporary GNUPGHOME.
func signedChecker(t *testing.T) *mfer.Checker {
t.Helper()
_, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not installed, skipping signing test")
}
gpgHome := t.TempDir()
params := "%no-protection\n" +
"Key-Type: RSA\nKey-Length: 2048\n" +
"Name-Real: MFER Test Key\nName-Email: test@mfer.test\n" +
"Expire-Date: 0\n%commit\n"
paramsFile := filepath.Join(gpgHome, "key-params")
require.NoError(t, os.WriteFile(paramsFile, []byte(params), 0o600))
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
cmd := exec.CommandContext(context.Background(), "gpg",
"--batch", "--gen-key", paramsFile)
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
out, err := cmd.CombinedOutput()
if err != nil {
t.Skipf("failed to generate test GPG key: %v: %s", err, out)
}
t.Setenv("GNUPGHOME", gpgHome)
b := mfer.NewBuilder()
b.SetSigningOptions(&mfer.SigningOptions{KeyID: mfer.GPGKeyID("test@mfer.test")})
content := []byte("signed file")
_, err = b.AddFile("f.txt", mfer.FileSize(len(content)), mfer.ModTime{},
bytes.NewReader(content), nil)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, b.Build(context.Background(), &buf))
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/index.mf", buf.Bytes(), 0o644))
chk, err := mfer.NewChecker("/index.mf", "/", fs)
require.NoError(t, err)
require.True(t, chk.IsSigned())
return chk
}
func TestPathDoesNotExistMessage(t *testing.T) {
t.Parallel() t.Parallel()
set := flag.NewFlagSet("gen", flag.ContinueOnError) checkErrMsgCases(t, []errMsgCase{
require.NoError(t, set.Parse([]string{"nope"})) {
name: "check: no manifest found",
mfa := &CLIApp{Fs: afero.NewMemMapFs()} err: fmt.Errorf("%w in %s (looked for index.mf and .index.mf)",
ctx := urfcli.NewContext(nil, set, nil) errNoManifestFound, "/tmp/x"),
want: "no manifest found in /tmp/x " +
_, err := mfa.collectInputPaths(ctx.Args()) "(looked for index.mf and .index.mf)",
require.ErrorIs(t, err, errPathNotExist) },
assert.EqualError(t, err, "path does not exist: nope") {
} name: "check: invalid fingerprint length",
err: fmt.Errorf("%w, got %d", errInvalidFingerprint, 8),
func TestOutputFileExistsMessage(t *testing.T) { want: "invalid fingerprint: must be exactly 40 hex characters, got 8",
t.Parallel() },
{
fs := afero.NewMemMapFs() name: "check: manifest not signed",
require.NoError(t, fs.MkdirAll("/d", 0o755)) err: fmt.Errorf("%w, but signature from %s is required",
require.NoError(t, afero.WriteFile(fs, "/d/f.txt", []byte("hi"), 0o644)) errManifestNotSigned, msgFpA),
require.NoError(t, afero.WriteFile(fs, "/out.mf", []byte("old"), 0o644)) want: "manifest is not signed, but signature from " + msgFpA +
" is required",
set := flag.NewFlagSet("gen", flag.ContinueOnError) },
set.String("output", "", "") {
set.Bool("force", false, "") name: "check: signer mismatch",
require.NoError(t, set.Parse([]string{"/d"})) err: fmt.Errorf("embedded signing key fingerprint %s %w %s",
require.NoError(t, set.Set("output", "/out.mf")) msgFpA, errSignerMismatch, msgFpB),
want: "embedded signing key fingerprint " + msgFpA +
mfa := &CLIApp{Fs: fs} " does not match required " + msgFpB,
ctx := urfcli.NewContext(nil, set, nil) },
{
// generateManifestOperation writes to the process-global logger during name: "gen: path does not exist",
// enumeration, so serialize with the other CLI runs. err: fmt.Errorf("%w: %s", errPathNotExist, "nope"),
err := runLocked(func() error { return mfa.generateManifestOperation(ctx) }) want: "path does not exist: nope",
require.ErrorIs(t, err, errOutputExists) },
assert.EqualError(t, err, {
"output file /out.mf already exists (use --force to overwrite)") name: "gen: output file exists",
} err: fmt.Errorf("output file %s %w", "index.mf", errOutputExists),
want: "output file index.mf already exists " +
// TestUnknownCommandMessage drives the root command's action. run only logs "(use --force to overwrite)",
// the error that action returns, so the test lets run build the app with no },
// command given and then runs that same app on an unknown command to get the {
// error itself. name: "mfer: unknown command",
func TestUnknownCommandMessage(t *testing.T) { err: fmt.Errorf("%w %q", errUnknownCommand, "bogus"),
t.Parallel() want: `unknown command "bogus"`,
},
mfa := &CLIApp{
appname: testApp,
Stdout: &bytes.Buffer{},
Stderr: &bytes.Buffer{},
Fs: afero.NewMemMapFs(),
}
// run points the process-global logger at this app's output, so
// serialize with the other CLI runs.
err := runLocked(func() error {
mfa.run([]string{testApp})
return mfa.app.Run([]string{testApp, "bogus"})
})
require.ErrorIs(t, err, errUnknownCommand)
assert.EqualError(t, err, `unknown command "bogus"`)
}
func TestManifestLoaderHTTPStatusMessage(t *testing.T) {
t.Parallel()
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
_, err := mfa.openManifestReader(server.URL + "/foo.mf")
require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err,
"failed to fetch "+server.URL+"/foo.mf: HTTP 404")
}
func TestFetchManifestHTTPStatusMessage(t *testing.T) {
t.Parallel()
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
require.NoError(t, set.Parse([]string{server.URL}))
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
ctx := urfcli.NewContext(nil, set, nil)
// fetchManifestOperation logs to the process-global logger.
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err, "failed to fetch manifest: HTTP 404")
}
func TestFetchFileHTTPStatusMessage(t *testing.T) {
t.Parallel()
server := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
err := downloadFile(context.Background(), server.URL+"/x", "x",
&mfer.MFFilePath{}, nil)
require.ErrorIs(t, err, errHTTPStatus)
assert.EqualError(t, err, "HTTP 500")
}
func TestURLRequiredMessage(t *testing.T) {
t.Parallel()
set := flag.NewFlagSet("fetch", flag.ContinueOnError)
require.NoError(t, set.Parse([]string{}))
mfa := &CLIApp{Fs: afero.NewMemMapFs()}
ctx := urfcli.NewContext(nil, set, nil)
// fetchManifestOperation logs to the process-global logger.
err := runLocked(func() error { return mfa.fetchManifestOperation(ctx) })
require.ErrorIs(t, err, errURLRequired)
assert.EqualError(t, err, "URL argument required")
}
func TestSanitizePathMessages(t *testing.T) {
t.Parallel()
t.Run("empty", func(t *testing.T) {
t.Parallel()
_, err := sanitizePath("")
require.ErrorIs(t, err, errEmptyPath)
assert.EqualError(t, err, "empty path")
})
t.Run("absolute", func(t *testing.T) {
t.Parallel()
_, err := sanitizePath("/etc/passwd")
require.ErrorIs(t, err, errAbsolutePath)
assert.EqualError(t, err, "absolute path not allowed: /etc/passwd")
})
t.Run("traversal", func(t *testing.T) {
t.Parallel()
_, err := sanitizePath("../x")
require.ErrorIs(t, err, errPathTraversal)
assert.EqualError(t, err, "path traversal not allowed: ../x")
}) })
} }
func TestSizeMismatchMessage(t *testing.T) { // TestFetchErrorMessagesVerbatim pins the fetch and manifest-loader
// messages; see TestErrorMessagesVerbatim for why.
func TestFetchErrorMessagesVerbatim(t *testing.T) {
t.Parallel() t.Parallel()
// finishDownload returns the size-mismatch error before it touches the checkErrMsgCases(t, []errMsgCase{
// paths, digest, or entry, so those can be zero here. {
err := finishDownload("", "", 9, 10, nil, nil, nil, nil) name: "manifest_loader: http status",
require.ErrorIs(t, err, errSizeMismatch) err: fmt.Errorf("failed to fetch %s: %w %d",
assert.EqualError(t, err, "size mismatch: expected 10 bytes, got 9") "https://example.com/index.mf", errHTTPStatus, 404),
want: "failed to fetch https://example.com/index.mf: HTTP 404",
},
{
name: "fetch: manifest http status",
err: fmt.Errorf("failed to fetch manifest: %w %d",
errHTTPStatus, 404),
want: "failed to fetch manifest: HTTP 404",
},
{
name: "fetch: file http status",
err: fmt.Errorf("%w %d", errHTTPStatus, 500),
want: "HTTP 500",
},
{
name: "fetch: empty path",
err: errEmptyPath,
want: "empty path",
},
{
name: "fetch: absolute path",
err: fmt.Errorf("%w: %s", errAbsolutePath, "/etc/passwd"),
want: "absolute path not allowed: /etc/passwd",
},
{
name: "fetch: path traversal",
err: fmt.Errorf("%w: %s", errPathTraversal, "../x"),
want: "path traversal not allowed: ../x",
},
{
name: "fetch: size mismatch",
err: fmt.Errorf("%w: expected %d bytes, got %d",
errSizeMismatch, 10, 9),
want: "size mismatch: expected 10 bytes, got 9",
},
{
name: "fetch: url required",
err: errURLRequired,
want: "URL argument required",
},
{
name: "fetch: hash mismatch",
err: errHashMismatch,
want: "hash mismatch",
},
})
} }
func TestHashMismatchMessage(t *testing.T) { // TestSentinelsAreMatchable checks that the wrapped forms of the
// messages above remain matchable with errors.Is, which is the reason
// the sentinels exist at all.
func TestSentinelsAreMatchable(t *testing.T) {
t.Parallel() t.Parallel()
// A 32-byte digest that matches none of the (empty) manifest hashes. wrapped := fmt.Errorf("embedded signing key fingerprint %s %w %s",
err := verifyDownloadedHash(make([]byte, 32), &mfer.MFFilePath{}) "a", errSignerMismatch, "b")
require.ErrorIs(t, err, errHashMismatch) require.ErrorIs(t, wrapped, errSignerMismatch)
require.NotErrorIs(t, err, errSizeMismatch)
assert.EqualError(t, err, "hash mismatch") wrapped = fmt.Errorf("output file %s %w", "index.mf", errOutputExists)
require.ErrorIs(t, wrapped, errOutputExists)
wrapped = fmt.Errorf("failed to fetch manifest: %w %d", errHTTPStatus, 404)
require.ErrorIs(t, wrapped, errHTTPStatus)
assert.NotErrorIs(t, errHashMismatch, errSizeMismatch)
} }
+1 -4
View File
@@ -110,10 +110,7 @@ func TestFreshenRecordEntryMtimePresence(t *testing.T) {
const relPath = "file1.txt" const relPath = "file1.txt"
// The scanned file's mtime is the Unix epoch. If recordEntry ever misreads mtime := time.Unix(1_700_000_000, 0)
// 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} info := stubFileInfo{size: 8, mtime: mtime}
for _, tc := range []struct { for _, tc := range []struct {
+3 -13
View File
@@ -19,12 +19,6 @@ const (
// time to type a passphrase or touch a smartcard. // time to type a passphrase or touch a smartcard.
gpgTimeout = time.Minute gpgTimeout = time.Minute
// gpgWaitDelay is how long a gpg run keeps waiting for gpg's stdout
// and stderr to close once gpg has been killed or has exited. Reading
// what gpg itself wrote takes far less; only a process gpg left behind
// holds them open longer.
gpgWaitDelay = time.Second
// privateDirPerms is the permission mode for temporary GPG home // privateDirPerms is the permission mode for temporary GPG home
// directories. // directories.
privateDirPerms os.FileMode = 0o700 privateDirPerms os.FileMode = 0o700
@@ -83,12 +77,9 @@ func gpgArgs(opts []string, positional ...string) []string {
func runGPG( func runGPG(
ctx context.Context, stdin io.Reader, args ...string, ctx context.Context, stdin io.Reader, args ...string,
) (*bytes.Buffer, *bytes.Buffer, error) { ) (*bytes.Buffer, *bytes.Buffer, error) {
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg // exec.CommandContext kills only gpg itself, and that is enough: a
// starts runs detached and holds none of gpg's output, but another // gpg-agent that gpg starts runs detached in its own session and does
// process gpg leaves behind (a wrapper script that runs the real gpg // not hold gpg's output open, so Run returns as soon as gpg dies.
// without exec, for example) can keep gpg's stdout or stderr open, and
// Run would wait for it to exit. WaitDelay stops that wait
// gpgWaitDelay after the kill; that process is left running.
ctx, cancel := context.WithTimeout(ctx, gpgTimeout) ctx, cancel := context.WithTimeout(ctx, gpgTimeout)
defer cancel() defer cancel()
@@ -101,7 +92,6 @@ func runGPG(
// and therefore cannot be reinterpreted by gpg as an option. // and therefore cannot be reinterpreted by gpg as an option.
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
ctx, "gpg", fullArgs...) ctx, "gpg", fullArgs...)
cmd.WaitDelay = gpgWaitDelay
cmd.Stdin = stdin cmd.Stdin = stdin
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
+5 -32
View File
@@ -397,24 +397,15 @@ func TestBuilderWithoutSigning(t *testing.T) {
"signing public key should be empty when not signing") "signing public key should be empty when not signing")
} }
// fakeGPGPath writes script as an executable named gpg into a temporary
// directory and returns a PATH value with that directory first.
func fakeGPGPath(t *testing.T, script string) string {
t.Helper()
binDir := t.TempDir()
//nolint:gosec // G306: the fake gpg has to be executable
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"),
[]byte(script), 0o700))
return binDir + string(os.PathListSeparator) + os.Getenv("PATH")
}
// TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on // TestGPGTimeoutKillsGPG puts a fake gpg that never finishes first on
// PATH and checks that a run past its deadline is killed and reported as // PATH and checks that a run past its deadline is killed and reported as
// a timeout of the named operation, instead of hanging. // a timeout of the named operation, instead of hanging.
func TestGPGTimeoutKillsGPG(t *testing.T) { func TestGPGTimeoutKillsGPG(t *testing.T) {
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nexec sleep 10\n")) binDir := t.TempDir()
fakeGPG := []byte("#!/bin/sh\nexec sleep 10\n")
//nolint:gosec // G306: the fake gpg has to be executable
require.NoError(t, os.WriteFile(filepath.Join(binDir, "gpg"), fakeGPG, 0o700))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() defer cancel()
@@ -424,24 +415,6 @@ func TestGPGTimeoutKillsGPG(t *testing.T) {
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out") assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
} }
// TestGPGTimeoutWhenChildHoldsOutput uses a fake gpg that runs sleep as a
// child instead of exec-ing it, the way a wrapper script around the real
// gpg might. Killing the fake gpg leaves sleep holding its stdout and
// stderr open; the call must still return shortly after the deadline
// instead of waiting for sleep to exit.
func TestGPGTimeoutWhenChildHoldsOutput(t *testing.T) {
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nsleep 3\nexit\n"))
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
start := time.Now()
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Less(t, time.Since(start), 3*time.Second,
"the call waited for the child holding gpg's output to exit")
}
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg // TestBuildPassesContextToSigning checks that a caller can cancel the gpg
// runs that sign a manifest through the context given to Build. // runs that sign a manifest through the context given to Build.
func TestBuildPassesContextToSigning(t *testing.T) { func TestBuildPassesContextToSigning(t *testing.T) {
+5 -15
View File
@@ -1,24 +1,14 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build; the Gitea workflow runs this on push. # script/cibuild: run the CI build. The Dockerfile runs script/check
# It builds the image with the same command as script/docker. --no-cache # (via make check), so a successful build implies all checks pass.
# because the checks the final stage depends on are RUN steps, and a # Generic: needs no adaptation. The Gitea workflow runs this on push.
# cached one is a check that did not run.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does docker build .
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"