cmd/age,plugin: add plugin.NotFoundError and CLI hint

Fixes #486
This commit is contained in:
Filippo Valsorda
2025-12-23 12:34:24 +01:00
parent bfae75d93d
commit 9795b63263
4 changed files with 96 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ package main
import (
"bufio"
"bytes"
"errors"
"flag"
"fmt"
"io"
@@ -406,7 +407,11 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor
out = a
}
w, err := age.Encrypt(out, recipients...)
if err != nil {
if e := new(plugin.NotFoundError); errors.As(err, &e) {
errorWithHint(err.Error(),
fmt.Sprintf("you might want to install the %q plugin", e.Name),
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
} else if err != nil {
errorf("%v", err)
}
if _, err := io.Copy(w, in); err != nil {
@@ -483,7 +488,11 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
}
r, err := age.Decrypt(in, identities...)
if err != nil {
if e := new(plugin.NotFoundError); errors.As(err, &e) {
errorWithHint(err.Error(),
fmt.Sprintf("you might want to install the %q plugin", e.Name),
"visit https://age-encryption.org/awesome#plugins for a list of available plugins")
} else if err != nil {
errorf("%v", err)
}
out.Write(nil) // trigger the lazyOpener even if r is empty

View File

@@ -19,6 +19,12 @@ cp age-plugin-pwn/pwn $TMPDIR/age-plugin-pwn/pwn
! age -d -j pwn/pwn test.age
! exists pwn
# check plugin not found hint
! age -r age1nonexistentplugin1pt5d8z -o test1.age
stderr /awesome#plugins
! age -d -i nonexistent-identity.txt test.age
stderr /awesome#plugins
-- input --
test
-- key.txt --
@@ -32,3 +38,5 @@ AGE-PLUGIN-PWN/PWN-19GYK4WLY
-- age-plugin-pwn/pwn --
#!/bin/sh
touch "$WORK/pwn"
-- nonexistent-identity.txt --
AGE-PLUGIN-NONEXISTENTPLUGIN-1R4XFW4

View File

@@ -9,6 +9,7 @@ package plugin
import (
"bufio"
"crypto/rand"
"errors"
"fmt"
"io"
mathrand "math/rand/v2"
@@ -402,6 +403,24 @@ type clientConnection struct {
close func()
}
// NotFoundError is returned by [Recipient.Wrap] and [Identity.Unwrap] when the
// plugin binary cannot be found.
type NotFoundError struct {
// Name is the plugin (not binary) name.
Name string
// Err is the underlying error, usually an [exec.Error] wrapping
// [exec.ErrNotFound].
Err error
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%q plugin not found: %v", e.Name, e.Err)
}
func (e *NotFoundError) Unwrap() error {
return e.Err
}
var testOnlyPluginPath string
func openClientConnection(name, protocol string) (*clientConnection, error) {
@@ -444,6 +463,9 @@ func openClientConnection(name, protocol string) (*clientConnection, error) {
cmd.Dir = os.TempDir()
if err := cmd.Start(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return nil, &NotFoundError{Name: name, Err: err}
}
return nil, err
}

View File

@@ -7,8 +7,11 @@
package plugin
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
@@ -108,3 +111,55 @@ func TestLabels(t *testing.T) {
t.Errorf("expected one pqc and one normal to fail")
}
}
func TestNotFound(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows support is TODO")
}
r := EncodeRecipient("nonexistentplugin", nil)
t.Log(r)
testPluginRecipient, err := NewRecipient(r, &ClientUI{})
if err != nil {
t.Fatal(err)
}
var e *NotFoundError
if _, err := age.Encrypt(io.Discard, testPluginRecipient); err == nil {
t.Errorf("expected error for nonexistent plugin")
} else if !errors.As(err, &e) {
t.Errorf("expected NotFoundError, got %T: %v", err, err)
} else if e.Name != "nonexistentplugin" {
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
} else if !errors.Is(err, exec.ErrNotFound) {
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
}
buf := &bytes.Buffer{}
id, err := age.GenerateHybridIdentity()
if err != nil {
t.Fatal(err)
}
w, err := age.Encrypt(buf, id.Recipient())
if err != nil {
t.Fatal(err)
}
w.Close()
i := EncodeIdentity("nonexistentplugin", nil)
t.Log(i)
testPluginIdentity, err := NewIdentity(i, &ClientUI{})
if err != nil {
t.Fatal(err)
}
if _, err := age.Decrypt(buf, testPluginIdentity); err == nil {
t.Errorf("expected error for nonexistent plugin")
} else if errors.As(err, new(*age.NoIdentityMatchError)) {
t.Errorf("expected NotFoundError, got NoIdentityMatchError: %v", err)
} else if !errors.As(err, &e) {
t.Errorf("expected NotFoundError, got %T: %v", err, err)
} else if e.Name != "nonexistentplugin" {
t.Errorf("expected NotFoundError.Name to be nonexistentplugin, got %q", e.Name)
} else if !errors.Is(err, exec.ErrNotFound) {
t.Errorf("expected error to wrap exec.ErrNotFound, got: %v", err)
}
}