diff --git a/cmd/age/age.go b/cmd/age/age.go index 5b1dfc8..59b4aa1 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -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 diff --git a/cmd/age/testdata/plugin.txt b/cmd/age/testdata/plugin.txt index 95cc029..9fdd2ba 100644 --- a/cmd/age/testdata/plugin.txt +++ b/cmd/age/testdata/plugin.txt @@ -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 diff --git a/plugin/client.go b/plugin/client.go index c2b62b4..299af12 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -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 } diff --git a/plugin/client_test.go b/plugin/client_test.go index ef08782..35b0eca 100644 --- a/plugin/client_test.go +++ b/plugin/client_test.go @@ -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) + } +}