From ae89b1a138914362d66e915c520d6dd00a877258 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 23:06:27 +0000 Subject: [PATCH 1/5] feat(golang): every failure a guest reports is a Diagnostic that unwraps to its sentinel A Go caller could check an error's kind with errors.Is and nothing more: the guest handed back a status number, and errors.As had no type to target. #1100 gave both guests se_last_error, which hands over the full error behind a failed export. This reads it. guest.Call fetches it after any non-zero status and returns a *Diagnostic: Code, Message, Help, URL, Severity, Fields and Causes, with Unwrap returning the sentinel the status already mapped to, so every errors.Is check keeps working and Error() is the Rust message. The buffer is wiped on both sides once read. A guest with no se_last_error, nothing recorded, or bytes that do not decode into an error with a message all give the bare sentinel, as before; a trap fetching it also wraps ErrTrap, so the caller closes the instance as after any trap. encrypt and auth expose the type as Diagnostic (and Cause) by alias, as they do the sentinels. ExpectedKeyset and FoundKeyset read a foreign-keyset refusal's two keysets, and Field and Reason a refused plan, record or value's field and reason. Tests: a hand-assembled guest drives each missing or broken detail and checks the wipe; each ZeroKMS outcome, each engine refusal reachable through the public API, and each profile-store kind asserts its code; a guest with se_last_error unbound fails with the bare sentinel; the foreign-keyset refusal carries both ids, hermetically and live. The README's Errors section explains errors.Is for the kind and errors.As for the detail, and the rule for what an error may contain. Closes #1101 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- languages/golang/auth/diagnostic_test.go | 107 +++++ languages/golang/auth/errors.go | 35 +- languages/golang/auth/guest.go | 3 + languages/golang/auth/strategy_test.go | 63 ++- languages/golang/auth/transport.go | 8 +- languages/golang/encrypt/README.md | 38 +- languages/golang/encrypt/checker.go | 5 +- languages/golang/encrypt/client.go | 11 +- languages/golang/encrypt/diagnostic_test.go | 78 ++++ languages/golang/encrypt/doc.go | 4 +- languages/golang/encrypt/errors.go | 46 ++- languages/golang/encrypt/errors_test.go | 97 +++++ languages/golang/encrypt/export_test.go | 7 + languages/golang/encrypt/guest.go | 3 + languages/golang/encrypt/guest_test.go | 78 +++- languages/golang/encrypt/live_test.go | 20 +- languages/golang/encrypt/records.go | 6 +- languages/golang/internal/guest/call.go | 12 +- languages/golang/internal/guest/diagnostic.go | 275 +++++++++++++ .../golang/internal/guest/diagnostic_test.go | 371 ++++++++++++++++++ languages/golang/internal/guest/doc.go | 12 +- languages/golang/internal/guest/errors.go | 7 +- languages/golang/internal/guest/status.go | 13 +- 23 files changed, 1246 insertions(+), 53 deletions(-) create mode 100644 languages/golang/auth/diagnostic_test.go create mode 100644 languages/golang/encrypt/diagnostic_test.go create mode 100644 languages/golang/encrypt/errors_test.go create mode 100644 languages/golang/internal/guest/diagnostic.go create mode 100644 languages/golang/internal/guest/diagnostic_test.go diff --git a/languages/golang/auth/diagnostic_test.go b/languages/golang/auth/diagnostic_test.go new file mode 100644 index 000000000..d2611caee --- /dev/null +++ b/languages/golang/auth/diagnostic_test.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// wantDiagnostic asserts that err carries a *Diagnostic with code and a +// message, and returns it. +func wantDiagnostic(t *testing.T, err error, code string) *Diagnostic { + t.Helper() + var d *Diagnostic + if !errors.As(err, &d) { + t.Fatalf("%v carries no Diagnostic", err) + } + if d.Code != code { + t.Fatalf("code = %q, want %q (%v)", d.Code, code, err) + } + if d.Message == "" { + t.Fatalf("%s has no message", code) + } + return d +} + +// wantCode is wantDiagnostic for a test that needs only the code. +func wantCode(t *testing.T, err error, code string) { + t.Helper() + _ = wantDiagnostic(t, err, code) +} + +// Each kind the profile store reports is its sentinel for errors.Is and a +// Diagnostic for errors.As. The strategies' kinds are asserted beside the +// tests that provoke them, in strategy_test.go. ErrInvalidFilename is not +// here: the store refuses every filename the guest would before asking it. +func TestEachStoreKindCarriesItsDiagnostic(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsB) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + run func() error + want error + code string + }{ + {"no current workspace", func() error { + _, err := s.CurrentWorkspace(ctx) + return err + }, ErrNoCurrentWorkspace, "stack_profile::no_current_workspace"}, + {"a workspace with no directory", func() error { + return s.SetCurrentWorkspace(ctx, "CCCCCCCCCCCCCCCC") + }, ErrWorkspaceNotFound, "stack_profile::workspace_not_found"}, + {"a workspace id that is a path", func() error { + return s.SetCurrentWorkspace(ctx, "../escape") + }, ErrInvalidWorkspaceID, "stack_profile::invalid_workspace_id"}, + {"a file that is not there", func() error { + _, err := ws.Token(ctx) + return err + }, ErrNotFound, "stack_profile::not_found"}, + {"a file that is not JSON", func() error { + write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{not json") + _, err := ws.Token(ctx) + return err + }, ErrInvalid, "stack_profile::json"}, + {"a file that is a directory", func() error { + if err := os.MkdirAll(filepath.Join(dir, "workspaces", wsB, "secretkey.json"), 0o700); err != nil { + t.Fatal(err) + } + _, _, err := ws.SecretKey(ctx) + return err + }, ErrIO, "stack_profile::io"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + if !errors.Is(err, tc.want) { + t.Fatalf("%v, want %v", err, tc.want) + } + wantCode(t, err, tc.code) + }) + } +} + +// A profile file that is not JSON says where: the line and column, never +// the parser's message, which could quote the file. +func TestAProfileJSONErrorNamesTheLine(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{\n \"access_token\": 7\n}") + ws, err := s.WorkspaceStore(ctx, wsB) + if err != nil { + t.Fatal(err) + } + _, err = ws.Token(ctx) + d := wantDiagnostic(t, err, "stack_profile::json") + if d.Fields["line"] != uint64(2) { + t.Errorf("fields = %v, want line 2", d.Fields) + } + if d.Help == "" { + t.Error("a JSON error gives no help") + } +} diff --git a/languages/golang/auth/errors.go b/languages/golang/auth/errors.go index 72eeba7a9..c04b568a9 100644 --- a/languages/golang/auth/errors.go +++ b/languages/golang/auth/errors.go @@ -9,7 +9,8 @@ import ( // Failure kinds the profile reports. The guest reports a status code from // the one table every guest shares, so these are the sentinels of the // shared decoder exposed under this package's names; an error from -// encrypt of the same kind is the same value. +// encrypt of the same kind is the same value. Check one with errors.Is; +// the detail behind it is a [Diagnostic]. var ( // ErrNotFound is a profile file that does not exist in the store asked: // no secretkey.json, auth.json or device.json there. For the workspace's @@ -62,3 +63,35 @@ var ( // logged in on this machine, or CS_CONFIG_PATH names the wrong place. ErrNoProfile = errors.New("auth: no profile directory; run `stash auth login`") ) + +// Diagnostic is the full error behind a failure the guest reports, beside +// the kind: every such failure is a *Diagnostic wrapping one of the +// sentinels above, so errors.Is matches the kind and errors.As reads the +// rest: +// +// var d *auth.Diagnostic +// if errors.As(err, &d) { +// log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help) +// } +// +// Its fields are Code ("stack_profile::not_found", "stack_auth::invalid_crn", +// ...; stable), Message (what Error returns), Help, URL, Severity, Fields +// (the structured fields, by name: a profile file's "path", a JSON error's +// "line" and "column") and Causes (the errors behind it, outermost first). +// +// What one may carry is fixed: workspace ids, CRNs and regions, profile +// file paths, HTTP statuses and the auth server's error descriptions. +// Never a token, an access key, a client key or a response body. +// +// Errors the store raises itself carry none: a closed store ([ErrState]), +// a guest that did not return, [ErrMemoryLock], [ErrNoProfile]. A guest +// built before the detail existed returns the bare sentinel too. +// +// It is the same type as encrypt.Diagnostic, by identity. +type Diagnostic = guest.Diagnostic + +// Cause is one error in a [Diagnostic]'s cause chain: a Code and a Message. +// A cause from a library outside the stack crates has no Code, and its +// Message is a description the guest vouches for, never that library's own +// text. +type Cause = guest.Cause diff --git a/languages/golang/auth/guest.go b/languages/golang/auth/guest.go index 2d157f5b0..5c17ef261 100644 --- a/languages/golang/auth/guest.go +++ b/languages/golang/auth/guest.go @@ -175,6 +175,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. return fail(fmt.Errorf("auth: guest is missing export %s", name)) } } + // Optional: a guest built before se_last_error reports the status + // alone, and its failures stay the bare sentinels. See guest.Diagnostic. + inst.exports.LastError = guest.LastErrorExport(module) return inst, nil } diff --git a/languages/golang/auth/strategy_test.go b/languages/golang/auth/strategy_test.go index 3715ea115..dea6c0c6e 100644 --- a/languages/golang/auth/strategy_test.go +++ b/languages/golang/auth/strategy_test.go @@ -250,6 +250,7 @@ func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) { if !errors.Is(err, ErrUsageLimit) { t.Fatalf("Token error = %v, want %v", err, ErrUsageLimit) } + wantCode(t, err, "stack_auth::usage_limit_exceeded") } func TestDeviceRefreshReportsInvalidClient(t *testing.T) { @@ -278,6 +279,7 @@ func TestDeviceRefreshReportsInvalidClient(t *testing.T) { if !errors.Is(err, ErrInvalidClient) { t.Fatalf("Token error = %v, want %v", err, ErrInvalidClient) } + wantCode(t, err, "stack_auth::invalid_client") } // Match stack-auth's AutoStrategy order: an access key wins over a stored @@ -452,13 +454,20 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { t.Fatalf("set but empty access key: error = %v, want %v", err, ErrConfig) } // A key that does not parse is a configuration error like the empty one, - // the class Rust's AutoStrategy reports, not a malformed-input error. - t.Setenv("CS_CLIENT_ACCESS_KEY", "not-a-key") + // the class Rust's AutoStrategy reports, not a malformed-input error. It + // is the one guest call that receives an access key and fails before + // any request, and its error never quotes the key. + const keyMarker = "leak-marker-access-key" + t.Setenv("CS_CLIENT_ACCESS_KEY", keyMarker) if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrConfig) { t.Fatalf("malformed access key: error = %v, want %v", err, ErrConfig) + } else if d := wantDiagnostic(t, err, "stack_auth::invalid_access_key"); strings.Contains(fmt.Sprintf("%v %+v", err, *d), keyMarker) { + t.Fatalf("the access key is in the Diagnostic: %v %+v", err, *d) } if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrConfig) { t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrConfig) + } else { + wantCode(t, err, "stack_auth::invalid_crn") } provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrConfig) { @@ -587,6 +596,7 @@ func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { if !errors.Is(err, ErrInvalidGrant) { t.Fatalf("Token error = %v, want ErrInvalidGrant", err) } + wantCode(t, err, "stack_auth::invalid_grant") } // The edge in front of production CTS answers a request whose User-Agent is @@ -639,8 +649,8 @@ func isStackAuthGoAgent(ua string) bool { return ok && version != "" && !strings.ContainsAny(version, " ()") } -// Only a status code crosses the guest ABI, so a refused exchange must still -// say which HTTP status refused it, and never carry the response body. +// A refused exchange says which HTTP status refused it, and never carries +// the response body: not in the message, and not in the Diagnostic. func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { guestOrSkip(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -663,13 +673,20 @@ func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { if !errors.Is(err, ErrTransport) { t.Fatalf("Token error = %v, want ErrTransport", err) } - if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want { + if want := "Server error: 403: HTTP 403"; err.Error() != want { t.Fatalf("Token error = %q, want %q", err, want) } + var d *Diagnostic + if !errors.As(err, &d) || d.Code != "stack_auth::server_error" { + t.Fatalf("Token error = %#v, want a stack_auth::server_error Diagnostic", err) + } + if shown := fmt.Sprintf("%+v", *d); strings.Contains(shown, "nginx") || strings.Contains(shown, "testKeySecret") { + t.Fatalf("the response body is in the Diagnostic: %s", shown) + } } -// A transport failure with no HTTP response at all stays the bare sentinel: -// there is no status to name. +// A transport failure with no HTTP response at all names no status: there +// is none to name. It is the guest's request error, over ErrTransport. func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { guestOrSkip(t) server := httptest.NewServer(http.NotFoundHandler()) @@ -687,7 +704,37 @@ func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { defer strategy.Close() _, err = strategy.Token(context.Background()) if !errors.Is(err, ErrTransport) || strings.Contains(err.Error(), "HTTP") { - t.Fatalf("Token error = %v, want a bare ErrTransport", err) + t.Fatalf("Token error = %v, want ErrTransport naming no status", err) + } + wantCode(t, err, "stack_auth::request_error") +} + +// The host's transport error stays out of the Diagnostic: a RoundTripper's +// or a proxy's text can carry a URL's query string or proxy credentials. +func TestAuthTransportErrorTextIsNotInTheDiagnostic(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + const marker = "leak-marker-transport" + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New(`Post "https://cts.invalid/token?secret=` + marker + `": proxyconnect tcp: refused`) + }) + profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt)) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(ctx) + var d *Diagnostic + if !errors.Is(err, ErrTransport) || !errors.As(err, &d) { + t.Fatalf("Token error = %#v, want a Diagnostic over ErrTransport", err) + } + if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) { + t.Fatalf("the host's transport error is in the Diagnostic: %s", shown) } } diff --git a/languages/golang/auth/transport.go b/languages/golang/auth/transport.go index d2cea0f65..3454182d5 100644 --- a/languages/golang/auth/transport.go +++ b/languages/golang/auth/transport.go @@ -42,9 +42,11 @@ func (f OIDCProviderFunc) Token(ctx context.Context) (string, error) { return f( const maxAuthResponseBytes = 16 << 20 // authHTTPStatus records the status of the last HTTP response the transport -// received during one guest call. Only a status code crosses the guest ABI, -// so without it a refused exchange (the edge in front of CTS answering 403) -// reaches the caller as a bare ErrTransport. It lives on the call's +// received during one guest call. The guest's error does not always name it +// (a refused response whose body could not be read reaches the guest as a +// transport failure, with no status), and a guest built before se_last_error +// gives no error at all, so without it a refused exchange (the edge in front +// of CTS answering 403) could reach the caller as a bare ErrTransport. It lives on the call's // context, which wazero hands to the host import, so concurrent calls on // different profiles never see each other's status. type authHTTPStatus struct{ code int } diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index e2a36e8a5..4a1073190 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -215,7 +215,9 @@ A database can compare some terms by itself: The generator and the compiler find a mistake in a declaration, so no call returns an error for one. A call returns an error for a key, for the network, -or for stored data; read them with `errors.Is`: +or for stored data. Check an error two ways. + +`errors.Is` tells you the kind, which is what a program branches on: - `ErrForeignKeyset`: a `*Cipher` got a row another keyset sealed. - `ErrContextMismatch`: a `*Cipher` with a `Context` got a row whose @@ -226,9 +228,37 @@ or for stored data; read them with `errors.Is`: - `ErrUnauthorized`, `ErrNotFound`, `ErrTransport`, `ErrKMS`: ZeroKMS. - `ErrState`: a call on a closed client. `ErrMemoryLock`: see below. -No error, warning or log line holds a plaintext value. A generated type hides -its sealed fields when a program prints or logs it; the struct you wrote does -not, and `stashgen -redact` writes `String` and `LogValue` for it. +`errors.As` with a `*Diagnostic` gives you the detail behind a failure the +engine reports: a stable `Code` (`stack_encrypt::foreign_keyset`, +`stack_kms::keyset_not_found`, ...), the one-line `Message` that `Error()` +returns, `Help` saying what to do about it, `Fields` (structured values, by +name) and `Causes` (the errors behind it). Accessors read the values a program +is likely to branch on: + +```go +var d *encrypt.Diagnostic +if errors.As(err, &d) { + log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help) + if expected, ok := d.ExpectedKeyset(); ok { + found, _ := d.FoundKeyset() + log.Printf("cipher is bound to %s; the row was sealed under %s", + encrypt.KeysetID(expected), encrypt.KeysetID(found)) + } +} +``` + +`Field` and `Reason` name the field and the problem (`field_missing`, +`unknown_key`, ...) on a refused plan, record or value. An error the client +raises itself carries no `Diagnostic`: a closed client, a guest that did not +return, `ErrMemoryLock`, and an argument refused before it reached the engine. + +What an error may contain is fixed. It may carry keyset ids and names, field +names, counts, index kinds, ZeroKMS request kinds and HTTP statuses. It never +carries a plaintext value, key material, a token, ciphertext or term bytes, or +the values of an encryption context. No warning or log line holds a plaintext +value either. A generated type hides its sealed fields when a program prints +or logs it; the struct you wrote does not, and `stashgen -redact` writes +`String` and `LogValue` for it. ## Key material diff --git a/languages/golang/encrypt/checker.go b/languages/golang/encrypt/checker.go index 7cd2cc5a3..7f3a99e25 100644 --- a/languages/golang/encrypt/checker.go +++ b/languages/golang/encrypt/checker.go @@ -40,8 +40,9 @@ func (k *Checker) Close() error { return k.c.Close() } // Check refuses a plan the engine would refuse: an index its field's type // does not admit, a context that is not a label, two fields under one -// identity. The error is [ErrEncoding]; which rule failed is the engine's to -// know, so a caller that wants the field named checks one field at a time. +// identity. The error is [ErrEncoding]. A refusal from the engine is a +// [*Diagnostic] that names the field with [Diagnostic.Field] and the rule +// with [Diagnostic.Reason]. func (k *Checker) Check(ctx context.Context, plan *record.Plan) error { if err := plan.Validate(); err != nil { return fmt.Errorf("%w: %v", ErrEncoding, err) diff --git a/languages/golang/encrypt/client.go b/languages/golang/encrypt/client.go index 361106ada..4f89bd4ff 100644 --- a/languages/golang/encrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -427,9 +427,16 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) } // The guest reports a failed token_get as a transport failure and no - // more; the token source said why. + // more; the token source said why. When the source's error is a + // Diagnostic it goes first, so errors.As finds the cause rather than the + // guest's "token_get failed"; errors.Is still matches both. if err != nil && c.transport != nil && c.transport.tokenErr != nil { - err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr) + var cause *guest.Diagnostic + if errors.As(c.transport.tokenErr, &cause) { + err = fmt.Errorf("token source: %w (%w)", c.transport.tokenErr, err) + } else { + err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr) + } } if err != nil { return nil, err diff --git a/languages/golang/encrypt/diagnostic_test.go b/languages/golang/encrypt/diagnostic_test.go new file mode 100644 index 000000000..811496d44 --- /dev/null +++ b/languages/golang/encrypt/diagnostic_test.go @@ -0,0 +1,78 @@ +package encrypt + +import ( + "context" + "errors" + "testing" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// wantDiagnostic asserts that err carries a *Diagnostic with code and a +// message, and returns it. +func wantDiagnostic(t *testing.T, err error, code string) *Diagnostic { + t.Helper() + var d *Diagnostic + if !errors.As(err, &d) { + t.Fatalf("%v carries no Diagnostic", err) + } + if d.Code != code { + t.Fatalf("code = %q, want %q (%v)", d.Code, code, err) + } + if d.Message == "" { + t.Fatalf("%s has no message", code) + } + return d +} + +// wantCode is wantDiagnostic for a test that needs only the code. +func wantCode(t *testing.T, err error, code string) { + t.Helper() + _ = wantDiagnostic(t, err, code) +} + +// The guest's own refusals, which no ZeroKMS stub reaches: input that is +// not the codec, and an operation before init. +func TestGuestRefusalsCarryTheirDiagnostic(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + _, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.planCheck, buf([]byte{0xff})) + }) + if !errors.Is(err, ErrEncoding) { + t.Fatalf("plan check of bytes that are not the codec: %v, want ErrEncoding", err) + } + wantCode(t, err, "stack_guest_abi::malformed_input") + + selector, err := vcffi.Marshal(KeysetName("tenant-b").selector()) + if err != nil { + t.Fatal(err) + } + _, err = c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.keyset, buf(selector)) + }) + if !errors.Is(err, ErrState) { + t.Fatalf("a keyset before init: %v, want ErrState", err) + } + if d := wantDiagnostic(t, err, "stack_guest_abi::out_of_order"); d.Help == "" { + t.Error("an out-of-order call gives no help") + } +} + +// A guest built before se_last_error fails as it always did: the bare +// sentinel, and nothing else. +func TestAGuestWithoutLastErrorFailsWithTheBareSentinel(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + c.inst.exports.LastError = nil + _, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.planCheck, buf([]byte{0xff})) + }) + if err != ErrEncoding { + t.Fatalf("err = %#v, want the bare ErrEncoding", err) + } + var d *Diagnostic + if errors.As(err, &d) { + t.Fatalf("a Diagnostic from a guest with no se_last_error: %+v", d) + } +} diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index 71d4de4b0..7b114f0e3 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -146,7 +146,9 @@ // network, or for stored data: [ErrForeignKeyset] when a *Cipher is given a // record another keyset sealed, [ErrAuthentication] or [ErrForbidden] for a // ciphertext that does not open under its field's context, [ErrEncoding] for -// a stored value that does not fit its declaration. Read them with errors.Is. +// a stored value that does not fit its declaration. Read the kind with +// errors.Is, and the detail behind a failure the engine reports (its code, +// help, structured fields and causes) with errors.As into a [*Diagnostic]. // No error, warning or log line holds a plaintext value; a generated type // hides its sealed fields when a program prints it. // diff --git a/languages/golang/encrypt/errors.go b/languages/golang/encrypt/errors.go index 1445c2d1d..fdf873c1c 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -2,10 +2,11 @@ package encrypt import "github.com/cipherstash/stack/languages/golang/internal/guest" -// Failure kinds surfaced across the boundary. The guest reports a status -// code and nothing else, so these are the whole vocabulary: they separate a -// tampered ciphertext from a bad token from a malformed input, and reveal -// nothing about plaintext or key material. +// Failure kinds surfaced across the boundary: the guest reports a status +// code, and these are what the codes decode to. They separate a tampered +// ciphertext from a bad token from a malformed input, and reveal nothing +// about plaintext or key material. Check one with errors.Is; the detail +// behind it is a [Diagnostic]. // // They are the sentinels every guest package shares (one status table for // every guest, decoded once), exposed here under this package's names: an @@ -64,3 +65,40 @@ var ( // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). ErrMemoryLock = guest.ErrMemoryLock ) + +// Diagnostic is the full error behind a failure the guest reports, beside +// the kind: every such failure is a *Diagnostic wrapping one of the +// sentinels above, so errors.Is matches the kind and errors.As reads the +// rest: +// +// var d *encrypt.Diagnostic +// if errors.As(err, &d) { +// log.Printf("%s: %s (%s)", d.Code, d.Message, d.Help) +// } +// +// Its fields are Code ("stack_encrypt::foreign_keyset", +// "stack_kms::keyset_not_found", ...; stable), Message (what Error +// returns), Help, URL, Severity, Fields (the structured fields, by name) +// and Causes (the errors behind it, outermost first). Accessors read the +// fields a caller branches on: ExpectedKeyset and FoundKeyset on an +// [ErrForeignKeyset] (each a [KeysetID]'s bytes), and Field and Reason on a +// refused plan, record or value. +// +// What one may carry is fixed: keyset ids and names, field names, counts, +// index kinds, ZeroKMS request kinds and HTTP statuses. Never plaintext, +// key material, tokens, ciphertext or term bytes, or the values of an +// encryption context. +// +// Errors the client raises itself carry none: a closed client ([ErrState]), +// a guest that did not return, [ErrMemoryLock], and an argument refused +// before it reached the guest. A guest built before the detail existed +// returns the bare sentinel too. +// +// It is the same type as auth.Diagnostic, by identity. +type Diagnostic = guest.Diagnostic + +// Cause is one error in a [Diagnostic]'s cause chain: a Code and a Message. +// A cause from a library outside the stack crates has no Code, and its +// Message is a description the guest vouches for, never that library's own +// text. +type Cause = guest.Cause diff --git a/languages/golang/encrypt/errors_test.go b/languages/golang/encrypt/errors_test.go new file mode 100644 index 000000000..e7253d348 --- /dev/null +++ b/languages/golang/encrypt/errors_test.go @@ -0,0 +1,97 @@ +package encrypt_test + +import ( + "context" + "errors" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// Each kind the engine refuses through the public API is its sentinel for +// errors.Is and a Diagnostic for errors.As. The ZeroKMS kinds are in +// TestTransportOutcomesMapToErrors, and the guest's own refusals in +// TestGuestRefusalsCarryTheirDiagnostic. +func TestEachKindCarriesItsDiagnostic(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + elsewhere, err := testusers.Encrypt(ctx, c.Keyset(encrypt.KeysetName("tenant-b")), people[:1]) + if err != nil { + t.Fatal(err) + } + flipped, err := testusers.Encrypt(ctx, cipher, people[:1]) + if err != nil { + t.Fatal(err) + } + flipped[0].Email.Ciphertext[len(flipped[0].Email.Ciphertext)-1] ^= 1 + truncated, err := testusers.Encrypt(ctx, cipher, people[:1]) + if err != nil { + t.Fatal(err) + } + truncated[0].Email.Ciphertext = truncated[0].Email.Ciphertext[:3] + cases := []struct { + name string + run func() error + want error + code string + }{ + {"a row sealed under another keyset", func() error { + _, err := testusers.Decrypt(ctx, cipher, elsewhere) + return err + }, encrypt.ErrForeignKeyset, "stack_encrypt::foreign_keyset"}, + {"a tampered leaf", func() error { + _, err := testusers.Decrypt(ctx, cipher, flipped) + return err + }, encrypt.ErrAuthentication, "stack_encrypt::aead"}, + {"match text with no token", func() error { + _, err := testusers.Fields.Email.Match(ctx, cipher, "") + return err + }, encrypt.ErrTerm, "stack_encrypt::empty_term_text"}, + {"a ciphertext that is not a sealed value", func() error { + _, err := testusers.Decrypt(ctx, cipher, truncated) + return err + }, encrypt.ErrEncoding, "stack_encrypt::leaf_truncated"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.run() + if !errors.Is(err, tc.want) { + t.Fatalf("%v, want %v", err, tc.want) + } + encrypt.WantCode(t, err, tc.code) + }) + } +} + +// A foreign-keyset refusal names both keysets: the one the cipher is bound +// to and the one the row was sealed under. +func TestAForeignKeysetNamesBothKeysets(t *testing.T) { + c := deterministicClient(t) + ctx := context.Background() + other := c.Keyset(encrypt.KeysetName("tenant-b")) + encrypted, err := testusers.Encrypt(ctx, other, people[:1]) + if err != nil { + t.Fatal(err) + } + _, err = testusers.Decrypt(ctx, c.DefaultKeyset(), encrypted) + d := encrypt.WantDiagnostic(t, err, "stack_encrypt::foreign_keyset") + wantExpected, err := c.DefaultKeyset().KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + wantFound, err := other.KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %s", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %s", got, ok, wantFound) + } + if d.Help == "" { + t.Error("a foreign keyset gives no help") + } +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 40e74cd4d..8fd779c95 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -176,3 +176,10 @@ func NewCheckerOver(ctx context.Context, wasm []byte) (*Checker, error) { // LiveClient is liveClient for the external tests: a client against real // ZeroKMS from the STACK_ENCRYPT_TEST_* variables, or a skip. func LiveClient(t *testing.T) *Client { return liveClient(t) } + +// WantDiagnostic asserts that err carries a *Diagnostic with code and a +// message, and returns it; WantCode only asserts. +var ( + WantDiagnostic = wantDiagnostic + WantCode = wantCode +) diff --git a/languages/golang/encrypt/guest.go b/languages/golang/encrypt/guest.go index cddc1bade..c5bbecbd4 100644 --- a/languages/golang/encrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -183,6 +183,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo return nil, fmt.Errorf("encrypt: guest is missing export %s", name) } } + // Optional: a guest built before se_last_error reports the status + // alone, and its failures stay the bare sentinels. See guest.Diagnostic. + inst.exports.LastError = guest.LastErrorExport(module) return inst, nil } diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index 6d9b1153a..f6863f62a 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -179,20 +179,24 @@ func TestNewClientIssuesTheLoadKeysetRequest(t *testing.T) { func TestTransportOutcomesMapToErrors(t *testing.T) { guestOrSkip(t) + // Each outcome is its sentinel for errors.Is and a Diagnostic for + // errors.As, whose code names the refusal and which never carries + // ZeroKMS's response body. cases := []struct { name string status int contentType string body string want error + code string }{ - {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized}, - {"403", http.StatusForbidden, "", "not permitted", ErrForbidden}, - {"404", http.StatusNotFound, "", "missing", ErrNotFound}, - {"409", http.StatusConflict, "", "exists", ErrConflict}, - {"500", http.StatusInternalServerError, "", "boom", ErrKMS}, - {"200 html", http.StatusOK, "text/html", "gateway", ErrKMS}, - {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS}, + {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized, "stack_kms::load_keyset_unauthorized"}, + {"403", http.StatusForbidden, "", "not permitted", ErrForbidden, "stack_kms::load_keyset_forbidden"}, + {"404", http.StatusNotFound, "", "missing", ErrNotFound, "stack_kms::keyset_not_found"}, + {"409", http.StatusConflict, "", "exists", ErrConflict, "stack_kms::load_keyset_failed"}, + {"500", http.StatusInternalServerError, "", "boom", ErrKMS, "stack_kms::load_keyset_failed"}, + {"200 html", http.StatusOK, "text/html", "gateway", ErrKMS, "stack_kms::load_keyset_failed"}, + {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS, "stack_kms::load_keyset_failed"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -201,6 +205,13 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { if !errors.Is(err, tc.want) { t.Fatalf("NewClient: %v, want %v", err, tc.want) } + d := wantDiagnostic(t, err, tc.code) + if d.Fields["request_kind"] == nil { + t.Errorf("fields = %v, want the request kind", d.Fields) + } + if shown := fmt.Sprintf("%s %v %v %s", d.Message, d.Fields, d.Causes, d.Help); strings.Contains(shown, tc.body) { + t.Errorf("the response body is in the detail: %s", shown) + } }) } t.Run("connection refused", func(t *testing.T) { @@ -211,6 +222,9 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } + if d := wantDiagnostic(t, err, "stack_kms::load_keyset_failed"); d.Fields["request_kind"] != "SendRequest" { + t.Errorf("fields = %v, want request_kind SendRequest", d.Fields) + } }) t.Run("no token", func(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") @@ -230,6 +244,22 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { t.Fatalf("a request was made without a token: %+v", stub.requests) } }) + t.Run("a token source that says why", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + cfg := testConfig(stub.URL) + // An auth strategy's refusal, as the auth package reports one. + refused := &Diagnostic{Code: "stack_auth::invalid_grant", Message: "Invalid grant", Help: "Log in again."} + cfg = append(cfg, WithCredentials(testCredentials(tokenFunc(func(context.Context) (string, error) { return "", refused })))) + _, err := NewClient(context.Background(), cfg...) + // errors.As finds the token source's detail, not the guest's + // "token_get failed", and errors.Is still matches both. + if d := wantDiagnostic(t, err, "stack_auth::invalid_grant"); d.Help != "Log in again." { + t.Errorf("Help = %q", d.Help) + } + if !errors.Is(err, ErrTransport) || !errors.Is(err, refused) { + t.Errorf("NewClient: %v, want it to match ErrTransport and the token source's error", err) + } + }) } func TestRoundTripperFailureIsTransport(t *testing.T) { @@ -757,12 +787,15 @@ func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { t.Errorf("%s: err = %v, want ErrEncoding naming the field", name, err) } // Past it, the engine refuses the same plan (Error::UntypedIndex in - // stack-encrypt) and the guest reports it as the caller's input. The - // ABI carries a status word and no message, so the field's name is - // the Go rule's to give; this asserts the two rules agree. - if err := checker.engineCheck(ctx, p); !errors.Is(err, ErrEncoding) { + // stack-encrypt) and the guest reports it as the caller's input, + // naming the same field: the two rules agree. + err = checker.engineCheck(ctx, p) + if !errors.Is(err, ErrEncoding) { t.Errorf("%s past Validate: err = %v, want ErrEncoding from the engine", name, err) } + if d := wantDiagnostic(t, err, "stack_encrypt::dynamic_untyped_index"); d.Field() != "age" { + t.Errorf("%s past Validate: the engine named field %q, want \"age\"", name, d.Field()) + } } sealed := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "notes", Kind: record.Untyped, Outputs: []record.Output{record.Ciphertext}}}} if err := checker.Check(ctx, sealed); err != nil { @@ -770,6 +803,29 @@ func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { } } +// The engine's reason reaches Go under the key Reason reads: a field whose +// outputs name the ciphertext twice is refused as a duplicate output. +func TestAPlanRefusalNamesItsReason(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + checker, err := NewChecker(ctx) + if err != nil { + t.Fatal(err) + } + defer checker.Close() + p := &record.Plan{Context: []string{"users"}, Fields: []record.Field{ + {Name: "age", Kind: record.Uint32, Outputs: []record.Output{record.Ciphertext, record.Ciphertext}}, + }} + err = checker.engineCheck(ctx, p) + if !errors.Is(err, ErrEncoding) { + t.Fatalf("err = %v, want ErrEncoding", err) + } + d := wantDiagnostic(t, err, "stack_encrypt::dynamic_plan") + if d.Field() != "age" || d.Reason() != "duplicate_output" { + t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason()) + } +} + // se_plan_check answers with no cipher: a plan the engine runs passes, a // plan it refuses is ErrEncoding, never ErrState. func TestPlanCheckAnswersWithoutACipher(t *testing.T) { diff --git a/languages/golang/encrypt/live_test.go b/languages/golang/encrypt/live_test.go index b479240c4..8c0b3a406 100644 --- a/languages/golang/encrypt/live_test.go +++ b/languages/golang/encrypt/live_test.go @@ -106,12 +106,30 @@ func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { t.Fatal(err) } encrypt.ResetSends(c) - if _, err := testusers.DecryptDocument(ctx, c.DefaultKeyset(), encrypted); !errors.Is(err, encrypt.ErrForeignKeyset) { + _, err = testusers.DecryptDocument(ctx, c.DefaultKeyset(), encrypted) + if !errors.Is(err, encrypt.ErrForeignKeyset) { t.Fatalf("default cipher opened another keyset's row: %v", err) } if n := encrypt.Sends(c); n != 0 { t.Errorf("a foreign row cost %d ZeroKMS calls before refusal", n) } + // The refusal names both keysets: the default the cipher is bound to, + // and the one the row was sealed under. + d := encrypt.WantDiagnostic(t, err, "stack_encrypt::foreign_keyset") + wantExpected, err := c.DefaultKeyset().KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + wantFound, err := c.Keyset(encrypt.KeysetName(other)).KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %s", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %s", got, ok, wantFound) + } if docs, err := testusers.DecryptDocument(ctx, c, encrypted); err != nil || docs[0].Title != "tenant b" { t.Fatalf("client decrypt of the other keyset: %v %+v", err, docs) } diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index d6ceea7ac..424184526 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -468,9 +468,9 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, context string, p } // locateTermFailure names the row, field and index behind an ErrTerm from a -// record call. The guest reports a status and nothing else, so the host asks -// the engine again, one term at a time, which costs no key request: a term -// derives locally. The engine defines no match term for text that yields no +// record call. The guest's error says what was wrong with the term but not +// which value or field it came from, so the host asks the engine again, one +// term at a time, which costs no key request: a term derives locally. The engine defines no match term for text that yields no // token (empty, separator-only, or shorter than the n-gram), because an // empty term would match every row; a program hands such a field a value or // drops the match index. p is the plan without this cipher's extension, diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 0d624af76..0c0c48d4a 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -41,9 +41,13 @@ func BufArg(data []byte) Arg { return Arg{data: data, isBuf: true} } // ScalarArg is a scalar argument. func ScalarArg(v uint64) Arg { return Arg{scalar: v} } -// Exports is the pair of exports every guest has, resolved on one module. +// Exports is the exports every guest package drives, resolved on one module. type Exports struct { Alloc, Dealloc api.Function + // LastError is se_last_error: the full error behind the last failed + // export (see Diagnostic). Nil for a guest built before it, which + // reports the status alone. + LastError api.Function } // AllocWrite stages data into a fresh guest buffer. @@ -81,7 +85,9 @@ func (e Exports) Free(ctx context.Context, b Buf) { // Call stages every buffer argument, calls fn with the arguments in // order, and copies the output out before every buffer — inputs and // output — is wiped and freed. mem is the module's allocator, held -// mapped for the whole call. +// mapped for the whole call. A failure the guest reports comes back as a +// *Diagnostic wrapping its status's sentinel, or as the sentinel alone +// when the guest gives no detail. func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.Function, args ...Arg) ([]byte, error) { mem.Enter() defer mem.Exit() @@ -110,7 +116,7 @@ func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.F } ptr, n, err := PackedResult(res[0]) if err != nil { - return nil, err + return nil, e.diagnose(ctx, m, err) } out := Buf{Ptr: ptr, Len: n} bufs = append(bufs, out) diff --git a/languages/golang/internal/guest/diagnostic.go b/languages/golang/internal/guest/diagnostic.go new file mode 100644 index 000000000..032fcb00a --- /dev/null +++ b/languages/golang/internal/guest/diagnostic.go @@ -0,0 +1,275 @@ +package guest + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero/api" +) + +// Diagnostic is the full error behind a guest's status: the error the Rust +// code raised, as the guest's se_last_error hands it over. Every failure a +// guest reports comes back as one, wrapping the sentinel its status maps +// to, so a caller checks the kind with errors.Is and reads the detail with +// errors.As: +// +// if errors.Is(err, encrypt.ErrForeignKeyset) { ... } +// var d *encrypt.Diagnostic +// if errors.As(err, &d) { log.Print(d.Code, d.Help) } +// +// What it may carry is the rule written on stack-profile's ErrorPayload: +// keyset ids and names, field names, counts, index kinds, ZeroKMS request +// kinds and HTTP statuses, workspace ids, CRNs and regions, profile file +// paths. Never plaintext, key material, tokens, ciphertext or term bytes, +// or a context's values: a stored context's length and parts at most. +// +// A guest built before se_last_error, or one whose error does not decode, +// returns the bare sentinel instead: the detail is never allowed to hide +// the failure. +type Diagnostic struct { + // Code is the error's code, "crate::name": "stack_encrypt::foreign_keyset", + // "stack_kms::keyset_not_found", "stack_profile::not_found". Stable, so + // a caller may branch on it; the sentinel Unwrap returns is the coarser + // kind. + Code string + // Message is the error's one-line message, what Error returns. + Message string + // Help says what to do about it, where the error knows. + Help string + // URL points at documentation for the error, where it has one. + URL string + // Severity is "error", "warning" or "advice". + Severity string + // Fields are the error's structured fields, by name: a foreign + // keyset's "expected" and "found", a refused plan's "field" and + // "reason", a ZeroKMS failure's "request_kind". Each value is a string, + // bool, uint64, int64, float64, nil, []any or map[string]any. + Fields map[string]any + // Causes are the errors behind this one, outermost first. + Causes []Cause + + kind error +} + +// Cause is one error in a Diagnostic's cause chain. A cause from one of the +// stack crates has a Code; one from another library has none, and its +// Message is a description the guest vouches for, never that library's own +// text. +type Cause struct { + Code string + Message string +} + +// Error returns the Rust error's message. +func (d *Diagnostic) Error() string { return d.Message } + +// Unwrap returns the sentinel the guest's status maps to, so errors.Is +// matches the same kinds it matched before the detail existed. +func (d *Diagnostic) Unwrap() error { return d.kind } + +// The code a foreign-keyset refusal carries, the one error whose keysets +// have accessors. +const codeForeignKeyset = "stack_encrypt::foreign_keyset" + +// ExpectedKeyset is the keyset a foreign-keyset refusal expected: the one +// the cipher is bound to. ok is false for any other error. The id is an +// encrypt.KeysetID's bytes: it compares with one as is. +func (d *Diagnostic) ExpectedKeyset() (id [16]byte, ok bool) { + return d.foreignKeyset("expected") +} + +// FoundKeyset is the keyset a foreign-keyset refusal found: the one the +// ciphertext was sealed under. ok is false for any other error. +func (d *Diagnostic) FoundKeyset() (id [16]byte, ok bool) { + return d.foreignKeyset("found") +} + +func (d *Diagnostic) foreignKeyset(key string) ([16]byte, bool) { + if d.Code != codeForeignKeyset { + return [16]byte{}, false + } + text, _ := d.Fields[key].(string) + return parseUUID(text) +} + +// Field is the field of a plan, record or value the error is about, or "" +// when it names none. +func (d *Diagnostic) Field() string { + field, _ := d.Fields["field"].(string) + return field +} + +// Reason is what was wrong with a plan, context, value or stored record, as +// the snake_case name stack-encrypt's dynamic::Reason gives it +// ("field_missing", "unknown_key", "field_type", ...), or "" for an error +// that gives none. New reasons may appear: keep a fallback. +func (d *Diagnostic) Reason() string { + reason, _ := d.Fields["reason"].(string) + return reason +} + +// parseUUID parses a canonical hyphenated UUID. +func parseUUID(text string) ([16]byte, bool) { + var id [16]byte + if len(text) != 36 || text[8] != '-' || text[13] != '-' || text[18] != '-' || text[23] != '-' { + return id, false + } + hexed := text[:8] + text[9:13] + text[14:18] + text[19:23] + text[24:] + if _, err := hex.Decode(id[:], []byte(hexed)); err != nil { + return [16]byte{}, false + } + return id, true +} + +// diagnose returns the full error behind kind, the sentinel a non-zero +// status decoded to: a *Diagnostic wrapping kind, fetched through the +// guest's se_last_error and wiped, host copy and guest buffer both, once +// decoded. It returns kind itself when there is no detail to give: the +// guest has no se_last_error (one built before it), recorded nothing, or +// handed over bytes that do not decode into an error with a message. +// +// For a status this host does not know, the Diagnostic comes behind kind's +// own text, so the status number stays in every log line: a newer guest +// records its own error for a status it added, and that error does not +// say the host is too old to know it. +// +// A trap in se_last_error leaves the guest in an unknown state, so the +// error then also wraps ErrTrap, for the caller to close the instance as +// it would after any trap; it still matches kind. +func (e Exports) diagnose(ctx context.Context, m api.Module, kind error) error { + if e.LastError == nil { + return kind + } + // Under a context that cannot be cancelled, as the frees are: the + // guest has returned, and a deadline that expires now must not cost + // the caller the detail of a failure already in hand. + res, err := e.LastError.Call(context.WithoutCancel(ctx)) + if err != nil { + return fmt.Errorf("%w; %w: fetching the error's detail: %w", kind, ErrTrap, err) + } + if len(res) == 0 || res[0]>>32 == 0 { + return kind + } + out := Buf{Ptr: uint32(res[0] >> 32), Len: uint32(res[0])} //nolint:gosec // splits the packed u64 into its two u32 halves + defer e.Free(ctx, out) + view, ok := m.Memory().Read(out.Ptr, out.Len) + if !ok { + return kind + } + raw := make([]byte, len(view)) + copy(raw, view) + defer Wipe(raw) + d, ok := decodeDiagnostic(raw) + if !ok { + return kind + } + d.kind = kind + var unknown unrecognizedStatus + if errors.As(kind, &unknown) { + return fmt.Errorf("%w: %w", kind, d) + } + return d +} + +// LastErrorExport is the module's se_last_error when it has the ABI's type, +// () -> i64, and nil otherwise, as for a guest built before it: the +// failure is then reported by its status alone. +func LastErrorExport(m api.Module) api.Function { + fn := m.ExportedFunction("se_last_error") + if fn == nil { + return nil + } + def := fn.Definition() + if len(def.ParamTypes()) != 0 || len(def.ResultTypes()) != 1 || def.ResultTypes()[0] != api.ValueTypeI64 { + return nil + } + return fn +} + +// decodeDiagnostic reads the object se_last_error encodes: code, message, +// help, url, severity, fields and causes. An error with no message is not +// one: ok is false, and the caller falls back to the sentinel. +func decodeDiagnostic(raw []byte) (*Diagnostic, bool) { + value, err := vcffi.Unmarshal(raw) + if err != nil { + return nil, false + } + object, ok := value.(vcvalue.Object) + if !ok { + return nil, false + } + d := &Diagnostic{Severity: "error", Fields: map[string]any{}} + text := map[string]*string{ + "code": &d.Code, + "message": &d.Message, + "help": &d.Help, + "url": &d.URL, + "severity": &d.Severity, + } + for _, entry := range object { + if slot, isText := text[entry.Key]; isText { + if s, isString := entry.Value.(string); isString { + *slot = s + } + continue + } + switch entry.Key { + case "fields": + if fields, isObject := entry.Value.(vcvalue.Object); isObject { + d.Fields = objectMap(fields) + } + case "causes": + d.Causes = causes(entry.Value) + } + } + if d.Message == "" { + return nil, false + } + return d, true +} + +// causes reads a list of {code?, message} objects, skipping anything else. +func causes(value any) []Cause { + items, _ := value.([]any) + var out []Cause + for _, item := range items { + entry, ok := item.(vcvalue.Object) + if !ok { + continue + } + fields := objectMap(entry) + code, _ := fields["code"].(string) + message, _ := fields["message"].(string) + out = append(out, Cause{Code: code, Message: message}) + } + return out +} + +// objectMap turns a decoded object into a map, and every object or list +// inside it likewise, so Fields holds only plain Go values. +func objectMap(object vcvalue.Object) map[string]any { + out := make(map[string]any, len(object)) + for _, entry := range object { + out[entry.Key] = plain(entry.Value) + } + return out +} + +func plain(value any) any { + switch v := value.(type) { + case vcvalue.Object: + return objectMap(v) + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = plain(item) + } + return out + default: + return v + } +} diff --git a/languages/golang/internal/guest/diagnostic_test.go b/languages/golang/internal/guest/diagnostic_test.go new file mode 100644 index 000000000..7c91d49b1 --- /dev/null +++ b/languages/golang/internal/guest/diagnostic_test.go @@ -0,0 +1,371 @@ +package guest_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental" +) + +// The fetch, decode and wipe of a guest's detail, against a hand-assembled +// guest whose se_last_error hands over fixed bytes, so each way the detail +// can be missing or broken is reachable. What the real guests record is +// tested where they are embedded (encrypt's and auth's error tests). + +// detailAt is where the stub guest keeps the bytes se_last_error hands over. +const detailAt = 1024 + +// lastErrorMode is what the stub guest's se_last_error does. +type lastErrorMode int + +const ( + withLastError lastErrorMode = iota + noLastError + trappingLastError + // outOfRangeLastError hands over a buffer that starts at the end of the + // stub's one page of memory. + outOfRangeLastError + // mistypedLastError exports se_last_error with se_dealloc's type, + // (i32, i32) -> (), which is not the ABI's. + mistypedLastError +) + +// stubGuest assembles a guest with the exports guest.Call drives, and a +// failing export: +// +// (module +// (memory (export "memory") 1) +// (func (export "se_alloc") (param i32) (result i32) i32.const 4096) +// (func (export "se_dealloc") (param i32 i32) +// local.get 0 i32.const 0 local.get 1 memory.fill) +// (func (export "fail") (param i32 i32) (result i64) i64.const ) +// (func (export "se_last_error") (result i64) i64.const ) +// (data (i32.const 1024) "")) +// +// se_dealloc zeroes, as the real guests' does, so a test can see the detail +// was released. With no detail, se_last_error returns zero (nothing +// recorded); under noLastError it is not exported at all, as in a guest +// built before it, and under trappingLastError its body is unreachable. +func stubGuest(status uint32, detail []byte, mode lastErrorMode) []byte { + packed := int64(0) + if len(detail) > 0 { + packed = int64(detailAt)<<32 | int64(len(detail)) + } + if mode == outOfRangeLastError { + packed = int64(1<<16)<<32 | 16 // starts at the end of the one-page memory + } + types := vec( + []byte{0x60, 0x01, 0x7f, 0x01, 0x7f}, // (i32) -> i32 + []byte{0x60, 0x02, 0x7f, 0x7f, 0x00}, // (i32, i32) -> () + []byte{0x60, 0x00, 0x01, 0x7e}, // () -> i64 + []byte{0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7e}, // (i32, i32) -> i64 + ) + funcs := vec([]byte{0}, []byte{1}, []byte{3}, []byte{2}) + memory := vec([]byte{0x00, 0x01}) // min 1 page, no max + exports := [][]byte{ + export("memory", 0x02, 0), + export("se_alloc", 0x00, 0), + export("se_dealloc", 0x00, 1), + export("fail", 0x00, 2), + } + switch mode { + case noLastError: + case mistypedLastError: + exports = append(exports, export("se_last_error", 0x00, 1)) + default: + exports = append(exports, export("se_last_error", 0x00, 3)) + } + lastError := body(append([]byte{0x42}, sleb(packed)...)...) + if mode == trappingLastError { + lastError = body(0x00) // unreachable + } + code := vec( + body(append([]byte{0x41}, sleb(4096)...)...), + body(0x20, 0x00, 0x41, 0x00, 0x20, 0x01, 0xfc, 0x0b, 0x00), + body(append([]byte{0x42}, sleb(int64(status))...)...), + lastError, + ) + segment := append([]byte{0x00, 0x41}, sleb(detailAt)...) + segment = append(segment, 0x0b) + segment = append(segment, uleb(uint64(len(detail)))...) + segment = append(segment, detail...) + module := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + for _, s := range []struct { + id byte + body []byte + }{ + {1, types}, {3, funcs}, {5, memory}, {7, vec(exports...)}, {10, code}, {11, vec(segment)}, + } { + module = append(module, s.id) + module = append(module, uleb(uint64(len(s.body)))...) + module = append(module, s.body...) + } + return module +} + +func uleb(v uint64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v == 0 { + return append(out, b) + } + out = append(out, b|0x80) + } +} + +func sleb(v int64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if (v == 0 && b&0x40 == 0) || (v == -1 && b&0x40 != 0) { + return append(out, b) + } + out = append(out, b|0x80) + } +} + +func vec(items ...[]byte) []byte { + out := uleb(uint64(len(items))) + for _, item := range items { + out = append(out, item...) + } + return out +} + +func export(name string, kind byte, index byte) []byte { + out := append(uleb(uint64(len(name))), name...) + return append(out, kind, index) +} + +// body is a function body with no locals. +func body(code ...byte) []byte { + b := append([]byte{0x00}, code...) + b = append(b, 0x0b) + return append(uleb(uint64(len(b))), b...) +} + +// callStub instantiates a stub guest, calls its failing export through +// guest.Call, and returns the detail's bytes as guest memory holds them +// afterwards, and the error. +func callStub(t *testing.T, status uint32, detail []byte, mode lastErrorMode) ([]byte, error) { + t.Helper() + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + defer func() { _ = rt.Close(ctx) }() + alloc := guest.NewAllocator(guest.BestEffort) + m, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), stubGuest(status, detail, mode), wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiating the stub guest: %v", err) + } + exports := guest.Exports{ + Alloc: m.ExportedFunction("se_alloc"), + Dealloc: m.ExportedFunction("se_dealloc"), + LastError: guest.LastErrorExport(m), + } + if (mode != noLastError && mode != mistypedLastError) != (exports.LastError != nil) { + t.Fatalf("se_last_error exported = %v under mode %d", exports.LastError != nil, mode) + } + _, callErr := guest.Call(ctx, alloc, m, exports, m.ExportedFunction("fail"), guest.BufArg([]byte("input"))) + left, ok := m.Memory().Read(detailAt, uint32(len(detail))) //nolint:gosec // a test detail is small + if !ok { + t.Fatal("reading the stub's memory") + } + return bytes.Clone(left), callErr +} + +func encode(t *testing.T, v any) []byte { + t.Helper() + raw, err := vcffi.Marshal(v) + if err != nil { + t.Fatal(err) + } + return raw +} + +// A failure with detail is a *Diagnostic: the sentinel still matches, the +// message is the Rust one, every field decodes, and the detail is wiped +// from guest memory once read. +func TestAFailureCarriesItsDiagnostic(t *testing.T) { + expected := "00000000-0000-0000-0000-000000000001" + found := "00000000-0000-0000-0000-0000000000ff" + detail := encode(t, map[string]any{ + "code": "stack_encrypt::foreign_keyset", + "message": "ciphertext was sealed under another keyset", + "help": "Open it through the client.", + "url": "https://example.com/errors/foreign_keyset", + "severity": "warning", + "fields": map[string]any{ + "expected": expected, + "found": found, + "field": "email", + "reason": "field_missing", + "count": uint64(3), + "nested": map[string]any{"list": []any{"a", uint64(1)}}, + }, + "causes": []any{ + map[string]any{"code": "stack_kms::keyset_not_found", "message": "inner"}, + map[string]any{"message": "an error from another library"}, + "not a cause", + }, + }) + left, err := callStub(t, guest.StatusForeignKeyset, detail, withLastError) + if !errors.Is(err, guest.ErrForeignKeyset) { + t.Fatalf("err = %v, want it to match ErrForeignKeyset", err) + } + var d *guest.Diagnostic + if !errors.As(err, &d) { + t.Fatalf("err = %#v, want a *Diagnostic", err) + } + if err.Error() != "ciphertext was sealed under another keyset" { + t.Errorf("Error() = %q, want the Rust message", err.Error()) + } + if d.Code != "stack_encrypt::foreign_keyset" || d.Help != "Open it through the client." || + d.URL != "https://example.com/errors/foreign_keyset" || d.Severity != "warning" { + t.Errorf("decoded %+v", d) + } + var wantExpected, wantFound [16]byte + wantExpected[15], wantFound[15] = 0x01, 0xff + if got, ok := d.ExpectedKeyset(); !ok || got != wantExpected { + t.Errorf("ExpectedKeyset() = %x, %v; want %x", got, ok, wantExpected) + } + if got, ok := d.FoundKeyset(); !ok || got != wantFound { + t.Errorf("FoundKeyset() = %x, %v; want %x", got, ok, wantFound) + } + if d.Field() != "email" || d.Reason() != "field_missing" { + t.Errorf("Field() = %q, Reason() = %q", d.Field(), d.Reason()) + } + if d.Fields["count"] != uint64(3) { + t.Errorf("Fields[count] = %#v", d.Fields["count"]) + } + nested, _ := d.Fields["nested"].(map[string]any) + if list, _ := nested["list"].([]any); len(list) != 2 || list[0] != "a" || list[1] != uint64(1) { + t.Errorf("Fields[nested] = %#v, want plain Go values all the way down", d.Fields["nested"]) + } + want := []guest.Cause{ + {Code: "stack_kms::keyset_not_found", Message: "inner"}, + {Message: "an error from another library"}, + } + if len(d.Causes) != len(want) || d.Causes[0] != want[0] || d.Causes[1] != want[1] { + t.Errorf("Causes = %+v, want %+v", d.Causes, want) + } + if !bytes.Equal(left, make([]byte, len(detail))) { + t.Error("the detail was not wiped from guest memory") + } +} + +// The keyset accessors answer only for a foreign-keyset refusal, and only +// with an id that parses. +func TestKeysetAccessorsAnswerOnlyForAForeignKeyset(t *testing.T) { + other := &guest.Diagnostic{Code: "stack_encrypt::keyset_mismatch", Fields: map[string]any{ + "expected": "00000000-0000-0000-0000-000000000001", + }} + if _, ok := other.ExpectedKeyset(); ok { + t.Error("ExpectedKeyset answered for another code") + } + for _, bad := range []any{"not a uuid", "00000000x0000-0000-0000-000000000001", "0000000g-0000-0000-0000-000000000001", uint64(1), nil} { + d := &guest.Diagnostic{Code: "stack_encrypt::foreign_keyset", Fields: map[string]any{"found": bad}} + if _, ok := d.FoundKeyset(); ok { + t.Errorf("FoundKeyset parsed %#v", bad) + } + } + none := &guest.Diagnostic{Fields: map[string]any{}} + if none.Field() != "" || none.Reason() != "" { + t.Error("Field or Reason answered for an error that gives none") + } +} + +// With no detail to give, the failure is the bare sentinel, exactly as +// before the detail existed: a missing explanation never hides the failure. +func TestWithoutDetailTheFailureIsTheBareSentinel(t *testing.T) { + good := encode(t, map[string]any{"code": "stack_guest_abi::status", "message": "failed"}) + cases := []struct { + name string + detail []byte + mode lastErrorMode + }{ + {"a guest built before se_last_error", good, noLastError}, + {"nothing recorded", nil, withLastError}, + {"bytes that are not the codec", []byte{0xff, 0x01}, withLastError}, + {"a value that is not an object", encode(t, "a string"), withLastError}, + {"an object with no message", encode(t, map[string]any{"code": "x::y"}), withLastError}, + {"a message that is not a string", encode(t, map[string]any{"message": uint64(1)}), withLastError}, + {"a buffer past the end of guest memory", good, outOfRangeLastError}, + {"an se_last_error without the ABI's type", good, mistypedLastError}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + left, err := callStub(t, guest.StatusEncoding, c.detail, c.mode) + if err != guest.ErrEncoding { + t.Fatalf("err = %#v, want the bare ErrEncoding", err) + } + if c.mode == withLastError && !bytes.Equal(left, make([]byte, len(c.detail))) { + t.Error("a detail that did not decode was not wiped") + } + }) + } +} + +// An unknown status keeps its number, with or without detail behind it: +// a newer guest's own message for a status it added does not name it. +func TestAnUnknownStatusIsStillInternal(t *testing.T) { + detail := encode(t, map[string]any{"code": "stack_encrypt::new_kind", "message": "a new kind of failure"}) + for _, c := range []struct { + name string + detail []byte + }{{"with detail", detail}, {"without detail", nil}} { + t.Run(c.name, func(t *testing.T) { + _, err := callStub(t, 99, c.detail, withLastError) + if !errors.Is(err, guest.ErrInternal) { + t.Fatalf("err = %#v, want it to match ErrInternal", err) + } + if !strings.Contains(err.Error(), "99") { + t.Errorf("err = %q, want the status number in it", err) + } + var d *guest.Diagnostic + if found := errors.As(err, &d); found != (c.detail != nil) { + t.Fatalf("errors.As found a Diagnostic = %v, want %v", found, c.detail != nil) + } + if d != nil && (d.Code != "stack_encrypt::new_kind" || !strings.Contains(err.Error(), d.Message)) { + t.Errorf("err = %q, Code = %q", err, d.Code) + } + }) + } +} + +// A detail with only some of its keys decodes with the defaults: Severity +// "error", Fields empty and not nil, and a key of the wrong type ignored. +func TestAPartialDetailDecodesWithDefaults(t *testing.T) { + detail := encode(t, map[string]any{ + "message": "failed", + "code": uint64(7), + "fields": "not an object", + }) + _, err := callStub(t, guest.StatusEncoding, detail, withLastError) + var d *guest.Diagnostic + if !errors.As(err, &d) { + t.Fatalf("err = %#v, want a *Diagnostic", err) + } + if d.Severity != "error" || d.Code != "" || d.Fields == nil || len(d.Fields) != 0 || d.Causes != nil { + t.Errorf("decoded %+v", d) + } +} + +// A guest that aborts handing over its detail is in an unknown state: the +// error says it trapped, for the caller to close the instance, and still +// matches the failure it was reporting. +func TestATrapFetchingTheDetailIsATrap(t *testing.T) { + detail := encode(t, map[string]any{"message": "never read"}) + _, err := callStub(t, guest.StatusEncoding, detail, trappingLastError) + if !errors.Is(err, guest.ErrEncoding) || !errors.Is(err, guest.ErrTrap) { + t.Fatalf("err = %v, want it to match ErrEncoding and ErrTrap", err) + } +} diff --git a/languages/golang/internal/guest/doc.go b/languages/golang/internal/guest/doc.go index 6e3af747f..c44e33da5 100644 --- a/languages/golang/internal/guest/doc.go +++ b/languages/golang/internal/guest/doc.go @@ -1,12 +1,12 @@ // Package guest is what the Go packages over the WASI guests share and // neither should own: the locked, non-dumpable memory a guest instance runs -// in; the status table every guest reports through and the errors it -// decodes to; and the opaque client key one package reads and the other -// consumes. +// in; the status table every guest reports through, the errors it decodes +// to and the Diagnostic behind each; and the opaque client key one package +// reads and the other consumes. // -// It sits under internal so that encrypt and auth expose what -// they need of it — the error sentinels, the ClientKey type — as their own -// identifiers (aliases, not copies: an error from either package is the +// It sits under internal so that encrypt and auth expose what they need of +// it — the error sentinels, the Diagnostic and ClientKey types — as their +// own identifiers (aliases, not copies: an error from either package is the // same value, and a key read by one is the type the other takes) without // either package importing the other. A binary that wants only the profile // must not carry the crypto guest, and this is the seam that makes that diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index 43b5a698a..347ec4609 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -3,9 +3,10 @@ package guest import "errors" // Failure kinds a guest surfaces across the boundary. A guest reports a -// status code and nothing else (see StatusError), so these are the whole -// vocabulary: they separate a tampered ciphertext from a bad token from a -// malformed input, and reveal nothing about plaintext or key material. The +// status code (see StatusError), and these are what the codes decode to: +// they separate a tampered ciphertext from a bad token from a malformed +// input, and reveal nothing about plaintext or key material. The detail +// behind a status is a Diagnostic, which unwraps to one of these. The // public packages expose them under their own names; the values are these. var ( // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index 4e3edfcd9..130834b05 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -101,10 +101,21 @@ func StatusError(status uint32) error { case StatusContextMismatch: return ErrContextMismatch default: - return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) + return unrecognizedStatus(status) } } +// unrecognizedStatus is a status this host does not know: an internal +// failure whose text keeps the number, the sign of a guest newer than its +// host. +type unrecognizedStatus uint32 + +func (s unrecognizedStatus) Error() string { + return fmt.Sprintf("%v (unrecognized guest status %d)", ErrInternal, uint32(s)) +} + +func (unrecognizedStatus) Unwrap() error { return ErrInternal } + // PackedResult decodes a guest export's packed u64: a non-zero high half is // an output pointer with the length in the low half; a zero high half // carries a status code in the low half, returned as its sentinel. From 642693613302376d086fe23435c8af844af80a5d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 14:32:47 +1100 Subject: [PATCH 2/5] fix(golang): a Diagnostic's text starts with its sentinel's Error returned the Rust message alone, so a guest failure's text lost the "cipherstash: ..." prefix it had before: a log line no longer said where it came from, and a caller matching on the old text stopped matching. Error now returns the sentinel's text, then the Rust message. The status number of an unknown status is in that prefix, so diagnose no longer wraps one specially. --- languages/golang/auth/errors.go | 7 +++--- languages/golang/auth/strategy_test.go | 2 +- languages/golang/auth/transport.go | 5 ++-- languages/golang/internal/guest/diagnostic.go | 24 +++++++++---------- .../golang/internal/guest/diagnostic_test.go | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/languages/golang/auth/errors.go b/languages/golang/auth/errors.go index c04b568a9..72fc2a7d0 100644 --- a/languages/golang/auth/errors.go +++ b/languages/golang/auth/errors.go @@ -75,9 +75,10 @@ var ( // } // // Its fields are Code ("stack_profile::not_found", "stack_auth::invalid_crn", -// ...; stable), Message (what Error returns), Help, URL, Severity, Fields -// (the structured fields, by name: a profile file's "path", a JSON error's -// "line" and "column") and Causes (the errors behind it, outermost first). +// ...; stable), Message (what Error returns after the sentinel's text), Help, +// URL, Severity, Fields (the structured fields, by name: a profile file's +// "path", a JSON error's "line" and "column") and Causes (the errors behind +// it, outermost first). // // What one may carry is fixed: workspace ids, CRNs and regions, profile // file paths, HTTP statuses and the auth server's error descriptions. diff --git a/languages/golang/auth/strategy_test.go b/languages/golang/auth/strategy_test.go index dea6c0c6e..957bb53a6 100644 --- a/languages/golang/auth/strategy_test.go +++ b/languages/golang/auth/strategy_test.go @@ -673,7 +673,7 @@ func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { if !errors.Is(err, ErrTransport) { t.Fatalf("Token error = %v, want ErrTransport", err) } - if want := "Server error: 403: HTTP 403"; err.Error() != want { + if want := "cipherstash: auth transport failed: Server error: 403: HTTP 403"; err.Error() != want { t.Fatalf("Token error = %q, want %q", err, want) } var d *Diagnostic diff --git a/languages/golang/auth/transport.go b/languages/golang/auth/transport.go index 3454182d5..599f40dab 100644 --- a/languages/golang/auth/transport.go +++ b/languages/golang/auth/transport.go @@ -59,8 +59,9 @@ func withAuthHTTPStatus(ctx context.Context) (context.Context, *authHTTPStatus) } // wrap names the HTTP status of a refused exchange on an ErrTransport -// ("cipherstash: auth transport failed: HTTP 403"). The body is never -// included: it may be an HTML error page, or echo a credential. +// ("cipherstash: auth transport failed: Server error: 403: HTTP 403", the +// guest's Diagnostic then the status). The body is never included: it may +// be an HTML error page, or echo a credential. func (s *authHTTPStatus) wrap(err error) error { if err == nil || !errors.Is(err, ErrTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) { return err diff --git a/languages/golang/internal/guest/diagnostic.go b/languages/golang/internal/guest/diagnostic.go index 032fcb00a..9a8c869e5 100644 --- a/languages/golang/internal/guest/diagnostic.go +++ b/languages/golang/internal/guest/diagnostic.go @@ -3,7 +3,6 @@ package guest import ( "context" "encoding/hex" - "errors" "fmt" "github.com/cipherstash/vitaminc/bindings/go/vcffi" @@ -36,7 +35,8 @@ type Diagnostic struct { // a caller may branch on it; the sentinel Unwrap returns is the coarser // kind. Code string - // Message is the error's one-line message, what Error returns. + // Message is the Rust error's one-line message. Error returns it after + // the sentinel's text. Message string // Help says what to do about it, where the error knows. Help string @@ -64,8 +64,15 @@ type Cause struct { Message string } -// Error returns the Rust error's message. -func (d *Diagnostic) Error() string { return d.Message } +// Error returns the sentinel's text, then the Rust error's message: +// "cipherstash: auth transport failed: Server error: 403", so a log line +// still says which package and which kind of failure it was. +func (d *Diagnostic) Error() string { + if d.kind == nil { + return d.Message + } + return d.kind.Error() + ": " + d.Message +} // Unwrap returns the sentinel the guest's status maps to, so errors.Is // matches the same kinds it matched before the detail existed. @@ -132,11 +139,6 @@ func parseUUID(text string) ([16]byte, bool) { // guest has no se_last_error (one built before it), recorded nothing, or // handed over bytes that do not decode into an error with a message. // -// For a status this host does not know, the Diagnostic comes behind kind's -// own text, so the status number stays in every log line: a newer guest -// records its own error for a status it added, and that error does not -// say the host is too old to know it. -// // A trap in se_last_error leaves the guest in an unknown state, so the // error then also wraps ErrTrap, for the caller to close the instance as // it would after any trap; it still matches kind. @@ -168,10 +170,6 @@ func (e Exports) diagnose(ctx context.Context, m api.Module, kind error) error { return kind } d.kind = kind - var unknown unrecognizedStatus - if errors.As(kind, &unknown) { - return fmt.Errorf("%w: %w", kind, d) - } return d } diff --git a/languages/golang/internal/guest/diagnostic_test.go b/languages/golang/internal/guest/diagnostic_test.go index 7c91d49b1..0948f753c 100644 --- a/languages/golang/internal/guest/diagnostic_test.go +++ b/languages/golang/internal/guest/diagnostic_test.go @@ -225,8 +225,8 @@ func TestAFailureCarriesItsDiagnostic(t *testing.T) { if !errors.As(err, &d) { t.Fatalf("err = %#v, want a *Diagnostic", err) } - if err.Error() != "ciphertext was sealed under another keyset" { - t.Errorf("Error() = %q, want the Rust message", err.Error()) + if want := guest.ErrForeignKeyset.Error() + ": ciphertext was sealed under another keyset"; err.Error() != want { + t.Errorf("Error() = %q, want %q: the sentinel's text, then the Rust message", err.Error(), want) } if d.Code != "stack_encrypt::foreign_keyset" || d.Help != "Open it through the client." || d.URL != "https://example.com/errors/foreign_keyset" || d.Severity != "warning" { From eb80fcb004e4f9ea4695cb01af743b8a83683f01 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 14:32:48 +1100 Subject: [PATCH 3/5] test(golang): a done context still fetches a failure's detail The real guests close their module when the context is done. The stub runtime now does too, and a test calls diagnose under a cancelled context: it fails with a trap if the fetch stops using context.WithoutCancel. --- .../golang/internal/guest/diagnostic_test.go | 31 +++++++++++++++++++ .../golang/internal/guest/export_test.go | 14 +++++++++ 2 files changed, 45 insertions(+) create mode 100644 languages/golang/internal/guest/export_test.go diff --git a/languages/golang/internal/guest/diagnostic_test.go b/languages/golang/internal/guest/diagnostic_test.go index 0948f753c..7b5b0e0fd 100644 --- a/languages/golang/internal/guest/diagnostic_test.go +++ b/languages/golang/internal/guest/diagnostic_test.go @@ -369,3 +369,34 @@ func TestATrapFetchingTheDetailIsATrap(t *testing.T) { t.Fatalf("err = %v, want it to match ErrEncoding and ErrTrap", err) } } + +// The detail is fetched under a context that cannot be cancelled: a +// deadline that passes once the export has returned does not cost the +// caller the detail of a failure already in hand. The real guests close +// their module when the context is done, so the stub runtime does too; a +// fetch under the caller's context would trap instead. +func TestADoneContextStillFetchesTheDetail(t *testing.T) { + ctx := context.Background() + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer func() { _ = rt.Close(ctx) }() + detail := encode(t, map[string]any{"code": "stack_encrypt::foreign_keyset", "message": "sealed under another keyset"}) + m, err := rt.Instantiate(ctx, stubGuest(guest.StatusForeignKeyset, detail, withLastError)) + if err != nil { + t.Fatalf("instantiating the stub guest: %v", err) + } + exports := guest.Exports{ + Alloc: m.ExportedFunction("se_alloc"), + Dealloc: m.ExportedFunction("se_dealloc"), + LastError: guest.LastErrorExport(m), + } + done, cancel := context.WithCancel(ctx) + cancel() + err = exports.Diagnose(done, m, guest.ErrForeignKeyset) + if errors.Is(err, guest.ErrTrap) { + t.Fatalf("err = %v, want the detail, not a trap", err) + } + var d *guest.Diagnostic + if !errors.As(err, &d) || !errors.Is(err, guest.ErrForeignKeyset) || d.Code != "stack_encrypt::foreign_keyset" { + t.Fatalf("err = %#v, want a foreign-keyset Diagnostic", err) + } +} diff --git a/languages/golang/internal/guest/export_test.go b/languages/golang/internal/guest/export_test.go new file mode 100644 index 000000000..e7f741479 --- /dev/null +++ b/languages/golang/internal/guest/export_test.go @@ -0,0 +1,14 @@ +package guest + +import ( + "context" + + "github.com/tetratelabs/wazero/api" +) + +// Diagnose is diagnose, for the external tests: it fetches a failure's +// detail under a context of the test's choosing, with no export failing +// first. +func (e Exports) Diagnose(ctx context.Context, m api.Module, kind error) error { + return e.diagnose(ctx, m, kind) +} From bbaf5901b6d1c449166a152344e46c25271debe8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 14:32:48 +1100 Subject: [PATCH 4/5] docs(golang): how a caller's fake builds a Diagnostic that matches a sentinel --- languages/golang/internal/guest/diagnostic.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/languages/golang/internal/guest/diagnostic.go b/languages/golang/internal/guest/diagnostic.go index 9a8c869e5..603fd6277 100644 --- a/languages/golang/internal/guest/diagnostic.go +++ b/languages/golang/internal/guest/diagnostic.go @@ -29,6 +29,13 @@ import ( // A guest built before se_last_error, or one whose error does not decode, // returns the bare sentinel instead: the detail is never allowed to hide // the failure. +// +// Only a guest makes a Diagnostic that unwraps to a sentinel. A fake in a +// caller's test that needs both wraps the two together, and errors.Is and +// errors.As each find theirs: +// +// d := &encrypt.Diagnostic{Code: "stack_encrypt::foreign_keyset", Message: "..."} +// return fmt.Errorf("%w: %w", encrypt.ErrForeignKeyset, d) type Diagnostic struct { // Code is the error's code, "crate::name": "stack_encrypt::foreign_keyset", // "stack_kms::keyset_not_found", "stack_profile::not_found". Stable, so From 58c7802287fb1207a54e6cf4bad20ed210213d2f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 15:27:38 +1100 Subject: [PATCH 5/5] docs(golang): encrypt's Diagnostic docs say Error puts the sentinel's text first --- languages/golang/encrypt/README.md | 2 +- languages/golang/encrypt/errors.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 4a1073190..bc4a3e155 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -231,7 +231,7 @@ or for stored data. Check an error two ways. `errors.As` with a `*Diagnostic` gives you the detail behind a failure the engine reports: a stable `Code` (`stack_encrypt::foreign_keyset`, `stack_kms::keyset_not_found`, ...), the one-line `Message` that `Error()` -returns, `Help` saying what to do about it, `Fields` (structured values, by +returns after the kind's text, `Help` saying what to do about it, `Fields` (structured values, by name) and `Causes` (the errors behind it). Accessors read the values a program is likely to branch on: diff --git a/languages/golang/encrypt/errors.go b/languages/golang/encrypt/errors.go index fdf873c1c..0a21d54af 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -78,11 +78,11 @@ var ( // // Its fields are Code ("stack_encrypt::foreign_keyset", // "stack_kms::keyset_not_found", ...; stable), Message (what Error -// returns), Help, URL, Severity, Fields (the structured fields, by name) -// and Causes (the errors behind it, outermost first). Accessors read the -// fields a caller branches on: ExpectedKeyset and FoundKeyset on an -// [ErrForeignKeyset] (each a [KeysetID]'s bytes), and Field and Reason on a -// refused plan, record or value. +// returns after the sentinel's text), Help, URL, Severity, Fields (the +// structured fields, by name) and Causes (the errors behind it, outermost +// first). Accessors read the fields a caller branches on: ExpectedKeyset +// and FoundKeyset on an [ErrForeignKeyset] (each a [KeysetID]'s bytes), and +// Field and Reason on a refused plan, record or value. // // What one may carry is fixed: keyset ids and names, field names, counts, // index kinds, ZeroKMS request kinds and HTTP statuses. Never plaintext,