check / check (push) Waiting to run
The two status-send tests now call the send directly on a channel nobody receives from, so a blocking send hangs the test into its timeout instead of racing a 100 ms timer that a loaded host can miss. The gpg test for a child holding gpg's output had the same problem: its 100 ms deadline could fire before the fake gpg wrote the PID of sleep, and the cleanup then failed. The fake gpg now writes that PID to a named pipe, and the test cancels only after reading it. It then waits up to 10 s for the call, so a call that waits for sleep fails the test and the cleanup still kills sleep. Model: opus-5-5
489 lines
14 KiB
Go
489 lines
14 KiB
Go
//nolint:testpackage // white-box tests exercise unexported internals
|
|
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
|
// Returns the key ID and the GPG home directory; callers must point
|
|
// GNUPGHOME at the returned directory (via t.Setenv) before using the
|
|
// gpg helpers under test.
|
|
func testGPGEnv(t *testing.T) (GPGKeyID, string) {
|
|
t.Helper()
|
|
|
|
// Check if gpg is installed
|
|
_, err := exec.LookPath("gpg")
|
|
if err != nil {
|
|
t.Skip("gpg not installed, skipping signing test")
|
|
}
|
|
|
|
// Create temporary GPG home directory (0700 by default)
|
|
gpgHome := t.TempDir()
|
|
|
|
// Generate a test key with no passphrase
|
|
keyParams := `%no-protection
|
|
Key-Type: RSA
|
|
Key-Length: 2048
|
|
Name-Real: MFER Test Key
|
|
Name-Email: test@mfer.test
|
|
Expire-Date: 0
|
|
%commit
|
|
`
|
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
|
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), gpgTimeout)
|
|
defer cancel()
|
|
|
|
//nolint:gosec // paramsFile is a test-controlled path inside t.TempDir()
|
|
cmd := exec.CommandContext(ctx, "gpg",
|
|
"--batch", "--gen-key", paramsFile)
|
|
|
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
|
}
|
|
|
|
// Get the key fingerprint
|
|
cmd = exec.CommandContext(ctx, "gpg",
|
|
"--list-keys", "--with-colons", "test@mfer.test")
|
|
|
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
|
|
|
output, err = cmd.Output()
|
|
if err != nil {
|
|
t.Fatalf("failed to list test key: %v", err)
|
|
}
|
|
|
|
// Parse fingerprint from output
|
|
var keyID string
|
|
|
|
for _, line := range strings.Split(string(output), "\n") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= gpgFingerprintMinFields &&
|
|
fields[0] == gpgFingerprintField {
|
|
keyID = fields[9]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if keyID == "" {
|
|
t.Fatal("failed to find test key fingerprint")
|
|
}
|
|
|
|
return GPGKeyID(keyID), gpgHome
|
|
}
|
|
|
|
func TestGPGSign(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, sig)
|
|
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
|
assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----")
|
|
}
|
|
|
|
func TestGPGExportPublicKey(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, pubKey)
|
|
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
assert.Contains(t, string(pubKey), "-----END PGP PUBLIC KEY BLOCK-----")
|
|
}
|
|
|
|
func TestGPGGetKeyFingerprint(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
fingerprint, err := gpgGetKeyFingerprint(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, fingerprint)
|
|
// The fingerprint should be 40 hex chars
|
|
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
|
|
}
|
|
|
|
// TestGPGArgsSeparatesPositionals pins that caller-supplied values are
|
|
// placed after an end-of-options marker. Key IDs arrive from --sign-key
|
|
// and MFER_SIGN_KEY as bare positional arguments, so without the marker
|
|
// a value beginning with "-" would be parsed by gpg as one of its own
|
|
// options.
|
|
func TestGPGArgsSeparatesPositionals(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t,
|
|
[]string{"--opt-a", "--opt-b", "--", "--version"},
|
|
gpgArgs([]string{"--opt-a", "--opt-b"}, "--version"))
|
|
|
|
assert.Equal(t,
|
|
[]string{"--opt-c", "--", "sig", "data"},
|
|
gpgArgs([]string{"--opt-c"}, "sig", "data"))
|
|
|
|
assert.Equal(t, []string{"--opt-d", "--"},
|
|
gpgArgs([]string{"--opt-d"}))
|
|
}
|
|
|
|
// TestGPGOptionLikeKeyIDIsNotAnOption drives real gpg with a key ID that
|
|
// looks like an option and asserts it is treated as a (nonexistent) key
|
|
// rather than executed as gpg's own --version.
|
|
func TestGPGOptionLikeKeyIDIsNotAnOption(t *testing.T) {
|
|
_, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), GPGKeyID("--version"))
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, errGPGKeyNotFound)
|
|
assert.NotContains(t, string(pubKey), "gpg (GnuPG)")
|
|
|
|
fpr, err := gpgGetKeyFingerprint(context.Background(), GPGKeyID("--version"))
|
|
require.Error(t, err)
|
|
assert.NotContains(t, string(fpr), "gpg (GnuPG)")
|
|
}
|
|
|
|
func TestGPGSignInvalidKey(t *testing.T) {
|
|
// Set up test environment (we need GNUPGHOME set)
|
|
_, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data")
|
|
_, err := gpgSign(context.Background(), data,
|
|
GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestBuilderWithSigning(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a builder with signing options
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are populated
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be populated")
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigner(), "signer should be populated")
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"signing public key should be populated")
|
|
|
|
// Verify signature is a valid PGP signature
|
|
assert.Contains(t, string(manifest.pbOuter.GetSignature()),
|
|
"-----BEGIN PGP SIGNATURE-----")
|
|
|
|
// Verify public key is a valid PGP public key block
|
|
assert.Contains(t, string(manifest.pbOuter.GetSigningPubKey()),
|
|
"-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
|
}
|
|
|
|
func TestScannerWithSigning(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create in-memory filesystem with test files
|
|
fs := afero.NewMemMapFs()
|
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
|
require.NoError(t,
|
|
afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
|
|
|
// Create scanner with signing options
|
|
opts := &ScannerOptions{
|
|
Fs: fs,
|
|
SigningOptions: &SigningOptions{
|
|
KeyID: keyID,
|
|
},
|
|
}
|
|
s := NewScannerWithOptions(opts)
|
|
|
|
// Enumerate files
|
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
|
assert.Equal(t, FileCount(2), s.FileCount())
|
|
|
|
// Generate signed manifest
|
|
var buf bytes.Buffer
|
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
|
|
|
// Parse and verify
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigner())
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSigningPubKey())
|
|
}
|
|
|
|
func TestGPGVerify(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign and verify")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the signature
|
|
err = gpgVerify(context.Background(), data, sig, pubKey)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data to sign")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
pubKey, err := gpgExportPublicKey(context.Background(), keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Try to verify with different data - should fail
|
|
wrongData := []byte("different data")
|
|
err = gpgVerify(context.Background(), wrongData, sig, pubKey)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestGPGVerifyBadPublicKey(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
data := []byte("test data")
|
|
sig, err := gpgSign(context.Background(), data, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Try to verify with invalid public key - should fail
|
|
badPubKey := []byte("not a valid public key")
|
|
err = gpgVerify(context.Background(), data, sig, badPubKey)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestManifestSignatureVerification(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a builder with signing options
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
// Add a test file
|
|
content := []byte("test file content for verification")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest - signature should be verified during load
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest)
|
|
|
|
// Signature should be present and valid
|
|
assert.NotEmpty(t, manifest.pbOuter.GetSignature())
|
|
}
|
|
|
|
func TestManifestTamperedSignatureFails(t *testing.T) {
|
|
keyID, gpgHome := testGPGEnv(t)
|
|
t.Setenv("GNUPGHOME", gpgHome)
|
|
|
|
// Create a signed manifest
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{
|
|
KeyID: keyID,
|
|
})
|
|
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Tamper with the signature by replacing some bytes
|
|
data := buf.Bytes()
|
|
// Find and modify a byte in the signature portion
|
|
for i := range data {
|
|
if i > 100 && data[i] == 'A' {
|
|
data[i] = 'B'
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Try to load the tampered manifest - should fail
|
|
_, err = NewManifestFromReader(bytes.NewReader(data))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestBuilderWithoutSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Create a builder without signing options
|
|
b := NewBuilder()
|
|
|
|
// Add a test file
|
|
content := []byte("test file content")
|
|
reader := bytes.NewReader(content)
|
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Build the manifest
|
|
var buf bytes.Buffer
|
|
|
|
err = b.Build(context.Background(), &buf)
|
|
require.NoError(t, err)
|
|
|
|
// Parse the manifest and verify signature fields are empty
|
|
manifest, err := NewManifestFromReader(&buf)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, manifest.pbOuter)
|
|
|
|
assert.Empty(t, manifest.pbOuter.GetSignature(),
|
|
"signature should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigner(),
|
|
"signer should be empty when not signing")
|
|
assert.Empty(t, manifest.pbOuter.GetSigningPubKey(),
|
|
"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
|
|
// PATH and checks that a run past its deadline is killed and reported as
|
|
// a timeout of the named operation, instead of hanging.
|
|
func TestGPGTimeoutKillsGPG(t *testing.T) {
|
|
t.Setenv("PATH", fakeGPGPath(t, "#!/bin/sh\nexec sleep 10\n"))
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
assert.Contains(t, err.Error(), "gpg sign failed: gpg timed out")
|
|
}
|
|
|
|
// TestGPGCancelWhenChildHoldsOutput 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 once ctx ends instead of waiting
|
|
// for sleep to exit. The fake gpg writes the process ID of sleep to a named
|
|
// pipe; the test ends ctx only after reading it, so sleep is running by
|
|
// then, and kills sleep before returning.
|
|
func TestGPGCancelWhenChildHoldsOutput(t *testing.T) {
|
|
pidPipe := filepath.Join(t.TempDir(), "sleep.pid")
|
|
require.NoError(t, syscall.Mkfifo(pidPipe, 0o600))
|
|
// sleep outlasts the 10 s wait below, so a call that waits for it fails.
|
|
t.Setenv("PATH", fakeGPGPath(t,
|
|
"#!/bin/sh\nsleep 60 &\necho $! >'"+pidPipe+"'\nwait\n"))
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
signErr := make(chan error, 1)
|
|
|
|
go func() {
|
|
_, err := gpgSign(ctx, []byte("data"), GPGKeyID("any"))
|
|
signErr <- err
|
|
}()
|
|
|
|
pid, err := os.ReadFile(pidPipe) //nolint:gosec // G304: path inside t.TempDir()
|
|
require.NoError(t, err)
|
|
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(pid)))
|
|
require.NoError(t, err)
|
|
|
|
sleep, err := os.FindProcess(n)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { require.NoError(t, sleep.Kill()) })
|
|
|
|
cancel()
|
|
|
|
// The call should return about gpgWaitDelay (one second) after the
|
|
// cancel. 10 s is far above that and well under the 30 s test timeout,
|
|
// which would abort the whole package before the cleanup kills sleep.
|
|
select {
|
|
case err := <-signErr:
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the call waited for the child holding gpg's output to exit")
|
|
}
|
|
}
|
|
|
|
// TestBuildPassesContextToSigning checks that a caller can cancel the gpg
|
|
// runs that sign a manifest through the context given to Build.
|
|
func TestBuildPassesContextToSigning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := NewBuilder()
|
|
b.SetSigningOptions(&SigningOptions{KeyID: "any"})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
require.ErrorIs(t, b.Build(ctx, io.Discard), context.Canceled)
|
|
}
|