From f9523d6623c787fb4233541d8f6c9c3e51bf64f9 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 22:40:11 +0000 Subject: [PATCH] Include gpg's stderr in the error when gpg fails (closes #146) When gpg failed while decrypting, encrypting or looking up a key fingerprint, the error gave only gpg's exit status, so the user could not tell why. cmd.Output keeps gpg's stderr in the *exec.ExitError it returns; the three errors now end with that stderr, trimmed. When gpg wrote nothing to stderr, the error is left as it was. gpg's stderr can run to more than one line, and the error then does too. The regression test gives gpg data that is not an OpenPGP message instead of one encrypted to a missing key: gpg reports both the same way, and this case starts no gpg-agent the test would have to stop. Model: opus-5-5 --- internal/secret/pgpunlocker.go | 25 ++++++++++++++++++++++--- internal/secret/pgpunlocker_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/internal/secret/pgpunlocker.go b/internal/secret/pgpunlocker.go index e80ed33..27b2f24 100644 --- a/internal/secret/pgpunlocker.go +++ b/internal/secret/pgpunlocker.go @@ -388,7 +388,8 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) { return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID) } - return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err) + return "", fmt.Errorf( + "failed to resolve GPG key fingerprint: %w", withGPGStderr(err)) } // Parse the output to extract the fingerprint @@ -418,6 +419,24 @@ func checkGPGAvailable() error { return nil } +// withGPGStderr appends gpg's stderr, which says why gpg failed, to err +// from cmd.Output. cmd.Output keeps that stderr in the *exec.ExitError. +// err is returned unchanged when gpg wrote nothing to stderr. +func withGPGStderr(err error) error { + var exitErr *exec.ExitError + + if !errors.As(err, &exitErr) { + return err + } + + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if stderr == "" { + return err + } + + return fmt.Errorf("%w: %s", err, stderr) +} + // gpgEncryptDefault is the default implementation of GPG encryption func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) { if data == nil { @@ -437,7 +456,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("GPG encryption failed: %w", err) + return nil, fmt.Errorf("GPG encryption failed: %w", withGPGStderr(err)) } return output, nil @@ -450,7 +469,7 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) { output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("GPG decryption failed: %w", err) + return nil, fmt.Errorf("GPG decryption failed: %w", withGPGStderr(err)) } // Create a secure buffer for the decrypted data diff --git a/internal/secret/pgpunlocker_test.go b/internal/secret/pgpunlocker_test.go index 60f13c8..1d20326 100644 --- a/internal/secret/pgpunlocker_test.go +++ b/internal/secret/pgpunlocker_test.go @@ -102,3 +102,27 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) { assert.NotEqual(t, first.GetDirectory(), second.GetDirectory()) assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory())) } + +// TestGPGDecryptErrorHasGPGReason runs the real gpg on data that is not an +// OpenPGP message and checks that the error carries the reason gpg writes +// to stderr. +func TestGPGDecryptErrorHasGPGReason(t *testing.T) { + // The empty GNUPGHOME keeps gpg away from the user's own keyring; + // LC_ALL=C keeps gpg's message in English. + t.Setenv("GNUPGHOME", t.TempDir()) + t.Setenv("LC_ALL", "C") + + _, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message")) + require.ErrorContains(t, err, "gpg: no valid OpenPGP data found") +} + +// TestGPGDecryptErrorWithoutGPGStderr checks that when gpg fails without +// writing to stderr, the error ends with the exit status and nothing after it. +// +//nolint:paralleltest // installFakeGPG uses t.Setenv +func TestGPGDecryptErrorWithoutGPGStderr(t *testing.T) { + installFakeGPG(t) + + _, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message")) + require.EqualError(t, err, "GPG decryption failed: exit status 1") +}