diff --git a/internal/ai/llm/anthropic/anthropic_test.go b/internal/ai/llm/anthropic/anthropic_test.go index c437b52..8137121 100644 --- a/internal/ai/llm/anthropic/anthropic_test.go +++ b/internal/ai/llm/anthropic/anthropic_test.go @@ -887,3 +887,26 @@ func TestRedactKeepsChainMatches(t *testing.T) { t.Fatal("redact wrapped an error that holds no key") } } + +// TestRedactedErrorsFormatWithoutKey formats every error type that holds the +// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString. +func TestRedactedErrorsFormatWithoutKey(t *testing.T) { + const key = "sk-distinctive-anthropic-format-7c1e" + single := redact(fmt.Errorf("anthropic %s: %w", key, llm.ErrBadOutput), key) + joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key) + if _, ok := single.(*redactedError); !ok { + t.Fatalf("single = %T, want *redactedError", single) + } + if _, ok := joined.(*redactedJoin); !ok { + t.Fatalf("joined = %T, want *redactedJoin", joined) + } + p := &Provider{apiKey: key} + transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded)) + for _, err := range []error{single, joined, transport} { + for _, verb := range []string{"%v", "%+v", "%#v"} { + if got := fmt.Sprintf(verb, err); strings.Contains(got, key) { + t.Fatalf("%s of %T leaks the key: %q", verb, err, got) + } + } + } +} diff --git a/internal/ai/llm/openai/openai.go b/internal/ai/llm/openai/openai.go index e6493de..33eb4c8 100644 --- a/internal/ai/llm/openai/openai.go +++ b/internal/ai/llm/openai/openai.go @@ -530,6 +530,9 @@ type redactedError struct { func (e *redactedError) Error() string { return e.text } +// GoString keeps %#v from printing the fields, which hold the key. +func (e *redactedError) GoString() string { return e.text } + func (e *redactedError) Unwrap() error { return redact(errors.Unwrap(e.err), e.key) } func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) } diff --git a/internal/ai/llm/openai/openai_test.go b/internal/ai/llm/openai/openai_test.go index 8f0a248..bbdd442 100644 --- a/internal/ai/llm/openai/openai_test.go +++ b/internal/ai/llm/openai/openai_test.go @@ -1119,3 +1119,26 @@ func TestGenerateWithAdapterNameKeyNeverLeaksKey(t *testing.T) { assertChainNoExactKey(t, err, key) }) } + +// TestRedactedErrorsFormatWithoutKey formats every error type that holds the +// key, or a cause holding it, with %v, %+v and %#v; %#v prints GoString. +func TestRedactedErrorsFormatWithoutKey(t *testing.T) { + const key = "sk-distinctive-openai-format-7c1e" + single := redact(fmt.Errorf("openai %s: %w", key, llm.ErrBadOutput), key) + joined := redact(errors.Join(fmt.Errorf("decode %s: %w", key, llm.ErrAuth), llm.NewProviderError(500, []byte("down "+key), key)), key) + if _, ok := single.(*redactedError); !ok { + t.Fatalf("single = %T, want *redactedError", single) + } + if _, ok := joined.(*redactedJoin); !ok { + t.Fatalf("joined = %T, want *redactedJoin", joined) + } + p := &Provider{apiKey: key} + transport := p.transportError(fmt.Errorf("dial %s: %w", key, context.DeadlineExceeded)) + for _, err := range []error{single, joined, transport} { + for _, verb := range []string{"%v", "%+v", "%#v"} { + if got := fmt.Sprintf(verb, err); strings.Contains(got, key) { + t.Fatalf("%s of %T leaks the key: %q", verb, err, got) + } + } + } +}