From 59d237d36421dbc59d8ac5c2477c44526edd6339 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:41:34 +0000 Subject: [PATCH 01/22] feat(stack-profile): ErrorPayload, the rule for what an error may contain, and codes for ProfileError Go callers get a status number when a guest call fails, and nothing else (#1098). Carrying more across means every error in stack-profile, stack-auth, stack-kms and stack-encrypt needs a stable code, help and structured fields, and a written rule for what those may hold. This is the first of the four crates, and the one the other three depend on, so the shared pieces live here. `ErrorPayload: miette::Diagnostic` gives an error's structured fields (`payload()`, a serde_json map shaped like stack-auth's `AuthErrorKind::payload`). Its docs carry the rule: keyset ids, counts, field names and the like are allowed; plaintext, key material, tokens, ciphertext and term bytes, and raw context values never are; context descriptors, another library's message and ZeroKMS response bodies are left out by default, each with its reason. `diagnostic::is_code_of` checks a code's shape (`crate::snake_case_name`), and `describe_json_error` renders a serde_json error without quoting input. `ProfileError` derives `miette::Diagnostic` with a `stack_profile::*` code on every variant, listed in `ERROR_CODES` and pinned by a test that builds every variant. `Io` names the I/O error's kind and `Json` gives the error kind, line and column instead of the parser's message: serde_json quotes the value it refused, and `auth.json` holds tokens. Both wrapped errors are still the source. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- Cargo.lock | 1 + languages/golang/auth/guest/Cargo.lock | 1 + languages/golang/encrypt/guest/Cargo.lock | 1 + packages/eql/Cargo.lock | 1 + packages/stack-auth/fuzz/Cargo.lock | 1 + packages/stack-encrypt/fuzz/Cargo.lock | 1 + packages/stack-kms/fuzz/Cargo.lock | 1 + packages/stack-profile/Cargo.toml | 3 + packages/stack-profile/src/diagnostic.rs | 174 ++++++++++++++++++++++ packages/stack-profile/src/error.rs | 147 +++++++++++++++++- packages/stack-profile/src/lib.rs | 4 +- 11 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 packages/stack-profile/src/diagnostic.rs diff --git a/Cargo.lock b/Cargo.lock index 60687ec98..92483bcb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,6 +3360,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "tempfile", diff --git a/languages/golang/auth/guest/Cargo.lock b/languages/golang/auth/guest/Cargo.lock index 921f2836d..ec6a57b95 100644 --- a/languages/golang/auth/guest/Cargo.lock +++ b/languages/golang/auth/guest/Cargo.lock @@ -1878,6 +1878,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index b64caaf00..5ccb84f32 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -2156,6 +2156,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 18f8ac534..2af68e968 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -4301,6 +4301,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index 1bf6c8223..5b3ee6b78 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -2635,6 +2635,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 880a27eff..9bc5c7f35 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2093,6 +2093,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index 93e2a0d9b..ee1344157 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -2003,6 +2003,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 0d59bba2b..b2b57354e 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -10,6 +10,9 @@ homepage.workspace = true [dependencies] dirs = "4.0.0" +# The `Diagnostic` derive on `ProfileError`, and the supertrait of +# `ErrorPayload`, which the four crates whose errors reach a binding share. +miette = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/packages/stack-profile/src/diagnostic.rs b/packages/stack-profile/src/diagnostic.rs new file mode 100644 index 000000000..feba52567 --- /dev/null +++ b/packages/stack-profile/src/diagnostic.rs @@ -0,0 +1,174 @@ +//! The structured fields an error carries, and what an error may contain. + +/// The structured fields an error carries beside its message, its miette +/// [`code`](miette::Diagnostic::code) and its help. +/// +/// Every error in `stack-profile`, `stack-auth`, `stack-kms` and +/// `stack-encrypt` implements it. Those are the crates whose errors reach a +/// caller through a language binding. The trait is defined here because this +/// crate is the one all four depend on, and each of the others re-exports +/// it, so a caller names it from the crate it uses. +/// +/// [`payload`](Self::payload) gives the error's facts as data: which keyset a +/// value was sealed under, which plan field was refused, how long a context +/// was. A binding hands them to its caller beside the code, so the caller can +/// branch on a field instead of parsing a message. Codes are for crossing a +/// boundary: Rust code that needs to branch on an error matches the variant. +/// +/// # What an error may contain +/// +/// The rule covers an error's message (its `Display`), its help and every +/// field of its payload, and every error under it that a binding reports as +/// a cause. The Go guests enforce it with a leak test: every error path a +/// test can reach is driven with marker values, and the test fails if a +/// marker appears anywhere in what the guest encodes. +/// +/// **Allowed:** +/// +/// - keyset ids and names +/// - counts and lengths +/// - term kinds and index names +/// - field names from a plan or a Go struct: these describe the schema, not +/// the data +/// - ZeroKMS request kinds and HTTP status numbers +/// - workspace ids, workspace CRNs and region names +/// - the path of a profile file, which names the store and not its contents +/// - a description the CipherStash token service sends for a person to read +/// (an OAuth `error_description`) +/// - the message of an error this rule also governs: one from these four +/// crates, or from a library whose messages are fixed text, such as +/// `url::ParseError` +/// +/// **Never allowed:** +/// +/// - plaintext, or any part of it +/// - key material: data keys, index keys, client keys +/// - access tokens and refresh tokens +/// - ciphertext bytes and index term bytes +/// - raw context values +/// +/// **Decided, with the reason:** +/// +/// - **Context descriptors are left out.** A context can be built from a +/// record field (`#[stash(context_field)]`), so its descriptor can hold +/// customer data. An error about a context gives the descriptor's length +/// and its number of parts instead. +/// - **An error from another library gives its type, not its message.** That +/// message is text these crates do not control: an HTTP client's error can +/// carry a URL with its query string, and a JSON parser's can quote the +/// input it refused. Where these crates wrap such an error, their message +/// names what failed, and the wrapped error stays reachable through +/// [`source`](std::error::Error::source) for a caller in the same process, +/// who decides what to log. It never appears in a message or a payload. +/// - **A slot any implementation can fill shows that implementation's +/// message**, and the implementation answers for it under this rule. Such +/// a slot is a `Box` a trait implementor hands back: +/// `stack_encrypt::Error::Other` from an `EncryptFrom` or `DecryptInto` +/// implementation, `TargetError::Other` from an EQL type resolver. Their +/// own report ("unsupported EQL ciphertext producer or version") is the +/// one a caller needs, so it is shown as given. An implementation that +/// fills one names what it refused, never a byte of the value or the +/// ciphertext, and does not pass another library's message through. +/// - **ZeroKMS response bodies are left out**, until someone confirms that a +/// ZeroKMS error body never echoes what the request carried. An error from +/// a ZeroKMS request gives the request kind and the HTTP status instead. +/// +/// A field that falls under none of these needs a decision before it is +/// added, recorded here. +pub trait ErrorPayload: miette::Diagnostic { + /// The error's structured fields, keyed by `snake_case` name. Every value + /// obeys the rule above. Empty unless the error has facts worth + /// branching on. + fn payload(&self) -> serde_json::Map { + serde_json::Map::new() + } +} + +/// A payload built from `(name, value)` pairs. +/// +/// Shared by the four crates' [`ErrorPayload`] impls so each reads as a list +/// of fields rather than a map built by hand. +pub fn payload( + fields: [(&str, serde_json::Value); N], +) -> serde_json::Map { + fields + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect() +} + +/// A JSON error as the rule allows it: what kind of error, and where. Never +/// serde_json's own message, which can quote the input it refused: +/// `syntax error at line 1 column 5`. +pub fn describe_json_error(error: &serde_json::Error) -> String { + let kind = match error.classify() { + serde_json::error::Category::Io => "read error", + serde_json::error::Category::Syntax => "syntax error", + serde_json::error::Category::Data => "unexpected data", + serde_json::error::Category::Eof => "unexpected end of input", + }; + format!("{kind} at line {} column {}", error.line(), error.column()) +} + +/// Whether `code` has the shape every code from these crates has: the crate's +/// name, `::`, then a `snake_case` name — `stack_encrypt::foreign_keyset`. +/// +/// Each crate's code test runs every code it can produce through this, so a +/// code in the wrong crate's namespace, or spelled in another case, fails +/// there rather than reaching a binding. +pub fn is_code_of(crate_name: &str, code: &str) -> bool { + let Some(name) = code + .strip_prefix(crate_name) + .and_then(|rest| rest.strip_prefix("::")) + else { + return false; + }; + let mut chars = name.chars(); + chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') + && !name.ends_with('_') + && !name.contains("__") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_code_is_its_crate_then_one_snake_case_name() { + for code in [ + "stack_encrypt::aead", + "stack_encrypt::foreign_keyset", + "stack_kms::keyset_not_found", + "stack_encrypt::v1_leaf", + ] { + let crate_name = code.split("::").next().unwrap_or_default(); + assert!(is_code_of(crate_name, code), "{code}"); + } + for (crate_name, code) in [ + ("stack_encrypt", "stack_kms::aead"), + ("stack_encrypt", "stack_encryptaead"), + ("stack_encrypt", "stack_encrypt::"), + ("stack_encrypt", "stack_encrypt::Aead"), + ("stack_encrypt", "stack_encrypt::foreign-keyset"), + ("stack_encrypt", "stack_encrypt::plan::no_context"), + ("stack_encrypt", "stack_encrypt::_aead"), + ("stack_encrypt", "stack_encrypt::1aead"), + ("stack_encrypt", "stack_encrypt::aead_"), + ("stack_encrypt", "stack_encrypt::foreign__keyset"), + ("stack_auth", "INVALID_CRN"), + ] { + assert!(!is_code_of(crate_name, code), "{code}"); + } + } + + #[test] + fn payload_keeps_every_field() { + let fields = payload([("field", "age".into()), ("count", 2.into())]); + assert_eq!(fields.len(), 2); + assert_eq!(fields["field"], "age"); + assert_eq!(fields["count"], 2); + } +} diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index bb0cad253..40eba24a2 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -1,34 +1,171 @@ use std::path::PathBuf; +use crate::diagnostic::{describe_json_error, payload, ErrorPayload}; + /// Errors that can occur when reading or writing profile files. -#[derive(Debug, thiserror::Error)] +/// +/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES). +/// [`Io`](Self::Io) and [`Json`](Self::Json) wrap another library's error +/// and keep its message out of their own (see [`ErrorPayload`] for the +/// rule): the wrapped error is their [`source`](std::error::Error::source). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum ProfileError { - /// An I/O error occurred while reading or writing a profile file. - #[error("I/O error: {0}")] + /// An I/O error occurred while reading or writing a profile file. The + /// message names the kind of failure; the I/O error itself is the source. + #[error("I/O error: {}", .0.kind())] + #[diagnostic(code(stack_profile::io))] Io(#[from] std::io::Error), - /// A profile file contained invalid JSON. - #[error("JSON error: {0}")] + /// A profile file contained invalid JSON. The message gives where and + /// what kind of error it was, never the parser's own text: that can + /// quote the file, and a profile file can hold a token. + #[error("JSON error: {}", describe_json_error(.0))] + #[diagnostic( + code(stack_profile::json), + help("The profile file is damaged. Log in again with `stash auth login` to rewrite it.") + )] Json(#[from] serde_json::Error), /// The user's home directory could not be determined. #[error("Could not determine home directory")] + #[diagnostic( + code(stack_profile::home_dir_not_found), + help("Set `HOME` (or `USERPROFILE` on Windows), or open the store at an explicit directory with `ProfileStore::new`.") + )] HomeDirNotFound, /// The requested profile file was not found. #[error("Profile not found: {path}")] + #[diagnostic( + code(stack_profile::not_found), + help("Log in with `stash auth login` to create the profile.") + )] NotFound { /// The path that was looked up. path: PathBuf, }, /// The filename is invalid (contains path separators, `..`, or is absolute). #[error("Invalid profile filename: {0}")] + #[diagnostic( + code(stack_profile::invalid_filename), + help("A profile filename is a bare name such as `auth.json`: no directory, no `..`.") + )] InvalidFilename(String), /// No current workspace is set but a workspace-scoped operation was attempted. #[error("No current workspace set. Run `stash login` or `stash workspaces switch` first.")] + #[diagnostic(code(stack_profile::no_current_workspace))] NoCurrentWorkspace, /// The workspace ID is invalid (not a 16-character base32 string). #[error("Invalid workspace ID: {0}")] + #[diagnostic( + code(stack_profile::invalid_workspace_id), + help("A workspace ID is 16 base32 characters, such as `ZVATKW3VHMFG27DY`.") + )] InvalidWorkspaceId(String), /// The workspace has no local profile data (not logged in). #[error("Workspace not found: {0}. Log in to this workspace first.")] + #[diagnostic(code(stack_profile::workspace_not_found))] WorkspaceNotFound(String), } + +impl ErrorPayload for ProfileError { + fn payload(&self) -> serde_json::Map { + match self { + Self::Io(error) => payload([("io_kind", format!("{:?}", error.kind()).into())]), + Self::Json(error) => payload([ + ("line", error.line().into()), + ("column", error.column().into()), + ]), + Self::NotFound { path } => payload([("path", path.display().to_string().into())]), + Self::InvalidFilename(filename) => payload([("filename", filename.as_str().into())]), + Self::InvalidWorkspaceId(workspace) | Self::WorkspaceNotFound(workspace) => { + payload([("workspace_id", workspace.as_str().into())]) + } + Self::HomeDirNotFound | Self::NoCurrentWorkspace => serde_json::Map::new(), + } + } +} + +/// Every miette code [`ProfileError`] can carry. A test builds every variant +/// and checks its code is here, so renaming a code means editing this list +/// on purpose. +pub const ERROR_CODES: &[&str] = &[ + "stack_profile::io", + "stack_profile::json", + "stack_profile::home_dir_not_found", + "stack_profile::not_found", + "stack_profile::invalid_filename", + "stack_profile::no_current_workspace", + "stack_profile::invalid_workspace_id", + "stack_profile::workspace_not_found", +]; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use miette::Diagnostic; + + use super::*; + use crate::diagnostic::is_code_of; + + /// One of every variant, so the code test covers them all. + fn every_variant() -> Vec { + vec![ + ProfileError::Io(std::io::Error::other("disk")), + serde_json::from_str::("\"secret\"") + .map_err(ProfileError::Json) + .unwrap_err(), + ProfileError::HomeDirNotFound, + ProfileError::NotFound { + path: "auth.json".into(), + }, + ProfileError::InvalidFilename("../x".into()), + ProfileError::NoCurrentWorkspace, + ProfileError::InvalidWorkspaceId("short".into()), + ProfileError::WorkspaceNotFound("AAAAAAAAAAAAAAAA".into()), + ] + } + + #[test] + fn every_variant_has_a_listed_code() { + let mut seen = BTreeSet::new(); + for error in every_variant() { + let code = error + .code() + .unwrap_or_else(|| panic!("{error:?} has no code")) + .to_string(); + assert!(is_code_of("stack_profile", &code), "{code}"); + assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); + seen.insert(code); + } + let listed: BTreeSet = ERROR_CODES.iter().map(|c| c.to_string()).collect(); + assert_eq!(seen, listed, "every listed code is produced by a variant"); + } + + /// serde_json quotes the value it refused; a profile file can hold a + /// token, so neither the message nor the payload may carry that text. + #[test] + fn a_json_error_does_not_quote_the_file() { + let error = serde_json::from_str::("\"marker-token\"") + .map_err(ProfileError::Json) + .unwrap_err(); + assert!( + std::error::Error::source(&error) + .is_some_and(|source| source.to_string().contains("marker-token")), + "the parser's own message is still the source", + ); + let shown = format!("{error} {:?}", error.payload()); + assert!(!shown.contains("marker-token"), "{shown}"); + assert!(shown.contains("line 1"), "{shown}"); + } + + #[test] + fn an_io_error_names_its_kind_not_its_message() { + let error = ProfileError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "marker-text", + )); + let shown = format!("{error} {:?}", error.payload()); + assert!(!shown.contains("marker-text"), "{shown}"); + assert_eq!(error.payload()["io_kind"], "PermissionDenied"); + } +} diff --git a/packages/stack-profile/src/lib.rs b/packages/stack-profile/src/lib.rs index 2dc30841b..e6b80ad40 100644 --- a/packages/stack-profile/src/lib.rs +++ b/packages/stack-profile/src/lib.rs @@ -63,11 +63,13 @@ use serde::de::DeserializeOwned; use serde::Serialize; mod device_identity; +pub mod diagnostic; mod error; mod profile_store; pub use device_identity::DeviceIdentity; -pub use error::ProfileError; +pub use diagnostic::ErrorPayload; +pub use error::{ProfileError, ERROR_CODES}; pub use profile_store::{FileLockGuard, ProfileStore}; /// A type that can be stored in a profile directory. From fb75f40d541df8158154b86ec5b7708aefe66836 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:41:51 +0000 Subject: [PATCH 02/22] feat(stack-auth): miette codes beside the frozen error codes Every stack-auth error now carries a path-style miette code (`stack_auth::invalid_crn`), the one source of truth #1099 settles on for what crosses a binding. The frozen codes stay exactly as they were: `error_code()`, the `INVALID_CRN`-style strings and `from_error_code` are the `type` of the published TypeScript `AuthFailure`, and two of them are what the token service sends back. A test builds every `AuthError` variant and pins its frozen code and its miette code side by side, so the two cannot drift; another checks every code an error here can produce is in the new crate-level `ERROR_CODES`. `InvalidAccessKey` gets its own four codes. `StoreError` is diagnostic-transparent: a profile failure that comes through the auth path carries `stack_profile::not_found` and the profile error's help, the same as one straight from the store, while its frozen code stays `STORE_ERROR`. `DeviceClientError` carries the code of the `AuthError` each variant converts into. `AuthError` implements the shared `ErrorPayload` with the fields `AuthErrorKind::payload` already gives. Messages follow the rule now written next to `ErrorPayload`: `RequestError` no longer repeats the transport's message (it can carry a URL with its query string) and makes it the source instead; a token whose claims do not decode no longer quotes the decoder's message; a failed device binding gives ZeroKMS's status, not its response body. Help is added where a caller can act. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- .changeset/auth-error-codes-and-help.md | 12 + languages/typescript/packages/auth/src/lib.rs | 17 +- packages/stack-auth/src/access_key.rs | 16 +- packages/stack-auth/src/device_client.rs | 31 +- packages/stack-auth/src/error.rs | 373 ++++++++++++++++-- packages/stack-auth/src/lib.rs | 23 +- packages/stack-auth/src/transport.rs | 7 +- 7 files changed, 441 insertions(+), 38 deletions(-) create mode 100644 .changeset/auth-error-codes-and-help.md diff --git a/.changeset/auth-error-codes-and-help.md b/.changeset/auth-error-codes-and-help.md new file mode 100644 index 000000000..ef2b8a636 --- /dev/null +++ b/.changeset/auth-error-codes-and-help.md @@ -0,0 +1,12 @@ +--- +"@cipherstash/auth": patch +--- + +Auth failures carry more help, and their messages never quote a credential or another library's text. A failure's `type` (`NOT_AUTHENTICATED`, `INVALID_CRN`, ...) is unchanged. + +- `REQUEST_ERROR`'s message no longer repeats the transport's own error, which can carry a URL with its query string. It gains `help` saying what to check. +- `INVALID_TOKEN` for a token whose claims do not decode no longer quotes the decoder's message, which could carry a byte or a claim of the token. +- A failed device binding reports ZeroKMS's status, not its response body. +- A profile file that is not valid JSON is reported by error kind, line and column, not by the parser's message, which could quote the file. +- `INVALID_GRANT`, `INVALID_WORKSPACE_ID` and `ALREADY_CONSUMED` gain `help`, and `NOT_AUTHENTICATED`'s help names `stash auth login`. +- A `STORE_ERROR` carries the help of the profile failure underneath it, such as logging in again when the profile file is missing. diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 30a0eded7..67bd19b9c 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -954,9 +954,9 @@ mod tests { // `device_client_to_napi_error` routes every `DeviceClientError` through // its canonical `AuthError` mapping. A help-carrying error (here an // `Auth`-wrapped `WorkspaceMismatch`) must keep its help + structured - // payload; a help-less one (`Profile` -> `Store`) yields just - // type + message. A regression that dropped the canonical routing would - // lose the help/payload here. + // payload; a `Profile` -> `Store` one carries the profile error's + // help. A regression that dropped the canonical routing would lose + // the help/payload here. #[test] fn device_client_auth_arm_preserves_full_envelope() { let ws = |s: &str| s.parse::().unwrap(); @@ -976,15 +976,20 @@ mod tests { ); // Non-Auth variant routes through `From` to the - // canonical `Store` error: same `STORE_ERROR` code, and no help - // (StoreError carries none). + // canonical `Store` error: same `STORE_ERROR` code, and the + // profile error's help, which `StoreError` forwards. let err = device_client_to_napi_error(DeviceClientError::Profile( stack_profile::ProfileError::HomeDirNotFound, )); let json = assertions::failure_json(&err); assert_eq!(json["type"], "STORE_ERROR"); assert!(json["message"].as_str().is_some()); - assert!(json.get("help").is_none()); + assert!( + json["help"] + .as_str() + .is_some_and(|help| help.contains("HOME")), + "a store failure carries the profile error's help, got: {json}" + ); } } diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index cef3285eb..dfce44464 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -67,22 +67,36 @@ impl FromStr for AccessKey { } /// Error returned when parsing an invalid access key string. -#[derive(Debug, thiserror::Error)] +/// +/// No variant quotes the string it refused: an access key is a credential. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum InvalidAccessKey { /// The string does not start with the `CSAK` prefix. #[error("access key must start with \"{ACCESS_KEY_PREFIX}\"")] + #[diagnostic( + code(stack_auth::access_key_missing_prefix), + help("Access keys have the form `CSAK.`.") + )] MissingPrefix, /// No `.` separator found between key ID and secret. #[error("access key must contain a \".\" separator")] + #[diagnostic( + code(stack_auth::access_key_missing_dot), + help("Access keys have the form `CSAK.`.") + )] MissingDot, /// The key ID portion (before the `.`) is empty. #[error("access key ID must not be empty")] + #[diagnostic(code(stack_auth::access_key_empty_id))] EmptyKeyId, /// The secret portion (after the `.`) is empty. #[error("access key secret must not be empty")] + #[diagnostic(code(stack_auth::access_key_empty_secret))] EmptySecret, } +impl stack_profile::ErrorPayload for InvalidAccessKey {} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 699fd72a9..9173a30f9 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -5,7 +5,7 @@ //! orchestration logic so that any consumer (not just the CLI) can perform //! this step. -use stack_profile::{DeviceIdentity, ProfileStore}; +use stack_profile::{DeviceIdentity, ErrorPayload, ProfileStore}; use uuid::Uuid; use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest}; @@ -36,29 +36,54 @@ struct SecretKeyFile { // --------------------------------------------------------------------------- /// Errors that can occur during device client provisioning. -#[derive(Debug, thiserror::Error)] +/// +/// Each variant carries the miette code of the [`AuthError`](crate::AuthError) +/// it converts into, so a binding reports the same code either way. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum DeviceClientError { /// The profile store could not load or create required data. #[error("Profile error: {0}")] + #[diagnostic(transparent)] Profile(#[from] stack_profile::ProfileError), /// Authentication token could not be loaded or decoded. #[error("Auth error: {0}")] + #[diagnostic(transparent)] Auth(#[from] crate::AuthError), /// The HTTP request to ZeroKMS failed, or its response did not decode. #[error("ZeroKMS request failed: {0}")] + #[diagnostic(transparent)] Request(#[from] RequestError), /// ZeroKMS returned a non-success, non-conflict status. - #[error("ZeroKMS returned {status}: {body}")] + /// + /// The message gives the status alone. `body` is ZeroKMS's response + /// text, kept for a caller in this process to inspect; no message or + /// payload carries it (see [`ErrorPayload`] for the rule). + #[error("ZeroKMS returned {status}")] + #[diagnostic(code(stack_auth::server_error))] Server { status: u16, body: String }, /// Failed to construct the ZeroKMS endpoint URL. #[error("Invalid ZeroKMS URL: {0}")] + #[diagnostic(code(stack_auth::invalid_url))] InvalidUrl(#[from] url::ParseError), } +impl ErrorPayload for DeviceClientError { + fn payload(&self) -> serde_json::Map { + match self { + Self::Profile(error) => error.payload(), + Self::Auth(error) => error.payload(), + Self::Request(_) | Self::InvalidUrl(_) => serde_json::Map::new(), + Self::Server { status, .. } => { + stack_profile::diagnostic::payload([("status", (*status).into())]) + } + } + } +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index d6acaf824..ada467fdb 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -14,12 +14,19 @@ use std::convert::Infallible; use cts_common::protocol::{CS_CODE_ORG_NOT_PROVISIONED, CS_CODE_USAGE_LIMIT_EXCEEDED}; +use stack_profile::diagnostic::ErrorPayload; + use crate::access_key; /// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant. /// /// Implemented by the per-error structs so each owns its FFI code and any /// structured payload; [`AuthError`] dispatches to it via `AuthError::kind`. +/// +/// [`error_code`](Self::error_code) is the frozen code the TypeScript +/// bindings publish (`INVALID_CRN`), kept unchanged beside the miette +/// [`code`](miette::Diagnostic::code) (`stack_auth::invalid_crn`) every error +/// also carries; [`ERROR_CODES`](crate::ERROR_CODES) lists the miette codes. pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { /// Stable machine-readable identifier surfaced across FFI boundaries /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with @@ -83,9 +90,21 @@ pub(crate) mod codes { /// use reported — the bundled one's `reqwest::Error`, or a host transport's /// own — or the encoder's or decoder's error for a body that did not /// serialize or parse. +/// +/// The transport's own message is not part of this one: it is text this +/// crate does not control, and an HTTP client's error can carry a URL with +/// its query string (see [`ErrorPayload`] for the rule). The transport's +/// error is the [`source`](std::error::Error::source), for a caller in the +/// same process to log. #[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[error("Request to the auth server failed: {0}")] -pub struct RequestError(pub Box); +#[error("Request to the auth server failed")] +#[diagnostic( + code(stack_auth::request_error), + help( + "The auth server could not be reached, or its response could not be read. Check the network path to it; the transport's error is this error's source." + ) +)] +pub struct RequestError(#[source] pub Box); impl AuthErrorKind for RequestError { fn error_code(&self) -> &'static str { codes::REQUEST_ERROR @@ -95,6 +114,7 @@ impl AuthErrorKind for RequestError { /// The user denied the authorization request. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Authorization was denied")] +#[diagnostic(code(stack_auth::access_denied))] pub struct AccessDenied; impl AuthErrorKind for AccessDenied { fn error_code(&self) -> &'static str { @@ -105,6 +125,10 @@ impl AuthErrorKind for AccessDenied { /// The grant type was rejected by the server. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid grant")] +#[diagnostic( + code(stack_auth::invalid_grant), + help("The credential was refused. Log in again with `stash auth login`, or use a current access key.") +)] pub struct InvalidGrant; impl AuthErrorKind for InvalidGrant { fn error_code(&self) -> &'static str { @@ -115,6 +139,7 @@ impl AuthErrorKind for InvalidGrant { /// The client ID is not recognized. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid client")] +#[diagnostic(code(stack_auth::invalid_client))] pub struct InvalidClient; impl AuthErrorKind for InvalidClient { fn error_code(&self) -> &'static str { @@ -125,6 +150,7 @@ impl AuthErrorKind for InvalidClient { /// A URL could not be parsed. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid URL: {0}")] +#[diagnostic(code(stack_auth::invalid_url))] pub struct InvalidUrl(pub url::ParseError); impl AuthErrorKind for InvalidUrl { fn error_code(&self) -> &'static str { @@ -135,7 +161,10 @@ impl AuthErrorKind for InvalidUrl { /// The requested region is not supported. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Unsupported region: {0}")] -#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))] +#[diagnostic( + code(stack_auth::invalid_region), + help("Use a supported region, e.g. `ap-southeast-2.aws`.") +)] pub struct UnsupportedRegion(pub cts_common::RegionError); impl AuthErrorKind for UnsupportedRegion { fn error_code(&self) -> &'static str { @@ -146,9 +175,12 @@ impl AuthErrorKind for UnsupportedRegion { /// The workspace CRN could not be parsed. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid workspace CRN: {0}")] -#[diagnostic(help( - "A workspace CRN looks like `crn::`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." -))] +#[diagnostic( + code(stack_auth::invalid_crn), + help( + "A workspace CRN looks like `crn::`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." + ) +)] pub struct InvalidCrn(pub cts_common::InvalidCrn); impl AuthErrorKind for InvalidCrn { fn error_code(&self) -> &'static str { @@ -161,9 +193,12 @@ impl AuthErrorKind for InvalidCrn { /// a different workspace, or when the wrong CRN was passed. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] -#[diagnostic(help( - "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." -))] +#[diagnostic( + code(stack_auth::workspace_mismatch), + help( + "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." + ) +)] pub struct WorkspaceMismatch { /// The workspace the strategy was configured for (from the CRN). pub expected_workspace: cts_common::WorkspaceId, @@ -193,6 +228,10 @@ impl AuthErrorKind for WorkspaceMismatch { /// The workspace ID could not be parsed. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid workspace ID: {0}")] +#[diagnostic( + code(stack_auth::invalid_workspace_id), + help("A workspace ID is 16 base32 characters, such as `ZVATKW3VHMFG27DY`.") +)] pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId); impl AuthErrorKind for InvalidWorkspaceId { fn error_code(&self) -> &'static str { @@ -205,9 +244,12 @@ impl AuthErrorKind for InvalidWorkspaceId { #[error( "Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn" )] -#[diagnostic(help( - "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." -))] +#[diagnostic( + code(stack_auth::missing_workspace_crn), + help( + "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." + ) +)] pub struct MissingWorkspaceCrn; impl AuthErrorKind for MissingWorkspaceCrn { fn error_code(&self) -> &'static str { @@ -218,9 +260,12 @@ impl AuthErrorKind for MissingWorkspaceCrn { /// No credentials are available (e.g. not logged in, no access key configured). #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Not authenticated")] -#[diagnostic(help( - "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." -))] +#[diagnostic( + code(stack_auth::not_authenticated), + help( + "Log in with `stash auth login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." + ) +)] pub struct NotAuthenticated; impl AuthErrorKind for NotAuthenticated { fn error_code(&self) -> &'static str { @@ -231,6 +276,7 @@ impl AuthErrorKind for NotAuthenticated { /// A token (access token or device code) has expired. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Token expired")] +#[diagnostic(code(stack_auth::expired_token))] pub struct TokenExpired; impl AuthErrorKind for TokenExpired { fn error_code(&self) -> &'static str { @@ -241,7 +287,10 @@ impl AuthErrorKind for TokenExpired { /// The access key string is malformed (e.g. missing `CSAK` prefix or `.`). #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid access key: {0}")] -#[diagnostic(help("Access keys have the form `CSAK.`."))] +#[diagnostic( + code(stack_auth::invalid_access_key), + help("Access keys have the form `CSAK.`.") +)] pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey); impl AuthErrorKind for InvalidAccessKeyError { fn error_code(&self) -> &'static str { @@ -252,6 +301,7 @@ impl AuthErrorKind for InvalidAccessKeyError { /// The JWT could not be decoded or its claims are malformed. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Invalid token: {0}")] +#[diagnostic(code(stack_auth::invalid_token))] pub struct InvalidToken(pub String); impl AuthErrorKind for InvalidToken { fn error_code(&self) -> &'static str { @@ -273,6 +323,7 @@ impl AuthErrorKind for InvalidToken { #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] #[diagnostic( + code(stack_auth::usage_limit_exceeded), help( "The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry." ), @@ -294,6 +345,7 @@ impl UsageLimitExceeded { #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] #[diagnostic( + code(stack_auth::org_not_provisioned), help( "The organisation is not set up for usage tracking. Contact CipherStash support — retrying and upgrading the plan will both fail." ), @@ -322,6 +374,7 @@ impl AuthErrorKind for UsageLimitExceeded { /// An unexpected error was returned by the auth server. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Server error: {0}")] +#[diagnostic(code(stack_auth::server_error))] pub struct ServerError(pub String); impl AuthErrorKind for ServerError { fn error_code(&self) -> &'static str { @@ -335,6 +388,10 @@ impl AuthErrorKind for ServerError { /// across the FFI boundary. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Handle already consumed")] +#[diagnostic( + code(stack_auth::already_consumed), + help("A device-code poll can be awaited once. Start a new device-code flow.") +)] pub struct AlreadyConsumed; impl AuthErrorKind for AlreadyConsumed { fn error_code(&self) -> &'static str { @@ -347,6 +404,7 @@ impl AuthErrorKind for AlreadyConsumed { /// boundary as a `Result` failure. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Internal error: {0}")] +#[diagnostic(code(stack_auth::internal_error))] pub struct InternalError(pub String); impl AuthErrorKind for InternalError { fn error_code(&self) -> &'static str { @@ -367,6 +425,7 @@ impl AuthErrorKind for InternalError { /// on the failure code must handle it. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] +#[diagnostic(code(stack_auth::custom))] pub struct CustomError(pub String); impl AuthErrorKind for CustomError { fn error_code(&self) -> &'static str { @@ -375,8 +434,15 @@ impl AuthErrorKind for CustomError { } /// A token store operation failed. +/// +/// Diagnostic-transparent: its code, help and payload are the +/// [`ProfileError`](stack_profile::ProfileError)'s, so a caller across a +/// binding sees `stack_profile::not_found` whether a profile failure came +/// through the auth path or straight from the store. Its old code, +/// `STORE_ERROR`, is unchanged. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Token store error: {0}")] +#[diagnostic(transparent)] pub struct StoreError(pub stack_profile::ProfileError); impl AuthErrorKind for StoreError { fn error_code(&self) -> &'static str { @@ -869,6 +935,56 @@ impl serde::Serialize for AuthError { } } +/// The same fields [`AuthErrorKind::payload`] gives the TypeScript bindings, +/// save for a store failure, whose fields are the profile error's: a store +/// failure is diagnostic-transparent ([`StoreError`]). +impl ErrorPayload for AuthError { + fn payload(&self) -> serde_json::Map { + match self { + Self::Store(StoreError(profile)) => profile.payload(), + _ => self.kind().payload(), + } + } +} + +/// Every miette code an error from this crate can carry: [`AuthError`]'s +/// twenty own codes and [`InvalidAccessKey`](crate::InvalidAccessKey)'s +/// four. `DeviceClientError` adds none: each of its variants carries the +/// code of the [`AuthError`] it converts into. A store failure carries a +/// `stack_profile` code ([`StoreError`]), so it is not here. +/// +/// Not [`AuthError::ERROR_CODES`]: that is the frozen list of `INVALID_CRN` +/// style codes the TypeScript bindings publish. A test maps each +/// [`AuthError`] miette code to its frozen code, so the two cannot drift, +/// and another builds every variant and checks its code is here, so renaming +/// a code means editing this list on purpose. +pub const ERROR_CODES: &[&str] = &[ + "stack_auth::request_error", + "stack_auth::access_denied", + "stack_auth::invalid_grant", + "stack_auth::invalid_client", + "stack_auth::invalid_url", + "stack_auth::invalid_region", + "stack_auth::invalid_crn", + "stack_auth::workspace_mismatch", + "stack_auth::invalid_workspace_id", + "stack_auth::missing_workspace_crn", + "stack_auth::not_authenticated", + "stack_auth::expired_token", + "stack_auth::invalid_access_key", + "stack_auth::invalid_token", + "stack_auth::usage_limit_exceeded", + "stack_auth::org_not_provisioned", + "stack_auth::server_error", + "stack_auth::already_consumed", + "stack_auth::internal_error", + "stack_auth::custom", + "stack_auth::access_key_missing_prefix", + "stack_auth::access_key_missing_dot", + "stack_auth::access_key_empty_id", + "stack_auth::access_key_empty_secret", +]; + // --------------------------------------------------------------------------- // Ergonomic `From` impls — keep `?` working where call sites lift a // foreign error straight into `AuthError` (the per-struct wrapping is internal). @@ -929,8 +1045,11 @@ impl From for AuthError { E::Auth(e) => e, E::Request(e) => e.into(), E::InvalidUrl(e) => e.into(), - E::Server { status, body } => { - Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}"))) + // The body stays on the `DeviceClientError` for a caller in this + // process; it is ZeroKMS response text, which no message carries + // (see `ErrorPayload` for the rule). + E::Server { status, .. } => { + Self::Server(ServerError(format!("ZeroKMS returned {status}"))) } } } @@ -1368,6 +1487,205 @@ mod classify_issuance_failure_tests { mod tests { use super::*; + /// One of every [`AuthError`] variant, with the frozen code and the + /// miette code it must carry. A store failure's miette code is its + /// profile error's, so its row names the `stack_profile` code. + fn every_variant() -> Vec<(AuthError, &'static str, &'static str)> { + let workspace = |id: &str| id.parse::().unwrap(); + vec![ + ( + RequestError(Box::new(std::io::Error::other("refused"))).into(), + codes::REQUEST_ERROR, + "stack_auth::request_error", + ), + ( + AccessDenied.into(), + codes::ACCESS_DENIED, + "stack_auth::access_denied", + ), + ( + InvalidGrant.into(), + codes::INVALID_GRANT, + "stack_auth::invalid_grant", + ), + ( + InvalidClient.into(), + codes::INVALID_CLIENT, + "stack_auth::invalid_client", + ), + ( + "not a url".parse::().unwrap_err().into(), + codes::INVALID_URL, + "stack_auth::invalid_url", + ), + ( + "nowhere".parse::().unwrap_err().into(), + codes::INVALID_REGION, + "stack_auth::invalid_region", + ), + ( + "not a crn".parse::().unwrap_err().into(), + codes::INVALID_CRN, + "stack_auth::invalid_crn", + ), + ( + WorkspaceMismatch { + expected_workspace: workspace("ZVATKW3VHMFG27DY"), + token_workspace: workspace("AAAAAAAAAAAAAAAA"), + } + .into(), + codes::WORKSPACE_MISMATCH, + "stack_auth::workspace_mismatch", + ), + ( + "short" + .parse::() + .unwrap_err() + .into(), + codes::INVALID_WORKSPACE_ID, + "stack_auth::invalid_workspace_id", + ), + ( + MissingWorkspaceCrn.into(), + codes::MISSING_WORKSPACE_CRN, + "stack_auth::missing_workspace_crn", + ), + ( + NotAuthenticated.into(), + codes::NOT_AUTHENTICATED, + "stack_auth::not_authenticated", + ), + ( + TokenExpired.into(), + codes::EXPIRED_TOKEN, + "stack_auth::expired_token", + ), + ( + "".parse::() + .unwrap_err() + .into(), + codes::INVALID_ACCESS_KEY, + "stack_auth::invalid_access_key", + ), + ( + InvalidToken("malformed".into()).into(), + codes::INVALID_TOKEN, + "stack_auth::invalid_token", + ), + ( + UsageLimitExceeded("over".into()).into(), + codes::USAGE_LIMIT_EXCEEDED, + "stack_auth::usage_limit_exceeded", + ), + ( + OrgNotProvisioned("unknown".into()).into(), + codes::ORG_NOT_PROVISIONED, + "stack_auth::org_not_provisioned", + ), + ( + ServerError("boom".into()).into(), + codes::SERVER_ERROR, + "stack_auth::server_error", + ), + ( + AlreadyConsumed.into(), + codes::ALREADY_CONSUMED, + "stack_auth::already_consumed", + ), + ( + InternalError("poisoned".into()).into(), + codes::INTERNAL_ERROR, + "stack_auth::internal_error", + ), + ( + CustomError("custom".into()).into(), + codes::CUSTOM, + "stack_auth::custom", + ), + ( + stack_profile::ProfileError::NotFound { + path: "auth.json".into(), + } + .into(), + codes::STORE_ERROR, + "stack_profile::not_found", + ), + ] + } + + /// The frozen codes and the miette codes are two names for one error: + /// every variant carries the pair in its row, so a code changed on one + /// side and not the other fails here. Every frozen code has a row. + #[test] + fn every_miette_code_maps_to_its_frozen_code() { + use miette::Diagnostic; + let mut frozen = std::collections::BTreeSet::new(); + for (error, old, new) in every_variant() { + assert_eq!(error.error_code(), old, "{error:?}"); + assert_eq!( + error.code().map(|code| code.to_string()).as_deref(), + Some(new), + "{error:?}" + ); + frozen.insert(old); + } + assert_eq!( + frozen, + AuthError::ERROR_CODES.iter().copied().collect(), + "every frozen code has a row" + ); + } + + /// Every code an error from this crate produces is in [`ERROR_CODES`], + /// in this crate's namespace, and every listed code is produced. + #[test] + fn every_variant_has_a_listed_code() { + use miette::Diagnostic; + use stack_profile::diagnostic::is_code_of; + let mut errors: Vec> = every_variant() + .into_iter() + .map(|(error, _, _)| Box::new(error) as Box) + .collect(); + for key in ["CSAK", "nope", "CSAK.secret", "CSAKid."] { + errors.push(Box::new( + key.parse::().unwrap_err(), + )); + } + let mut seen = std::collections::BTreeSet::new(); + for error in &errors { + let code = error + .code() + .unwrap_or_else(|| panic!("{error:?} has no code")) + .to_string(); + if code.starts_with("stack_profile::") { + assert!( + stack_profile::ERROR_CODES.contains(&code.as_str()), + "{code}" + ); + continue; + } + assert!(is_code_of("stack_auth", &code), "{code}"); + assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); + seen.insert(code); + } + let listed: std::collections::BTreeSet = + ERROR_CODES.iter().map(|code| code.to_string()).collect(); + assert_eq!(seen, listed, "every listed code is produced"); + } + + /// A store failure's payload is the profile error's, so a binding + /// reports the same fields whichever path the failure came through. + #[test] + fn a_store_failure_carries_the_profile_payload() { + let error = AuthError::from(stack_profile::ProfileError::WorkspaceNotFound( + "AAAAAAAAAAAAAAAA".into(), + )); + assert_eq!(error.payload()["workspace_id"], "AAAAAAAAAAAAAAAA"); + // The TypeScript serialization is unchanged: no profile fields. + let json = serde_json::to_value(&error).unwrap(); + assert!(json.get("workspace_id").is_none(), "{json}"); + } + #[test] fn profile_error_retains_store_type() { let err = AuthError::from(stack_profile::ProfileError::NotFound { @@ -1532,8 +1850,13 @@ mod tests { ))))); assert_eq!(request.error_code(), codes::REQUEST_ERROR); assert!( - request.to_string().contains("connection refused"), - "{request}" + !request.to_string().contains("connection refused"), + "the transport's message stays out of the message: {request}" + ); + assert!( + std::error::Error::source(&request) + .is_some_and(|source| source.to_string().contains("connection refused")), + "the transport's error is the source: {request:?}" ); // Non-`Auth` variants route to their canonical `AuthError` equivalent. assert_eq!( @@ -1547,12 +1870,16 @@ mod tests { ); let server = AuthError::from(E::Server { status: 500, - body: "boom".to_string(), + body: "marker-body".to_string(), }); assert_eq!(server.error_code(), codes::SERVER_ERROR); assert!( - server.to_string().contains("ZeroKMS returned 500: boom"), - "server error should preserve the status/body detail: {server}" + server.to_string().contains("ZeroKMS returned 500"), + "server error should keep the status: {server}" + ); + assert!( + !server.to_string().contains("marker-body"), + "a ZeroKMS response body stays out of the message: {server}" ); } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index d4176da3f..506454fb0 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -77,12 +77,21 @@ mod oidc_refresher; mod refresher; pub use error::StoreError; +pub use error::ERROR_CODES; pub use error::{ AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, OrgNotProvisioned, RequestError, ServerError, TokenExpired, UnsupportedRegion, UsageLimitExceeded, WorkspaceMismatch, }; +/// [`ErrorPayload`]'s module: the payload and code-shape helpers the four +/// crates share. +pub use stack_profile::diagnostic; +/// The trait every error from this crate implements to hand over its +/// structured fields, and the rule for what an error may contain. Defined in +/// `stack-profile`, the crate all four of `stack-profile`, `stack-auth`, +/// `stack-kms` and `stack-encrypt` share. +pub use stack_profile::ErrorPayload; // Filesystem-backed device identity and the interactive device-code flow are // native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) @@ -408,14 +417,20 @@ where "JWT must have three segments".to_string(), ))); } + // Neither decoder's own message is passed on: base64's names a byte of + // the token, and serde_json's can quote the claim it refused. A token is + // a credential (see `ErrorPayload` for the rule). let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(segments[1]) - .map_err(|e| { - AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}"))) + .map_err(|_| { + AuthError::InvalidToken(error::InvalidToken( + "the JWT's claims segment is not base64url".to_string(), + )) })?; serde_json::from_slice(&payload).map_err(|e| { AuthError::InvalidToken(error::InvalidToken(format!( - "failed to decode JWT claims: {e}" + "failed to decode JWT claims: {}", + stack_profile::diagnostic::describe_json_error(&e) ))) }) } @@ -654,7 +669,7 @@ mod tests { ), ( AuthError::NotAuthenticated(crate::error::NotAuthenticated), - "stash login", + "stash auth login", ), ( AuthError::from("".parse::().unwrap_err()), diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index 8a9436d1d..eecc2ee2d 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -910,8 +910,13 @@ mod tests { transport, ); let err = refresher.refresh(&()).await.unwrap_err(); + // The transport's message is the source, never part of the message. match err { - AuthError::Request(e) => assert!(e.to_string().contains("connection refused")), + AuthError::Request(e) => { + assert!(!e.to_string().contains("connection refused"), "{e}"); + assert!(std::error::Error::source(&e) + .is_some_and(|source| source.to_string().contains("connection refused"))); + } other => panic!("{other:?}"), } } From 5bed15854c97af4c204c413b60f7465fbe9c7051 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:42:08 +0000 Subject: [PATCH 03/22] feat(stack-kms): miette codes, help and structured fields on every error stack-kms already derived `miette::Diagnostic` but gave no codes and no help, so a ZeroKMS failure reached a binding as its message alone. Every error here now has a `stack_kms::*` code (`stack_kms::keyset_not_found`), listed in `ERROR_CODES` and pinned by a test that builds every variant; the top-level `Error` and `StackKmsBuilderError` forward the code of what they carry. Each implements the shared `ErrorPayload`: a failed ZeroKMS request gives its request kind and a count mismatch both counts, and nothing from a response body. `KeysetNotFound`'s help says what its source has always said, that ZeroKMS answers 404 for an unknown client too. Messages follow the rule next to `ErrorPayload`: `FailedRetrieval` keeps ZeroKMS's per-key reason on the variant but out of the message; `GenerateIv` and `ConnectionInitError` no longer repeat another library's message (it is the source); and `InvalidEndpoint` no longer echoes the URL it refused, which can still carry credentials or a query string at the point those checks run. `diagnostic` and `ErrorPayload` are re-exported here, so stack-encrypt reaches them through this crate. The stack-kms fuzz lockfile also gains the `sha2` dependency stack-auth already declares, which it was missing. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-kms/fuzz/Cargo.lock | 1 + packages/stack-kms/src/builder.rs | 25 ++ packages/stack-kms/src/client.rs | 5 +- packages/stack-kms/src/connection/classify.rs | 24 +- packages/stack-kms/src/connection/http.rs | 9 +- packages/stack-kms/src/endpoint.rs | 33 +- packages/stack-kms/src/errors.rs | 326 +++++++++++++++++- packages/stack-kms/src/key_provider.rs | 16 +- packages/stack-kms/src/lib.rs | 6 + 9 files changed, 431 insertions(+), 14 deletions(-) diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index ee1344157..a18c2eba9 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1948,6 +1948,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", + "sha2", "stack-profile", "thiserror 1.0.69", "tokio", diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs index 30c05923a..7d1bca258 100644 --- a/packages/stack-kms/src/builder.rs +++ b/packages/stack-kms/src/builder.rs @@ -9,23 +9,30 @@ use stack_auth::{AuthStrategy, AuthStrategyBounds}; use thiserror::Error; /// Error type for [`StackKmsBuilder`] operations. +/// +/// The variants that carry another error from this crate or `stack-auth` +/// are diagnostic-transparent: their code and help are that error's. #[derive(Debug, Error, miette::Diagnostic)] pub enum StackKmsBuilderError { /// Failed to initialize the underlying client. #[error("Failed to initialize client: {0}")] + #[diagnostic(transparent)] ClientInit(#[from] crate::errors::Error), /// Authentication strategy failed to initialize. #[error("Auth strategy error: {0}")] + #[diagnostic(transparent)] Auth(#[from] stack_auth::AuthError), /// Key provider failed to load a client key. #[error("Key provider error: {0}")] + #[diagnostic(transparent)] KeyProvider(#[from] KeyProviderError), /// A builder option was set to an invalid value (e.g. a zero concurrency /// or keys-per-request limit). #[error(transparent)] + #[diagnostic(transparent)] InvalidConfig(#[from] InvalidClientOpts), /// The ZeroKMS endpoint in the named environment variable is not usable. @@ -33,13 +40,31 @@ pub enum StackKmsBuilderError { /// token's `services` claim would silently send key operations somewhere /// the operator did not configure. #[error("Invalid ZeroKMS endpoint in {env_var}: {source}")] + #[diagnostic( + code(stack_kms::invalid_endpoint), + help("Set {env_var} to an `http://` or `https://` URL with a host and no query, or unset it to use the endpoint the token names.") + )] InvalidEndpoint { env_var: &'static str, #[source] + #[diagnostic_source] source: InvalidEndpoint, }, } +impl stack_auth::ErrorPayload for StackKmsBuilderError { + fn payload(&self) -> serde_json::Map { + match self { + Self::ClientInit(error) => error.payload(), + Self::Auth(error) => error.payload(), + Self::KeyProvider(_) | Self::InvalidConfig(_) => serde_json::Map::new(), + Self::InvalidEndpoint { env_var, .. } => { + stack_auth::diagnostic::payload([("env_var", (*env_var).into())]) + } + } + } +} + /// A builder for creating [`StackKms`] clients. /// /// A [`ClientKey`] is **required** — key generation and retrieval can't happen diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 887d6f604..6b8d0bc6d 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -25,10 +25,13 @@ pub const DEFAULT_CONCURRENT_REQS: usize = 5; /// Returned when a [`ClientOpts`] limit is set to a value the client can't /// operate with (currently: a zero `max_keys_per_req` or `max_concurrent_reqs`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[error("Invalid client options: {0}")] +#[diagnostic(code(stack_kms::invalid_client_opts))] pub struct InvalidClientOpts(&'static str); +impl stack_auth::ErrorPayload for InvalidClientOpts {} + /// Options for configuring certain behaviours of the [`Client`]. /// // The builder is the `http` feature's entry point; without it the host diff --git a/packages/stack-kms/src/connection/classify.rs b/packages/stack-kms/src/connection/classify.rs index 5f141c942..1743ec158 100644 --- a/packages/stack-kms/src/connection/classify.rs +++ b/packages/stack-kms/src/connection/classify.rs @@ -25,18 +25,25 @@ use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; /// Classified as a request-*preparation* error, never an authentication /// failure — a caller that read it as a 401 would refresh its token and retry /// forever against what is really a configuration problem. -#[derive(Debug, Error)] +#[derive(Debug, Error, miette::Diagnostic)] #[error("ZeroKMS base URL was not resolved from the token's `services` claim")] +#[diagnostic( + code(stack_kms::base_url_unresolved), + help("Configure the ZeroKMS endpoint explicitly (`CS_ZEROKMS_HOST`), or use a token whose services claim names ZeroKMS.") +)] pub struct BaseUrlUnresolved; +impl stack_auth::ErrorPayload for BaseUrlUnresolved {} + /// A 2xx response whose `Content-Type` is not JSON — typically a proxy or load /// balancer answering with an HTML error page. /// /// `Display` carries only what was received and expected: the body and headers /// are attacker-influenced and unbounded, and this type's `Display` reaches /// logs. They stay available through `Debug` for structured inspection. -#[derive(Debug, Error)] +#[derive(Debug, Error, miette::Diagnostic)] #[error("Received '{received:?}', expected '{expected}'")] +#[diagnostic(code(stack_kms::unexpected_content_type))] #[non_exhaustive] pub struct UnexpectedContentType { pub received: Option, @@ -50,8 +57,9 @@ pub struct UnexpectedContentType { /// `Display` is the status alone, for the same reason as /// [`UnexpectedContentType`]: body and headers are unbounded server text that /// must not be pulled into a log line. `Debug` still carries them. -#[derive(Debug, Error)] +#[derive(Debug, Error, miette::Diagnostic)] #[error("Status: {status}")] +#[diagnostic(code(stack_kms::failure_response))] #[non_exhaustive] pub struct FailureResponse { pub status: u16, @@ -59,6 +67,16 @@ pub struct FailureResponse { pub headers: HashMap, } +/// The content type alone is a header value, kept out like the body. +impl stack_auth::ErrorPayload for UnexpectedContentType {} + +/// The status alone: the body is ZeroKMS response text. +impl stack_auth::ErrorPayload for FailureResponse { + fn payload(&self) -> serde_json::Map { + stack_auth::diagnostic::payload([("status", self.status.into())]) + } +} + /// `true` if a `content-type` header value denotes JSON, ignoring any /// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and /// API gateways commonly normalise the header that way. diff --git a/packages/stack-kms/src/connection/http.rs b/packages/stack-kms/src/connection/http.rs index e8f693607..ff556e56d 100644 --- a/packages/stack-kms/src/connection/http.rs +++ b/packages/stack-kms/src/connection/http.rs @@ -18,10 +18,15 @@ use zerokms_protocol::{ViturRequest, ViturRequestError}; #[cfg(not(target_arch = "wasm32"))] const REQUEST_TIMEOUT_SECS: u64 = 10; -#[derive(Debug, Error)] -#[error("Failed to initialize HTTP connection: {0}")] +/// The HTTP client could not be built. reqwest's message is not repeated +/// (it is another library's text); it is this error's source. +#[derive(Debug, Error, miette::Diagnostic)] +#[error("Failed to initialize HTTP connection")] +#[diagnostic(code(stack_kms::http_client_init))] pub struct ConnectionInitError(#[from] reqwest::Error); +impl stack_auth::ErrorPayload for ConnectionInitError {} + pub struct HttpConnectionOpts { base_url: Option, request_timeout: Option, diff --git a/packages/stack-kms/src/endpoint.rs b/packages/stack-kms/src/endpoint.rs index 6654e2aaf..57f44be8f 100644 --- a/packages/stack-kms/src/endpoint.rs +++ b/packages/stack-kms/src/endpoint.rs @@ -5,31 +5,58 @@ use thiserror::Error; use url::Url; /// Why a URL was rejected as a [`ZeroKmsEndpoint`]. -#[derive(Debug, Error, PartialEq, Eq)] +/// +/// No message repeats the URL it refused: [`NoHost`](Self::NoHost) and +/// [`QueryOrFragment`](Self::QueryOrFragment) are checked before +/// [`Userinfo`](Self::Userinfo), so the URL they hold can still carry +/// credentials, and a query string can carry anything. The URL stays in the +/// variant for a caller in this process. +#[derive(Debug, Error, PartialEq, Eq, miette::Diagnostic)] pub enum InvalidEndpoint { /// The value did not parse as a URL at all. #[error("not a valid URL: {0}")] + #[diagnostic(code(stack_kms::endpoint_not_url))] Parse(#[from] url::ParseError), /// The URL parsed but has no authority — `localhost:8080` parses as scheme /// `localhost`, path `8080` — so no request path could ever be joined to it. - #[error("`{0}` has no host; is the `http://` or `https://` prefix missing?")] + #[error("the URL has no host; is the `http://` or `https://` prefix missing?")] + #[diagnostic(code(stack_kms::endpoint_no_host))] NoHost(String), /// Only `http` and `https` can reach ZeroKMS. #[error("unsupported scheme `{0}`; expected `http` or `https`")] + #[diagnostic(code(stack_kms::endpoint_scheme))] Scheme(String), /// Request URLs are built by joining an endpoint path onto the base, which /// discards any query or fragment — so accepting one would silently drop it. - #[error("query strings and fragments are not supported on a ZeroKMS endpoint: `{0}`")] + #[error("query strings and fragments are not supported on a ZeroKMS endpoint")] + #[diagnostic(code(stack_kms::endpoint_query_or_fragment))] QueryOrFragment(String), /// Credentials belong in the bearer token, never in the URL. #[error("userinfo is not supported on a ZeroKMS endpoint")] + #[diagnostic( + code(stack_kms::endpoint_userinfo), + help("Remove the user and password from the URL: ZeroKMS takes the credential as a bearer token.") + )] Userinfo, } +impl stack_auth::ErrorPayload for InvalidEndpoint { + fn payload(&self) -> serde_json::Map { + match self { + Self::Scheme(scheme) => { + stack_auth::diagnostic::payload([("scheme", scheme.as_str().into())]) + } + Self::Parse(_) | Self::NoHost(_) | Self::QueryOrFragment(_) | Self::Userinfo => { + serde_json::Map::new() + } + } + } +} + /// A validated ZeroKMS base URL. /// /// Construction is the one place URL hygiene happens, so everything downstream diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index a736ecfa5..b382def61 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -1,26 +1,53 @@ use miette::Diagnostic; +use stack_auth::diagnostic::{payload, ErrorPayload}; use thiserror::Error; use vitaminc::random::RandomError; use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; +/// The fields a failed ZeroKMS request contributes: the request kind +/// (`NotFound`, `SendRequest`, ...), and nothing from the response body. +fn request_payload(error: &ViturRequestError) -> serde_json::Map { + payload([("request_kind", format!("{:?}", error.kind).into())]) +} + +/// The fields of a key-count mismatch. +fn count_payload(expected: usize, received: usize) -> serde_json::Map { + payload([("expected", expected.into()), ("received", received.into())]) +} + /// Key material returned by ZeroKMS failed up-front validation before key /// derivation — e.g. a truncated or corrupt response whose material is not the /// exact length the keyset's block permutation covers. The material is /// network-supplied, so this must surface as an error, never a panic. #[derive(Diagnostic, Error, Debug)] #[error("Invalid keyset key material: {0}")] +#[diagnostic(code(stack_kms::invalid_key_material))] pub struct InvalidKeyMaterialError(#[from] pub recipher::errors::RecipherError); +impl ErrorPayload for InvalidKeyMaterialError {} + #[derive(Diagnostic, Error, Debug)] pub enum RetrieveKeyError { + // `ViturRequestError`'s Display is its kind and a static message; the + // response it carries stays behind `source()`. #[error("Failed to send request: {0}")] + #[diagnostic(code(stack_kms::retrieve_key_failed))] RequestFailed(#[from] ViturRequestError), #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + #[diagnostic(code(stack_kms::retrieved_key_count))] InvalidNumberOfKeys { expected: usize, received: usize }, /// Represents an error that occurs when a single key retrieval fails. /// May be part of a batch retrieval operation. - #[error("Failed to retrieve key: {0}")] + /// + /// The string is ZeroKMS's own reason for the one key, kept for a caller + /// in this process to inspect. It is response text, so the message does + /// not repeat it (see [`ErrorPayload`] for the rule). + #[error("Failed to retrieve key")] + #[diagnostic( + code(stack_kms::key_not_retrieved), + help("ZeroKMS returned no data key for this value. Check it is opened under the context it was sealed with.") + )] FailedRetrieval(String), #[error(transparent)] @@ -31,12 +58,23 @@ pub enum RetrieveKeyError { #[derive(Diagnostic, Error, Debug)] pub enum GenerateKeyError { #[error("Request not authorized")] + #[diagnostic( + code(stack_kms::generate_key_unauthorized), + help("ZeroKMS refused the access token. Refresh the credential and retry.") + )] Unauthorized, #[error("Request forbidden due to insufficient permissions")] + #[diagnostic( + code(stack_kms::generate_key_forbidden), + help("The client is not allowed to generate keys in this keyset: check the keyset's grants, and that it is enabled.") + )] Forbidden, - #[error("Failed to generate IV: {0}")] - GenerateIv(RandomError), + // The random source's own message is not repeated; it is the source. + #[error("Failed to generate IV")] + #[diagnostic(code(stack_kms::generate_iv))] + GenerateIv(#[source] RandomError), #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + #[diagnostic(code(stack_kms::generated_key_count))] InvalidNumberOfKeys { expected: usize, received: usize }, #[error(transparent)] @@ -52,9 +90,32 @@ pub enum GenerateKeyError { // underlying transport / response error; the Display string itself // stays free of dynamic data. #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + #[diagnostic(code(stack_kms::generate_key_failed))] RequestFailed(#[source] ViturRequestError), } +impl ErrorPayload for RetrieveKeyError { + fn payload(&self) -> serde_json::Map { + match self { + Self::RequestFailed(error) => request_payload(error), + Self::InvalidNumberOfKeys { expected, received } => count_payload(*expected, *received), + Self::FailedRetrieval(_) => serde_json::Map::new(), + Self::InvalidKeyMaterial(error) => error.payload(), + } + } +} + +impl ErrorPayload for GenerateKeyError { + fn payload(&self) -> serde_json::Map { + match self { + Self::RequestFailed(error) => request_payload(error), + Self::InvalidNumberOfKeys { expected, received } => count_payload(*expected, *received), + Self::InvalidKeyMaterial(error) => error.payload(), + Self::Unauthorized | Self::Forbidden | Self::GenerateIv(_) => serde_json::Map::new(), + } + } +} + impl From for GenerateKeyError { fn from(err: ViturRequestError) -> Self { match err.kind { @@ -74,8 +135,16 @@ pub enum LoadKeysetError { // static (no dynamic data); the distinguishing server response is // reachable through `source()`. #[error("Request not authorized")] + #[diagnostic( + code(stack_kms::load_keyset_unauthorized), + help("ZeroKMS refused the access token. Refresh the credential and retry.") + )] Unauthorized(#[source] ViturRequestError), #[error("Request forbidden due to insufficient permissions")] + #[diagnostic( + code(stack_kms::load_keyset_forbidden), + help("The client is not granted this keyset, or the keyset is disabled.") + )] Forbidden(#[source] ViturRequestError), // `load-keyset` uniquely takes a caller-supplied keyset id or name, so a // server 404 is an expected, user-actionable outcome — e.g. a typo'd @@ -84,6 +153,10 @@ pub enum LoadKeysetError { // not prove the named keyset is missing: inspect `source()` for the // server's response body before treating this as "create the keyset". #[error("Keyset not found (or the client is unknown or has no default keyset)")] + #[diagnostic( + code(stack_kms::keyset_not_found), + help("ZeroKMS answers 404 when no keyset has this id or name, and also when the client is unknown or has no default keyset. Check the client ID and the keyset's name before creating a keyset.") + )] KeysetNotFound(#[source] ViturRequestError), #[error(transparent)] #[diagnostic(transparent)] @@ -91,9 +164,22 @@ pub enum LoadKeysetError { // Same shape as `GenerateKeyError::RequestFailed`: Display carries only the // static kind/message; the dynamic error stays behind `source()`. #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + #[diagnostic(code(stack_kms::load_keyset_failed))] RequestFailed(#[source] ViturRequestError), } +impl ErrorPayload for LoadKeysetError { + fn payload(&self) -> serde_json::Map { + match self { + Self::Unauthorized(error) + | Self::Forbidden(error) + | Self::KeysetNotFound(error) + | Self::RequestFailed(error) => request_payload(error), + Self::InvalidKeyMaterial(error) => error.payload(), + } + } +} + impl From for LoadKeysetError { fn from(err: ViturRequestError) -> Self { match err.kind { @@ -105,6 +191,214 @@ impl From for LoadKeysetError { } } +/// Every miette code an error from this crate can carry. The variants that +/// carry a `stack_auth` error carry its code instead, so those are in +/// `stack_auth::ERROR_CODES`. A test builds every variant and checks its code +/// is here, so renaming a code means editing this list on purpose. +pub const ERROR_CODES: &[&str] = &[ + "stack_kms::invalid_key_material", + "stack_kms::retrieve_key_failed", + "stack_kms::retrieved_key_count", + "stack_kms::key_not_retrieved", + "stack_kms::generate_key_unauthorized", + "stack_kms::generate_key_forbidden", + "stack_kms::generate_iv", + "stack_kms::generated_key_count", + "stack_kms::generate_key_failed", + "stack_kms::load_keyset_unauthorized", + "stack_kms::load_keyset_forbidden", + "stack_kms::keyset_not_found", + "stack_kms::load_keyset_failed", + "stack_kms::connection_init", + "stack_kms::invalid_endpoint", + "stack_kms::unexpected", + "stack_kms::endpoint_not_url", + "stack_kms::endpoint_no_host", + "stack_kms::endpoint_scheme", + "stack_kms::endpoint_query_or_fragment", + "stack_kms::endpoint_userinfo", + "stack_kms::client_key_not_configured", + "stack_kms::invalid_client_key", + "stack_kms::client_key_load", + "stack_kms::invalid_client_opts", + "stack_kms::base_url_unresolved", + "stack_kms::unexpected_content_type", + "stack_kms::failure_response", + "stack_kms::http_client_init", +]; + +#[cfg(test)] +mod codes { + use std::collections::BTreeSet; + + use stack_auth::diagnostic::is_code_of; + + use super::*; + use crate::connection::{BaseUrlUnresolved, FailureResponse, UnexpectedContentType}; + use crate::endpoint::InvalidEndpoint; + use crate::key_provider::KeyProviderError; + + fn vitur(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "boom", std::io::Error::other("detail")) + } + + fn material() -> InvalidKeyMaterialError { + recipher::errors::RecipherError::InvalidInputLength { + expected: 32, + received: 3, + } + .into() + } + + /// One of every variant of every error type here. A transparent + /// variant is built once, to show the code it forwards is listed + /// somewhere. + fn every_variant() -> Vec> { + let mut errors: Vec> = vec![ + Box::new(material()), + Box::new(RetrieveKeyError::RequestFailed(vitur( + ViturRequestErrorKind::SendRequest, + ))), + Box::new(RetrieveKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1, + }), + Box::new(RetrieveKeyError::FailedRetrieval("no key".into())), + Box::new(RetrieveKeyError::InvalidKeyMaterial(material())), + Box::new(GenerateKeyError::Unauthorized), + Box::new(GenerateKeyError::Forbidden), + Box::new(GenerateKeyError::GenerateIv(RandomError::GenerationFailed)), + Box::new(GenerateKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1, + }), + Box::new(GenerateKeyError::InvalidKeyMaterial(material())), + Box::new(GenerateKeyError::RequestFailed(vitur( + ViturRequestErrorKind::Other, + ))), + Box::new(LoadKeysetError::Unauthorized(vitur( + ViturRequestErrorKind::Unauthorized, + ))), + Box::new(LoadKeysetError::Forbidden(vitur( + ViturRequestErrorKind::Forbidden, + ))), + Box::new(LoadKeysetError::KeysetNotFound(vitur( + ViturRequestErrorKind::NotFound, + ))), + Box::new(LoadKeysetError::InvalidKeyMaterial(material())), + Box::new(LoadKeysetError::RequestFailed(vitur( + ViturRequestErrorKind::Conflict, + ))), + Box::new(Error::GenerateKey(GenerateKeyError::Forbidden)), + Box::new(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( + "no key".into(), + ))), + Box::new(Error::LoadKeyset(LoadKeysetError::KeysetNotFound(vitur( + ViturRequestErrorKind::NotFound, + )))), + Box::new(Error::Auth(stack_auth::AuthError::TokenExpired( + stack_auth::TokenExpired, + ))), + Box::new(Error::ConnectionInit(Box::new(std::io::Error::other("no")))), + Box::new(Error::InvalidEndpoint(InvalidEndpoint::Userinfo)), + Box::new(Error::Unexpected("unexpected".into())), + Box::new(InvalidEndpoint::Parse(url::ParseError::EmptyHost)), + Box::new(InvalidEndpoint::NoHost("localhost:8080".into())), + Box::new(InvalidEndpoint::Scheme("ftp".into())), + Box::new(InvalidEndpoint::QueryOrFragment("https://x/?q".into())), + Box::new(InvalidEndpoint::Userinfo), + Box::new(KeyProviderError::NotConfigured("unset".into())), + Box::new(KeyProviderError::InvalidKey("not hex".into())), + Box::new(KeyProviderError::LoadError("disk".into())), + Box::new(BaseUrlUnresolved), + Box::new(UnexpectedContentType { + received: Some("text/html".into()), + expected: "application/json", + body: None, + headers: Default::default(), + }), + Box::new(FailureResponse { + status: 500, + body: None, + headers: Default::default(), + }), + ]; + if let Err(error) = crate::ClientOpts::new(()).with_max_keys_per_req(0) { + errors.push(Box::new(error)); + } + #[cfg(feature = "http")] + { + use crate::builder::StackKmsBuilderError; + let reqwest_error = reqwest::Client::new() + .get("not a url") + .build() + .expect_err("not a URL"); + errors.push(Box::new(crate::ConnectionInitError::from(reqwest_error))); + errors.push(Box::new(StackKmsBuilderError::InvalidEndpoint { + env_var: "CS_ZEROKMS_HOST", + source: InvalidEndpoint::Userinfo, + })); + errors.push(Box::new(StackKmsBuilderError::ClientInit( + Error::Unexpected("x".into()), + ))); + errors.push(Box::new(StackKmsBuilderError::KeyProvider( + KeyProviderError::NotConfigured("unset".into()), + ))); + } + errors + } + + #[test] + fn every_variant_has_a_listed_code() { + let mut seen = BTreeSet::new(); + for error in every_variant() { + let code = error + .code() + .unwrap_or_else(|| panic!("{error:?} has no code")) + .to_string(); + if code.starts_with("stack_auth::") { + assert!(stack_auth::ERROR_CODES.contains(&code.as_str()), "{code}"); + continue; + } + assert!(is_code_of("stack_kms", &code), "{code}"); + assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); + seen.insert(code); + } + // `http_client_init` needs the `http` feature to be built. + let listed: BTreeSet = ERROR_CODES + .iter() + .filter(|code| cfg!(feature = "http") || **code != "stack_kms::http_client_init") + .map(|code| code.to_string()) + .collect(); + assert_eq!(seen, listed, "every listed code is produced"); + } + + /// A ZeroKMS failure gives its request kind, never the response it + /// carried: the detail behind `source()` stays out of the payload too. + #[test] + fn a_request_failure_gives_the_kind_and_not_the_response() { + let error = Error::LoadKeyset(LoadKeysetError::KeysetNotFound(vitur( + ViturRequestErrorKind::NotFound, + ))); + let fields = error.payload(); + assert_eq!(fields["request_kind"], "NotFound"); + assert!(!format!("{fields:?}").contains("detail"), "{fields:?}"); + assert!(error + .help() + .is_some_and(|help| help.to_string().contains("client is unknown"))); + } + + #[test] + fn no_endpoint_message_repeats_the_url() { + for error in [ + InvalidEndpoint::NoHost("marker://user:pass@".into()), + InvalidEndpoint::QueryOrFragment("https://x/?marker".into()), + ] { + assert!(!error.to_string().contains("marker"), "{error}"); + } + } +} + /// Shared scaffolding for the `From` mapping tests below: /// one place for the fixture error and the assertions both mappings need, so /// a new error type doesn't copy another 80 lines. @@ -293,12 +587,36 @@ pub enum Error { /// initialise. Boxed because the error type belongs to whichever /// connection the client was built over. #[error("Failed to initialize the ZeroKMS connection")] + #[diagnostic(code(stack_kms::connection_init))] ConnectionInit(#[source] Box), /// The ZeroKMS endpoint named by the token's `services` claim is unusable. #[error("Invalid ZeroKMS endpoint in the token's services claim: {0}")] - InvalidEndpoint(#[from] crate::endpoint::InvalidEndpoint), + #[diagnostic( + code(stack_kms::invalid_endpoint), + help("Configure the ZeroKMS endpoint explicitly (`CS_ZEROKMS_HOST`), or use a token whose services claim names a usable one.") + )] + InvalidEndpoint( + #[from] + #[diagnostic_source] + crate::endpoint::InvalidEndpoint, + ), #[error("Unexpected error: {0}")] + #[diagnostic(code(stack_kms::unexpected))] Unexpected(String), } + +impl ErrorPayload for Error { + fn payload(&self) -> serde_json::Map { + match self { + Self::GenerateKey(error) => error.payload(), + Self::RetrieveKey(error) => error.payload(), + Self::LoadKeyset(error) => error.payload(), + Self::Auth(error) => error.payload(), + Self::ConnectionInit(_) | Self::InvalidEndpoint(_) | Self::Unexpected(_) => { + serde_json::Map::new() + } + } + } +} diff --git a/packages/stack-kms/src/key_provider.rs b/packages/stack-kms/src/key_provider.rs index c64f2a664..2e87ba760 100644 --- a/packages/stack-kms/src/key_provider.rs +++ b/packages/stack-kms/src/key_provider.rs @@ -41,23 +41,37 @@ use crate::key::ClientKey; use crate::secret_key::decode_client_key_material; /// Errors that can occur when loading a [`ClientKey`] from a [`KeyProvider`]. -#[derive(Debug, Error)] +/// +/// The strings are written by this crate and never hold key material: a +/// decoding failure names the encoding, not the bytes it refused. +#[derive(Debug, Error, miette::Diagnostic)] pub enum KeyProviderError { /// The provider has no key configured (e.g. env vars not set). /// /// [`FallbackKeyProvider`] uses this variant to decide whether to try the next provider. #[error("Client key not configured: {0}")] + #[diagnostic( + code(stack_kms::client_key_not_configured), + help("Set `CS_CLIENT_ID` and `CS_CLIENT_KEY`, or log in with `stash auth login`.") + )] NotConfigured(String), /// Key material was found but is invalid (e.g. bad hex encoding). #[error("Invalid client key: {0}")] + #[diagnostic( + code(stack_kms::invalid_client_key), + help("`CS_CLIENT_KEY` is a hex or base64 client key, and `CS_CLIENT_ID` the UUID of the client it belongs to.") + )] InvalidKey(String), /// An I/O or other runtime error prevented loading the key. #[error("Failed to load client key: {0}")] + #[diagnostic(code(stack_kms::client_key_load))] LoadError(String), } +impl stack_auth::ErrorPayload for KeyProviderError {} + /// A source of [`ClientKey`] credentials for ZeroKMS. /// /// Implementations must be `Send + Sync + 'static` so they can be stored in the builder diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index eda5e5839..4cf0af70c 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -121,7 +121,13 @@ pub use maybe_send::MaybeSend; // Errors pub use errors::{ Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, RetrieveKeyError, + ERROR_CODES, }; +/// The trait every error from this crate implements to hand over its +/// structured fields, with the rule for what an error may contain, and the +/// helpers that go with it. Shared by `stack-profile`, `stack-auth`, +/// `stack-kms` and `stack-encrypt`. +pub use stack_auth::{diagnostic, ErrorPayload}; // Key material pub use key::{ClientKey, DataKey, DataKeyWithTag, IndexKey, V1KeySet}; From 031f1d7a15eaeb292945aaa0286b3c7a836d24e3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:42:39 +0000 Subject: [PATCH 04/22] feat(stack-encrypt)!: codes, help and fields on every error; dynamic input errors name the field stack-encrypt was the one crate in the chain with no miette support: its errors were plain thiserror enums with no codes and no help, and the dynamic module's input errors were unit variants that could not say which field was wrong. A Go caller got "malformed input" for a bad plan, a record that did not fit it, an empty context and an over-long one alike. Every error type here now derives `miette::Diagnostic` with a `stack_encrypt::*` code, listed in `ERROR_CODES` and pinned by a test that builds every variant: `Error`, `PlanError`, `LabelError`, `LeafBytesError`, `sem::TermError`, `sem::TermBytesError`, and with `dynamic`, `dynamic::Error` and `dynamic::TargetError`. Help is added where a caller can act (`ForeignKeyset`, `DescriptorTooLong`, `EmptyTermText`, the plan refusals), and each implements the shared `ErrorPayload`: both keyset ids of a `ForeignKeyset`, the field and expected type of a `FieldType`, a descriptor's length against its limit. `Error::Kms`, `Term` and `Plan` forward the code of what they carry; `Kms` is now transparent outright, so a ZeroKMS keyset-not-found reads as itself. BREAKING CHANGE: `dynamic::Error::Context`, `Plan`, `Source` and `Record` are struct variants carrying `field: Option` and a `Reason` from the new fixed `dynamic::Reason` enum (`MissingContext`, `DuplicateOutput`, `FieldMissing`, `NoCiphertextNode`, ...), and `Term` gains `field`. Every site that raised one now names the field it knows, plan builder and engine refusals included; `in_field` names it from the caller's side for functions that never see one. `Error::Kms` loses its message prefix. `ContextMismatch` gives the stored context's length and part count rather than its descriptor, which a context field can fill with customer data, and `TermError::Prf` and `MatchPositionOutOfRange` stop repeating another library's message and a value read from term bytes. The Go guest's status mapping matches the new shapes; its numbers are unchanged. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- Cargo.lock | 1 + languages/golang/encrypt/guest/Cargo.lock | 2 + languages/golang/encrypt/guest/src/status.rs | 44 +- packages/eql/Cargo.lock | 2 + packages/stack-encrypt/CHANGELOG.md | 40 + packages/stack-encrypt/Cargo.toml | 6 +- packages/stack-encrypt/fuzz/Cargo.lock | 2 + packages/stack-encrypt/src/cipher.rs | 142 ++- packages/stack-encrypt/src/codes.rs | 344 +++++++ packages/stack-encrypt/src/descriptor.rs | 30 +- packages/stack-encrypt/src/dynamic/context.rs | 19 +- packages/stack-encrypt/src/dynamic/kind.rs | 20 +- packages/stack-encrypt/src/dynamic/mod.rs | 433 +++++++- packages/stack-encrypt/src/dynamic/record.rs | 952 ++++++++++++++---- packages/stack-encrypt/src/dynamic/target.rs | 95 +- packages/stack-encrypt/src/dynamic/term.rs | 52 +- packages/stack-encrypt/src/lib.rs | 7 + packages/stack-encrypt/src/plan/error.rs | 117 ++- packages/stack-encrypt/src/sem/mod.rs | 60 +- 19 files changed, 2106 insertions(+), 262 deletions(-) create mode 100644 packages/stack-encrypt/src/codes.rs diff --git a/Cargo.lock b/Cargo.lock index 92483bcb4..b5e0a12bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3279,6 +3279,7 @@ version = "0.2.0" dependencies = [ "base64ct", "cllw-ore", + "miette", "serde", "serde_json", "stack-auth", diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 5ccb84f32..841e36ff0 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -2068,7 +2068,9 @@ version = "0.2.0" dependencies = [ "base64ct", "cllw-ore", + "miette", "serde", + "serde_json", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 4ae92acab..0fcc793d3 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -116,12 +116,12 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { use stack_encrypt::dynamic::Error; match error { - Error::Context + Error::Context { .. } | Error::Term { .. } - | Error::Plan + | Error::Plan { .. } | Error::UntypedIndex { .. } - | Error::Source - | Error::Record => STATUS_ENCODING, + | Error::Source { .. } + | Error::Record { .. } => STATUS_ENCODING, Error::Cipher(e) => status_for_error(e), // A target refusal is a statement about the plan, the label or the // value (an unknown or unproducible type, an extended plan, a value @@ -433,26 +433,52 @@ mod tests { #[test] fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { - use stack_encrypt::dynamic::Error; + use stack_encrypt::dynamic::{Error, Reason}; use stack_encrypt::sem::MatchOptions; use stack_encrypt::target::IndexSpec; + let age = || Some("age".to_string()); for (label, err) in [ - ("a bad context", Error::Context), + ( + "a bad context", + Error::Context { + field: None, + reason: Reason::EmptyContext, + }, + ), ( "a bad term request", Error::Term { + field: age(), kind: IndexSpec::Match(MatchOptions::default()), }, ), - ("a bad plan", Error::Plan), + ( + "a bad plan", + Error::Plan { + field: age(), + reason: Reason::DuplicateOutput, + }, + ), ( "an indexed field with no type", Error::UntypedIndex { field: "age".to_string(), }, ), - ("a bad source", Error::Source), - ("a bad record", Error::Record), + ( + "a bad source", + Error::Source { + field: age(), + reason: Reason::FieldMissing, + }, + ), + ( + "a bad record", + Error::Record { + field: age(), + reason: Reason::NoCiphertextNode, + }, + ), ] { assert_eq!( status_for_dynamic(&err), diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 2af68e968..e053231f3 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -4231,7 +4231,9 @@ version = "0.2.0" dependencies = [ "base64ct", "cllw-ore 0.5.0", + "miette", "serde", + "serde_json", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a1f86c002..a2fb441a4 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Every error has a miette code, help where a caller can act, and its + facts as structured fields.** `Error`, `PlanError`, `LabelError`, + `LeafBytesError`, `sem::TermError`, `sem::TermBytesError` and, with + `dynamic`, `dynamic::Error` and `dynamic::TargetError` derive + `miette::Diagnostic` with a path-style code named after the crate + (`stack_encrypt::aead`, `stack_encrypt::foreign_keyset`), listed in + `stack_encrypt::ERROR_CODES` and pinned by a test that builds every + variant. A variant that carries another crate's error forwards its code: + `Error::Kms` shows `stack_kms::keyset_not_found` itself. Each error + implements `ErrorPayload` (re-exported here from `stack-profile`, the + crate `stack-profile`, `stack-auth`, `stack-kms` and this crate share), + whose `payload()` gives the facts a caller branches on — both keyset ids + of a `ForeignKeyset`, the field of a plan refusal — and whose docs hold + the rule for what an error may contain: no plaintext, key material, + tokens, ciphertext or term bytes, or raw context values. Codes are for + crossing a boundary; Rust code keeps matching variants. +- **A dynamic input error names its field and says why.** `dynamic::Reason` + is the fixed vocabulary (`MissingContext`, `DuplicateOutput`, + `FieldMissing`, `NoCiphertextNode`, ...; `as_str()` is its `snake_case` + name), and `dynamic::Error::field()`, `reason()` and `in_field()` read + and fill them. - **A data plan field may name an EQL type as its target.** Beside the output form, `dynamic::record::plan_with` reads `{"context": [...], "target": "TextEq", "type"?: ...}` — the two forms are exclusive — and @@ -32,6 +53,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **`dynamic::Error`'s input variants carry the field and a reason.** + `Context`, `Plan`, `Source` and `Record` are struct variants + `{ field: Option, reason: Reason }`, and `Term` gains + `field: Option`: a match on `Error::Plan` becomes + `Error::Plan { .. }`. The messages name both (`record plan is malformed: + an output is named twice (field "age")`). A source with a field the plan + does not name is now refused naming that field (`UnknownField`) after the + plan's own fields are checked, rather than first, on a field count. +- **`Error::Kms` is transparent.** Its message, code and help are the + `stack_kms::Error`'s; the `ZeroKMS data-key operation failed:` prefix is + gone, and `source()` skips to the ZeroKMS error's own cause. +- **Some messages leave out what an error may not contain.** + `Error::ContextMismatch` gives the stored context's length and number of + parts instead of its descriptor, which can be customer data (the + `stored` field still holds it). `sem::TermError::Prf` no longer repeats + the PRF backend's message, and + `sem::TermBytesError::MatchPositionOutOfRange` no longer quotes the + position read from the term's bytes: both stay on the error for a caller + in this process. - **A data plan field with a term output must declare its `"type"`.** A plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no `"type"` is refused when it is built (`Error::UntypedIndex`, naming the diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index ea9c805d6..d62c87f47 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -41,6 +41,11 @@ serde = { workspace = true } base64ct = { version = "1.7", features = ["alloc"] } cllw-ore = { workspace = true } +# Every error derives `miette::Diagnostic` (a code, and help where a caller +# can act) and gives its structured fields as a `serde_json` map through +# `ErrorPayload`. Both are already in the graph through stack-kms. +miette = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } @@ -63,7 +68,6 @@ dynamic = ["dep:vitaminc-aead-value"] test-support = ["stack-kms/test-support"] [dev-dependencies] -serde_json = { workspace = true } # `default-features = false` here too: dev-dependency features unify into the # `cargo test -p stack-encrypt --no-default-features` graph, so leaving the # default on would silently pull `stack-kms/http` -> `stack-auth/http` -> diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 9bc5c7f35..086027d53 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2025,7 +2025,9 @@ version = "0.2.0" dependencies = [ "base64ct", "cllw-ore", + "miette", "serde", + "serde_json", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 6a7024f6e..eb61203e0 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -119,19 +119,34 @@ pub type BoxedPassthrough = Box; pub type StackCipherText = CipherText; /// Errors from sealing or opening a [`StackCipherText`]. -#[derive(Debug, thiserror::Error)] +/// +/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES), +/// or forwards the code of the error it carries ([`Kms`](Self::Kms), +/// [`Term`](Self::Term), [`Plan`](Self::Plan)); its structured fields are its +/// [`ErrorPayload`](crate::ErrorPayload). No message carries a context's +/// descriptor or another library's text (see the rule on +/// [`ErrorPayload`](crate::ErrorPayload)). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum Error { - /// A ZeroKMS data-key generate/retrieve call failed. - #[error("ZeroKMS data-key operation failed: {0}")] + /// A ZeroKMS data-key or keyset operation failed. Transparent: the + /// message, code and help are the [`stack_kms::Error`]'s, so a caller + /// across a binding sees `stack_kms::keyset_not_found` itself. + #[error(transparent)] + #[diagnostic(transparent)] Kms(#[from] stack_kms::Error), /// AEAD sealing/opening failed, or the ciphertext shape did not match the /// requested type. On decrypt this is the expected outcome for a wrong key, /// wrong AAD, or tampered ciphertext. #[error("AEAD operation failed (wrong key, AAD mismatch, or malformed ciphertext)")] + #[diagnostic( + code(stack_encrypt::aead), + help("Open the value under the context it was sealed with. If the context is right, the ciphertext has been altered.") + )] Aead, /// ZeroKMS returned a different number of keys than were requested. #[error("expected {expected} data keys from ZeroKMS but received {received}")] + #[diagnostic(code(stack_encrypt::key_count_mismatch))] KeyCountMismatch { expected: usize, received: usize }, /// A context rendered to a descriptor longer than ZeroKMS can bind /// ([`Descriptor::MAX_LEN`]). Raised before any request is sent, so no @@ -140,6 +155,13 @@ pub enum Error { "context renders to a {len}-byte ZeroKMS descriptor; the limit is {} bytes", Descriptor::MAX_LEN )] + #[diagnostic( + code(stack_encrypt::descriptor_too_long), + help( + "ZeroKMS binds a descriptor of at most {} bytes. Shorten the context: fewer parts, shorter labels, or an id in place of a long name.", + Descriptor::MAX_LEN + ) + )] DescriptorTooLong { len: usize }, /// Building a ZeroKMS client from the environment failed: credentials or /// client key missing or malformed. @@ -148,15 +170,30 @@ pub enum Error { /// that type only exists with `http`, and a variant whose presence tracks /// a feature is not additive — feature unification elsewhere in the graph /// would then change this enum's shape under a downstream match. + /// + /// The box holds whatever built it: this crate fills it with + /// `StackKmsBuilderError`, whose message obeys the rule on + /// [`ErrorPayload`](crate::ErrorPayload), and so may be shown here. #[error("could not build a ZeroKMS client from the environment: {0}")] + #[diagnostic( + code(stack_encrypt::config), + help("Check `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY`, or log in with `stash auth login`.") + )] Config(#[source] Box), /// An index term failed to derive. #[error(transparent)] + #[diagnostic(transparent)] Term(#[from] crate::sem::TermError), /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / /// [`DecryptInto`](crate::target::DecryptInto) implementation failed /// for a reason of its own. + /// + /// Its message is the implementation's, shown as given: an + /// implementation that fills this slot writes its message under the + /// rule on [`ErrorPayload`](crate::ErrorPayload), naming what it refused + /// and never a byte of the value or the ciphertext. #[error(transparent)] + #[diagnostic(code(stack_encrypt::other))] Other(Box), /// A [`transcode::Visitor`](crate::target::transcode::Visitor) was handed /// an encrypted output shape its destination does not accept: a scalar @@ -165,12 +202,25 @@ pub enum Error { /// Never a data error: the output was produced correctly, the /// destination just has nowhere to put it. #[error("destination does not support this encrypted output shape")] + #[diagnostic(code(stack_encrypt::unsupported_shape))] UnsupportedShape, /// A record carrying its context in storage (`#[stash(context_field)]`) /// was opened with an [`ExpectedContext`](crate::target::ExpectedContext) - /// naming a different one. Refused before any key is retrieved; the - /// descriptor is the stored context's, rendered as ZeroKMS would log it. - #[error("stored context {stored} does not match the expected context")] + /// naming a different one. Refused before any key is retrieved. + /// + /// `stored` is the stored context's descriptor, rendered as ZeroKMS + /// would log it, for a caller in this process. The message gives only + /// its length and number of parts: a stored context can be built from + /// a record field, so its descriptor can be customer data. + #[error( + "stored context ({} bytes in {} parts) does not match the expected context", + stored.len(), + descriptor_parts(stored) + )] + #[diagnostic( + code(stack_encrypt::context_mismatch), + help("The record was stored under another context than the one it is opened with. Open it with the context it was stored under.") + )] ContextMismatch { stored: Descriptor }, /// A [`Pending`](crate::target::Pending) fulfilment's requests and /// responses did not line up: it drew more responses — or a different @@ -178,6 +228,7 @@ pub enum Error { /// Always a composition bug in an `EncryptFrom`/`DecryptInto` /// implementation, never a data error. #[error("a pending fulfilment's responses did not match its requests")] + #[diagnostic(code(stack_encrypt::response_shape))] ResponseShape, /// [`Pending`](crate::target::Pending)s scoped to different keysets were /// merged (`zip` / `all`): one built through a [`KeysetCipher`] for one @@ -195,6 +246,10 @@ pub enum Error { /// rather than client identity, and so refused two ciphers over the /// same client and the same keyset.) #[error("merged pendings were scoped to different keysets ({left} and {right})")] + #[diagnostic( + code(stack_encrypt::keyset_mismatch), + help("Build every part of one record or batch through the same keyset.") + )] KeysetMismatch { left: Uuid, right: Uuid }, /// A leaf sealed under one keyset was handed to a [`KeysetCipher`] for /// another. The handle's keyset is a constraint the caller asked for — @@ -202,6 +257,12 @@ pub enum Error { /// — so this is refused before any key is retrieved. To open leaves /// from any keyset, decrypt through the [`StackCipher`]. #[error("leaf was sealed under keyset {found}, not the handle's keyset {expected}")] + #[diagnostic( + code(stack_encrypt::foreign_keyset), + help( + "Decrypt through the client, not a keyset-bound cipher, to open rows from any keyset." + ) + )] ForeignKeyset { expected: Uuid, found: Uuid }, /// A data key was requested through a [`StackCipher`] rather than a /// [`KeysetCipher`]: a [`Request::generate_data_key`] needs a keyset @@ -211,12 +272,17 @@ pub enum Error { /// /// [`Request::generate_data_key`]: crate::target::Request::generate_data_key #[error("a data key was requested with no keyset to mint it under")] + #[diagnostic( + code(stack_encrypt::no_keyset), + help("Encrypt through a keyset cipher: `default_keyset()` or `keyset(..)` on the client.") + )] NoKeyset, /// A [`DecryptField`](crate::target::DecryptField) implementation /// declared its type [`DECRYPTABLE`](crate::target::Decryptable::DECRYPTABLE) /// but passed the field over. Always a bug in a third-party /// `DecryptField`, never a data error. #[error("a field declared decryptable was not opened by its DecryptField implementation")] + #[diagnostic(code(stack_encrypt::not_opened))] NotOpened, /// A [plan](crate::plan) was refused: it did not validate when it was /// built, or the value, record or query it was run with does not match @@ -224,9 +290,54 @@ pub enum Error { /// is requested; [`FieldValues::take`](crate::plan::FieldValues::take) /// also returns one for a record already in hand. #[error(transparent)] + #[diagnostic(transparent)] Plan(#[from] crate::plan::PlanError), } +/// How many separator-delimited parts a descriptor has: what an error about +/// a context says of it in place of the descriptor itself. +fn descriptor_parts(descriptor: &Descriptor) -> usize { + descriptor.as_str().split(Descriptor::SEPARATOR).count() +} + +impl crate::ErrorPayload for Error { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::Kms(error) => error.payload(), + Self::Term(error) => error.payload(), + Self::Plan(error) => error.payload(), + Self::KeyCountMismatch { expected, received } => payload([ + ("expected", (*expected).into()), + ("received", (*received).into()), + ]), + Self::DescriptorTooLong { len } => payload([ + ("len", (*len).into()), + ("limit", Descriptor::MAX_LEN.into()), + ]), + Self::ContextMismatch { stored } => payload([ + ("stored_len", stored.len().into()), + ("stored_parts", descriptor_parts(stored).into()), + ]), + Self::KeysetMismatch { left, right } => payload([ + ("left", left.to_string().into()), + ("right", right.to_string().into()), + ]), + Self::ForeignKeyset { expected, found } => payload([ + ("expected", expected.to_string().into()), + ("found", found.to_string().into()), + ]), + Self::Aead + | Self::Config(_) + | Self::Other(_) + | Self::UnsupportedShape + | Self::ResponseShape + | Self::NoKeyset + | Self::NotOpened => serde_json::Map::new(), + } + } +} + #[cfg(feature = "http")] impl From for Error { fn from(error: stack_kms::StackKmsBuilderError) -> Self { @@ -865,7 +976,7 @@ pub struct SealedValue { /// structural — a leaf that *decodes* has proven nothing about integrity /// (that is the AEAD open's job); a leaf that fails here was never a valid /// v1 encoding at all. -#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[derive(Debug, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum LeafBytesError { /// The leading version byte is not one this build knows how to parse. @@ -873,10 +984,15 @@ pub enum LeafBytesError { /// different version, passes here and fails authentication instead — /// the version byte is bound into the leaf AAD.) #[error("unknown sealed-leaf format version {0}")] + #[diagnostic( + code(stack_encrypt::leaf_version), + help("The value was sealed by a newer build of stack-encrypt, or the bytes are not a sealed value.") + )] UnknownVersion(u8), /// The buffer ends before the fixed-width fields, or before the key tag /// the `tag_len` field promises. #[error("sealed-leaf bytes are truncated")] + #[diagnostic(code(stack_encrypt::leaf_truncated))] Truncated, /// The key tag does not fit the format's `u16` length field. Every /// construction site rejects an oversized tag — [`SealedValue::from_parts`] @@ -885,9 +1001,21 @@ pub enum LeafBytesError { /// of bytes) by failing the encrypt — so a live `SealedValue` always /// encodes. #[error("key tag of {0} bytes exceeds the format's u16 length field")] + #[diagnostic(code(stack_encrypt::leaf_tag_too_long))] TagTooLong(usize), } +impl crate::ErrorPayload for LeafBytesError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::UnknownVersion(version) => payload([("version", (*version).into())]), + Self::TagTooLong(len) => payload([("len", (*len).into())]), + Self::Truncated => serde_json::Map::new(), + } + } +} + impl SealedValue { /// The version byte prefixing the frozen byte encoding /// ([`to_bytes`](Self::to_bytes)). Also bound into every leaf's AAD (the diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs new file mode 100644 index 000000000..b68b5eb95 --- /dev/null +++ b/packages/stack-encrypt/src/codes.rs @@ -0,0 +1,344 @@ +//! Every miette code an error from this crate can carry. + +/// Every miette code an error from this crate can carry: [`Error`](crate::Error), +/// [`PlanError`](crate::PlanError), [`LabelError`](crate::LabelError), +/// [`LeafBytesError`](crate::LeafBytesError), the term errors in +/// [`sem`](crate::sem), and with the `dynamic` feature the dynamic module's +/// errors. A variant that carries a `stack_kms` or `stack_auth` error carries +/// its code instead. +/// +/// A test builds every variant and checks its code is here, so renaming a +/// code means editing this list on purpose. Codes are for crossing a +/// boundary: Rust code that branches on an error matches the variant. +pub const ERROR_CODES: &[&str] = &[ + // `Error` + "stack_encrypt::aead", + "stack_encrypt::key_count_mismatch", + "stack_encrypt::descriptor_too_long", + "stack_encrypt::config", + "stack_encrypt::other", + "stack_encrypt::unsupported_shape", + "stack_encrypt::context_mismatch", + "stack_encrypt::response_shape", + "stack_encrypt::keyset_mismatch", + "stack_encrypt::foreign_keyset", + "stack_encrypt::no_keyset", + "stack_encrypt::not_opened", + // `LeafBytesError` + "stack_encrypt::leaf_version", + "stack_encrypt::leaf_truncated", + "stack_encrypt::leaf_tag_too_long", + // `sem::TermError` + "stack_encrypt::prf_failed", + "stack_encrypt::ore_failed", + "stack_encrypt::invalid_match_options", + "stack_encrypt::empty_term_text", + // `sem::TermBytesError` + "stack_encrypt::equality_term_length", + "stack_encrypt::match_term_length", + "stack_encrypt::match_position_out_of_range", + "stack_encrypt::cllw_ciphertext_length", + // `LabelError` + "stack_encrypt::label_empty", + "stack_encrypt::label_empty_segment", + "stack_encrypt::label_separator", + "stack_encrypt::label_reserved", + "stack_encrypt::label_reserved_prefix", + // `PlanError` + "stack_encrypt::plan_context_label", + "stack_encrypt::plan_field_label", + "stack_encrypt::plan_identity_without_field", + "stack_encrypt::plan_duplicate_field", + "stack_encrypt::plan_shared_identity", + "stack_encrypt::plan_passthrough_indexed", + "stack_encrypt::plan_duplicate_index", + "stack_encrypt::plan_empty_indexes", + "stack_encrypt::plan_field_not_in_plan", + "stack_encrypt::plan_field_not_in_value", + "stack_encrypt::plan_field_type", + "stack_encrypt::plan_no_such_field", + "stack_encrypt::plan_mixed_ciphers", + "stack_encrypt::plan_index_not_declared", + "stack_encrypt::plan_index_options", + "stack_encrypt::plan_two_context_sources", + "stack_encrypt::plan_no_context", + "stack_encrypt::plan_target_with_verbs", + // `dynamic::Error` + "stack_encrypt::dynamic_context", + "stack_encrypt::dynamic_term", + "stack_encrypt::dynamic_plan", + "stack_encrypt::dynamic_untyped_index", + "stack_encrypt::dynamic_source", + "stack_encrypt::dynamic_record", + "stack_encrypt::dynamic_internal", + // `dynamic::TargetError` + "stack_encrypt::target_none", + "stack_encrypt::target_unknown", + "stack_encrypt::target_unproducible", + "stack_encrypt::target_no_query", + "stack_encrypt::target_extended", + "stack_encrypt::target_kind", + "stack_encrypt::target_column", + "stack_encrypt::target_plaintext", + "stack_encrypt::target_stored", + "stack_encrypt::target_other", +]; + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use miette::Diagnostic; + use uuid::Uuid; + + use super::ERROR_CODES; + use crate::diagnostic::is_code_of; + use crate::sem::{MatchOptions, TermBytesError, TermError}; + use crate::target::IndexSpec; + use crate::{Descriptor, Error, LabelError, LeafBytesError, PlanError}; + + /// One of every variant of every error type here. A transparent variant + /// is built once, to show the code it forwards is listed somewhere. + fn every_variant() -> Vec> { + let boxed = || Box::new(std::io::Error::other("cause")); + let (a, b) = (Uuid::from_u128(1), Uuid::from_u128(2)); + let field = || "age".to_string(); + let errors: Vec> = vec![ + Box::new(Error::Kms(crate::kms::Error::Unexpected("kms".into()))), + Box::new(Error::Aead), + Box::new(Error::KeyCountMismatch { + expected: 2, + received: 1, + }), + Box::new(Error::DescriptorTooLong { len: 513 }), + Box::new(Error::Config(boxed())), + Box::new(Error::Term(TermError::EmptyTermText)), + Box::new(Error::Other(boxed())), + Box::new(Error::UnsupportedShape), + Box::new(Error::ContextMismatch { + stored: Descriptor::of("users"), + }), + Box::new(Error::ResponseShape), + Box::new(Error::KeysetMismatch { left: a, right: b }), + Box::new(Error::ForeignKeyset { + expected: a, + found: b, + }), + Box::new(Error::NoKeyset), + Box::new(Error::NotOpened), + Box::new(Error::Plan(PlanError::NoContext)), + Box::new(LeafBytesError::UnknownVersion(9)), + Box::new(LeafBytesError::Truncated), + Box::new(LeafBytesError::TagTooLong(70_000)), + Box::new(TermError::Prf(boxed())), + Box::new(TermError::Ore(cllw_ore::Error)), + Box::new(TermError::InvalidOptions("k out of range")), + Box::new(TermError::EmptyTermText), + Box::new(TermError::Bytes(TermBytesError::OddMatchTermsLength(3))), + Box::new(TermBytesError::WrongEqualityTermLength(3)), + Box::new(TermBytesError::OddMatchTermsLength(3)), + Box::new(TermBytesError::MatchPositionOutOfRange { + position: 900, + filter_size: 256, + }), + Box::new(TermBytesError::MalformedCllwCiphertext(3)), + Box::new(LabelError::Empty), + Box::new(LabelError::EmptySegment { index: 0 }), + Box::new(LabelError::Separator { index: 0 }), + Box::new(LabelError::Reserved { + index: 0, + found: '(', + }), + Box::new(LabelError::ReservedPrefix { index: 0 }), + Box::new(PlanError::ContextLabel(LabelError::Empty)), + Box::new(PlanError::FieldLabel { + field: field(), + source: LabelError::Empty, + }), + Box::new(PlanError::IdentityWithoutField), + Box::new(PlanError::DuplicateField { field: field() }), + Box::new(PlanError::SharedIdentity { + identity: "age".into(), + first: "age".into(), + second: "years".into(), + }), + Box::new(PlanError::PassthroughIndexed { field: field() }), + Box::new(PlanError::DuplicateIndex { + at: field(), + index: "eq", + }), + Box::new(PlanError::EmptyIndexes), + Box::new(PlanError::NotInPlan { field: field() }), + Box::new(PlanError::NotInValue { field: field() }), + Box::new(PlanError::FieldType { + field: field(), + expected: "int64", + }), + Box::new(PlanError::NoSuchField { field: field() }), + Box::new(PlanError::MixedCiphers), + Box::new(PlanError::IndexNotDeclared { + field: field(), + index: "ore", + }), + Box::new(PlanError::IndexOptions { + field: field(), + declared: IndexSpec::Match(MatchOptions::default()), + asked: IndexSpec::Match(MatchOptions { + downcase: false, + ..MatchOptions::default() + }), + }), + Box::new(PlanError::TwoContextSources { + first: "the plan", + second: "the call", + }), + Box::new(PlanError::NoContext), + Box::new(PlanError::TargetWithVerbs { field: field() }), + ]; + #[cfg(feature = "dynamic")] + let errors = errors.into_iter().chain(dynamic_variants()).collect(); + errors + } + + #[cfg(feature = "dynamic")] + fn dynamic_variants() -> Vec> { + use crate::dynamic::{Error, Reason, TargetError, ValueKind}; + let name = || "email".to_string(); + let target = || "TextEq".to_string(); + vec![ + Box::new(Error::bad_context(Reason::EmptyContext)), + Box::new(Error::Term { + field: Some(name()), + kind: IndexSpec::Equality, + }), + Box::new(Error::bad_plan(Reason::NoFields)), + Box::new(Error::UntypedIndex { field: name() }), + Box::new(Error::bad_source(Reason::FieldMissing)), + Box::new(Error::bad_record(Reason::NoCiphertextNode)), + Box::new(Error::Internal), + Box::new(Error::Target(TargetError::NoTargets { name: target() })), + Box::new(Error::Cipher(crate::Error::Aead)), + Box::new(TargetError::NoTargets { name: target() }), + Box::new(TargetError::Unknown { name: target() }), + Box::new(TargetError::Unproducible { + name: target(), + reason: "block ORE".into(), + }), + Box::new(TargetError::NoQuery { name: target() }), + Box::new(TargetError::Extended { + name: name(), + label: "users/email".into(), + }), + Box::new(TargetError::Kind { + name: name(), + target: target(), + expected: Some(ValueKind::String), + declared: ValueKind::UInt64, + }), + Box::new(TargetError::Column { + name: name(), + label: "app/users/email".into(), + reason: "two segments".into(), + }), + Box::new(TargetError::Plaintext { + name: name(), + target: target(), + expected: Some(ValueKind::String), + found: None, + }), + Box::new(TargetError::Stored { + name: name(), + target: target(), + reason: "not JSON".into(), + }), + Box::new(TargetError::Other(Box::new(std::io::Error::other("boom")))), + ] + } + + #[test] + fn every_variant_has_a_listed_code() { + let mut seen = BTreeSet::new(); + for error in every_variant() { + let code = error + .code() + .unwrap_or_else(|| panic!("{error:?} has no code")) + .to_string(); + if code.starts_with("stack_kms::") { + assert!(crate::kms::ERROR_CODES.contains(&code.as_str()), "{code}"); + continue; + } + assert!(is_code_of("stack_encrypt", &code), "{code}"); + assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); + seen.insert(code); + } + // The dynamic module's codes need its feature to be built. + let listed: BTreeSet = ERROR_CODES + .iter() + .filter(|code| { + cfg!(feature = "dynamic") + || !(code.starts_with("stack_encrypt::dynamic_") + || code.starts_with("stack_encrypt::target_")) + }) + .map(|code| code.to_string()) + .collect(); + assert_eq!(seen, listed, "every listed code is produced"); + } + + /// A stored context can be customer data: its descriptor stays out of + /// the message and the payload, which give its length and parts. + #[test] + fn a_context_mismatch_does_not_render_the_stored_context() { + use crate::ErrorPayload; + let error = Error::ContextMismatch { + stored: Descriptor::of(("tenant", "marker-tenant")), + }; + let shown = format!("{error} {:?}", error.payload()); + assert!(!shown.contains("marker-tenant"), "{shown}"); + assert_eq!(error.payload()["stored_parts"], 2); + } + + /// A PRF backend's error is another library's: it is the source and + /// never the message. + #[test] + fn a_prf_backends_message_is_the_source_only() { + let error = TermError::Prf(Box::new(std::io::Error::other("marker-cause"))); + assert!(!error.to_string().contains("marker-cause"), "{error}"); + assert!(std::error::Error::source(&error) + .is_some_and(|source| source.to_string().contains("marker-cause"))); + } + + /// The slots an implementation fills show its message as given: the + /// implementation answers for it under the rule, and its own report is + /// the useful one ("unsupported EQL ciphertext producer or version"). + #[test] + fn an_implementations_own_error_shows_its_message() { + let error = Error::Other("the implementation's own words".into()); + assert_eq!(error.to_string(), "the implementation's own words"); + assert_eq!( + error.code().map(|code| code.to_string()).as_deref(), + Some("stack_encrypt::other") + ); + } + + #[test] + fn a_foreign_keyset_carries_both_keysets() { + use crate::ErrorPayload; + let (expected, found) = (Uuid::from_u128(1), Uuid::from_u128(2)); + let error = Error::ForeignKeyset { expected, found }; + assert_eq!(error.payload()["expected"], expected.to_string()); + assert_eq!(error.payload()["found"], found.to_string()); + assert!(error.help().is_some()); + } + + /// `Kms` is transparent all the way: a ZeroKMS keyset-not-found reads + /// as itself, code and help, through the encryption error. + #[test] + fn a_kms_error_shows_through() { + let error = Error::Kms(crate::kms::Error::Unexpected("kms".into())); + assert_eq!( + error.code().map(|code| code.to_string()).as_deref(), + Some("stack_kms::unexpected") + ); + assert_eq!(error.to_string(), "Unexpected error: kms"); + } +} diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 9a98766dc..7fd27ca58 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -620,36 +620,64 @@ impl std::str::FromStr for Label { /// Why a string is not a [`Label`] segment. `index` is the segment's /// position, counting from zero. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +/// +/// A label is schema — a plan's context and its fields' names — so a message +/// may quote the character it refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum LabelError { /// No segments at all. #[error("a label needs at least one segment")] + #[diagnostic(code(stack_encrypt::label_empty))] Empty, /// The segment is the empty string. #[error("label segment {index} is empty")] + #[diagnostic(code(stack_encrypt::label_empty_segment))] EmptySegment { index: usize }, /// The segment contains the separator, [`/`](Descriptor::SEPARATOR). #[error( "label segment {index} contains the separator '{}'", Descriptor::SEPARATOR )] + #[diagnostic( + code(stack_encrypt::label_separator), + help("Give each segment as its own element rather than joining them with `/`.") + )] Separator { index: usize }, /// The segment contains a control character, an invisible format /// character or a parenthesis, which the descriptor reserves. #[error("label segment {index} contains {found:?}, which the descriptor reserves")] + #[diagnostic(code(stack_encrypt::label_reserved))] Reserved { index: usize, found: char }, /// The segment begins like another descriptor form: `b64:`, a digit or /// `-`. #[error("label segment {index} begins like another descriptor form (`b64:`, a digit or `-`)")] + #[diagnostic(code(stack_encrypt::label_reserved_prefix))] ReservedPrefix { index: usize }, /// The value a label was read from is not text at all: a number, bytes, /// a list or a composite where a context field's value should be a /// label such as `tenants/acme`. #[error("a label is read from text, and this value is not text")] + #[diagnostic(code(stack_encrypt::label_not_text))] NotText, } +impl crate::ErrorPayload for LabelError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::Empty | Self::NotText => serde_json::Map::new(), + Self::EmptySegment { index } + | Self::Separator { index } + | Self::ReservedPrefix { index } => payload([("segment", (*index).into())]), + Self::Reserved { index, found } => payload([ + ("segment", (*index).into()), + ("character", found.to_string().into()), + ]), + } + } +} + impl std::fmt::Display for Descriptor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index a272ed06c..49c983222 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; -use super::Error; +use super::{Error, Reason}; use crate::{ContextPiece, NonEmpty}; /// A context arrives from a binding as a value and becomes a [`ContextPiece`] @@ -90,10 +90,12 @@ use crate::{ContextPiece, NonEmpty}; /// /// # Errors /// -/// [`Error::Context`] for anything outside the shape above, and for a -/// context that renders empty. +/// [`Error::Context`] for anything outside the shape above +/// ([`Reason::ContextKind`], [`Reason::ContextNotUtf8`]), and for a context +/// that renders empty ([`Reason::EmptyContext`]). It names no field: the +/// caller that knows the field names it ([`Error::in_field`]). pub fn context(value: FfiValue) -> Result>, Error> { - NonEmpty::new(piece_of(value)?).map_err(|_| Error::Context) + NonEmpty::new(piece_of(value)?).map_err(|_| Error::bad_context(Reason::EmptyContext)) } fn piece_of(value: FfiValue) -> Result, Error> { @@ -103,7 +105,8 @@ fn piece_of(value: FfiValue) -> Result, Error> { // moves out of its `Protected` rather than being copied: a context // is not secret, and the copy would only be wiped and freed. FfiValue::String(s) => ContextPiece::Text(Cow::Owned( - String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| Error::Context)?, + String::from_utf8(s.into_inner().risky_unwrap()) + .map_err(|_| Error::bad_context(Reason::ContextNotUtf8))?, )), FfiValue::Bytes(bytes) => ContextPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), FfiValue::Int32(v) => ContextPiece::I32(v), @@ -124,7 +127,7 @@ fn piece_of(value: FfiValue) -> Result, Error> { | FfiValue::Float32(_) | FfiValue::Float64(_) | FfiValue::Object(_) - | FfiValue::Passthrough(_) => return Err(Error::Context), + | FfiValue::Passthrough(_) => return Err(Error::bad_context(Reason::ContextKind)), }) } @@ -430,7 +433,7 @@ mod tests { ), ] { assert!( - matches!(context(empty), Err(Error::Context)), + matches!(context(empty), Err(Error::Context { .. })), "{label} is empty by the tuple rule and must be refused" ); } @@ -470,7 +473,7 @@ mod tests { ), ] { assert!( - matches!(context(bad), Err(Error::Context)), + matches!(context(bad), Err(Error::Context { .. })), "{label} is not a context and must be refused" ); } diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index 9ed9e4d55..1b49d3856 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -39,7 +39,7 @@ //! only carries its value through, may leave it out: no term derives from it. use vitaminc_aead_value::{FfiValue, ValueKind}; -use super::Error; +use super::{Error, Reason}; use crate::target::IndexSpec; /// Whether the scheme defines an `index` term for values of `kind`. @@ -104,12 +104,14 @@ pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { /// /// # Errors /// -/// [`Error::Source`] if the value cannot be read as `kind` exactly. +/// [`Error::Source`] ([`Reason::FieldType`]) if the value cannot be read as +/// `kind` exactly. It names no field: the caller that knows the field names +/// it ([`Error::in_field`]). pub fn read(kind: ValueKind, value: FfiValue) -> Result { if kind.holds(&value) { return Ok(value); } - let number = Number::of(&value).ok_or(Error::Source)?; + let number = Number::of(&value).ok_or(Error::bad_source(Reason::FieldType))?; let read = match kind { ValueKind::Int32 => number .integer() @@ -131,7 +133,7 @@ pub fn read(kind: ValueKind, value: FfiValue) -> Result { ValueKind::Float32 => number.exact_f32().map(FfiValue::Float32), _ => None, }; - read.ok_or(Error::Source) + read.ok_or(Error::bad_source(Reason::FieldType)) } /// A numeric leaf, widened without loss: every integer variant fits an @@ -362,7 +364,7 @@ mod tests { ]; for (at, (kind, value)) in refused.into_iter().enumerate() { assert!( - matches!(read(kind, value), Err(Error::Source)), + matches!(read(kind, value), Err(Error::Source { .. })), "case {at}, as {kind}" ); } @@ -426,7 +428,7 @@ mod tests { ]; for (at, (kind, value)) in refused.into_iter().enumerate() { assert!( - matches!(read(kind, value), Err(Error::Source)), + matches!(read(kind, value), Err(Error::Source { .. })), "case {at}, as {kind}" ); } @@ -438,11 +440,11 @@ mod tests { fn read_refuses_a_nan_in_both_float_directions() { assert!(matches!( read(ValueKind::Float32, FfiValue::Float64(f64::NAN)), - Err(Error::Source) + Err(Error::Source { .. }) )); assert!(matches!( read(ValueKind::Float64, FfiValue::Float32(f32::NAN)), - Err(Error::Source) + Err(Error::Source { .. }) )); } @@ -465,7 +467,7 @@ mod tests { ]; for (at, (kind, value)) in refused.into_iter().enumerate() { assert!( - matches!(read(kind, value), Err(Error::Source)), + matches!(read(kind, value), Err(Error::Source { .. })), "case {at}, as {kind}" ); } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 3130feeea..89c78861a 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -153,7 +153,16 @@ fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { /// the engine's [`Pending`](crate::Pending), whose failure is the crate's /// [`Error`](crate::Error); a [`Plan`](crate::Error::Plan) failure there is /// again a statement about the caller's data. -#[derive(Debug, thiserror::Error)] +/// +/// Each input error names the field it is about, where there is one, and +/// says what was wrong with a [`Reason`]: both are in the message and in the +/// [`ErrorPayload`](crate::ErrorPayload) (`field`, `reason`), so a binding's +/// caller can tell a bad plan field from a bad record field without parsing +/// text. A field is `None` where the error is about the whole plan, value or +/// record, or where the function that raised it never sees a field +/// ([`context`](context()), [`read`]); [`in_field`](Self::in_field) names +/// it from the caller's side. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum Error { /// A value used as an encryption context is not one — a boolean, float, @@ -161,38 +170,63 @@ pub enum Error { /// a context that renders empty. Leaves take a /// [`NonEmpty`](vitaminc_protected::NonEmpty) and nothing else, so an /// empty context is refused where it is read rather than sealed under. - #[error("value cannot be read as a non-empty encryption context")] - Context, + #[error( + "value cannot be read as a non-empty encryption context: {reason}{}", + in_field_text(field) + )] + #[diagnostic( + code(stack_encrypt::dynamic_context), + help("A context is a string, an integer, bytes, or a list of them, and renders to at least one byte.") + )] + Context { + /// The plan field whose context it is, if it is one. + field: Option, + /// What was wrong with it. + reason: Reason, + }, /// A term was asked for a value the scheme defines no such term for: a /// container, null or passthrough (which have no term semantics at all), /// or a scalar outside the kind's domain — equality over a float or a /// boolean, match over anything but text. See /// [`IndexSpec::supports`]. - #[error("no {kind} term is defined for this value")] + #[error("no {kind} term is defined for this value{}", in_field_text(field))] + #[diagnostic(code(stack_encrypt::dynamic_term))] Term { + /// The plan field the value is for, if it is one. + field: Option, /// The index that was asked for. kind: IndexSpec, }, /// A record plan is malformed: not an object of field specs, empty, /// missing or duplicating an output, carrying a key that is not - /// `"context"`, `"outputs"` or `"type"`, naming a type that is not one, - /// asking for an index its declared type is not defined for, giving a - /// field a context that is not a label a fields plan can seal it under, - /// or declaring what the plan builder refuses (two fields under one - /// identity, fields under different contexts). - #[error("record plan is malformed")] - Plan, + /// `"context"`, `"outputs"`, `"target"` or `"type"`, naming a type that + /// is not one, asking for an index its declared type is not defined + /// for, giving a field a context that is not a label a fields plan can + /// seal it under, or declaring what the plan builder refuses (two fields + /// under one identity, fields under different contexts). + #[error("record plan is malformed: {reason}{}", in_field_text(field))] + #[diagnostic(code(stack_encrypt::dynamic_plan))] + Plan { + /// The plan field at fault, if the fault is one field's. + field: Option, + /// What was wrong with the plan. + reason: Reason, + }, /// A record plan field has a term output (`"eq"`, `"match"`, `"ore"`, /// `"ope"`) and declares no `"type"`. The field's terms derive from the /// one declared kind, never from whatever tag each value arrived with, /// so the plan is refused when it is built, before any value arrives. - /// Its own variant rather than a cause of [`Plan`](Error::Plan) because - /// it names the field: it is the refusal a plan written before types - /// were required meets first, and the one a caller fixes field by field. + /// Its own variant rather than a [`Plan`](Error::Plan) reason because + /// it is the refusal a plan written before types were required meets + /// first, and the one a caller fixes field by field. #[error("record plan field {field:?} has a term output and no declared type")] + #[diagnostic( + code(stack_encrypt::dynamic_untyped_index), + help("Declare the field's \"type\" (\"string\", \"int64\", ...): its index terms derive from that type.") + )] UntypedIndex { /// The field's name — its key in the plan. field: String, @@ -204,8 +238,17 @@ pub enum Error { /// under a field the plan seals, or a value of another type than its /// field declares. Also a query value that cannot be read as its field's /// type ([`read`]). - #[error("record source does not fit the plan")] - Source, + #[error( + "record source does not fit the plan: {reason}{}", + in_field_text(field) + )] + #[diagnostic(code(stack_encrypt::dynamic_source))] + Source { + /// The source field at fault, if the fault is one field's. + field: Option, + /// What was wrong with the source. + reason: Reason, + }, /// A stored record does not fit its plan: not a map (or a sequence of /// them), a ciphertext-bearing field that is absent or given twice, or @@ -217,13 +260,23 @@ pub enum Error { /// of another type than it declares fails the pending instead /// ([`PlanError::FieldType`](crate::PlanError::FieldType)): the type tag /// is inside the AEAD envelope. - #[error("stored record does not fit the plan")] - Record, + #[error( + "stored record does not fit the plan: {reason}{}", + in_field_text(field) + )] + #[diagnostic(code(stack_encrypt::dynamic_record))] + Record { + /// The stored field at fault, if the fault is one field's. + field: Option, + /// What was wrong with the stored record. + reason: Reason, + }, /// An invariant this module maintains did not hold — a slot count that /// did not line up, a re-proof that should not have been able to fail. /// Always a bug here, never a statement about the caller's data. #[error("internal invariant violated")] + #[diagnostic(code(stack_encrypt::dynamic_internal))] Internal, /// A plan field names an EQL type as its target and the name, the @@ -233,9 +286,353 @@ pub enum Error { /// the plan is built or the value is read, before any key is touched /// — save [`TargetError::Other`], which is the resolver's own failure. #[error(transparent)] + #[diagnostic(transparent)] Target(#[from] TargetError), /// Sealing, opening or deriving failed. #[error(transparent)] + #[diagnostic(transparent)] Cipher(#[from] crate::Error), } + +/// ` (field "age")`, or nothing: the tail of an input error's message. +fn in_field_text(field: &Option) -> String { + field + .as_deref() + .map(|field| format!(" (field {field:?})")) + .unwrap_or_default() +} + +impl Error { + /// A context error with no field named yet. + pub(crate) fn bad_context(reason: Reason) -> Self { + Self::Context { + field: None, + reason, + } + } + + /// A plan error with no field named yet. + pub(crate) fn bad_plan(reason: Reason) -> Self { + Self::Plan { + field: None, + reason, + } + } + + /// A source error with no field named yet. + pub(crate) fn bad_source(reason: Reason) -> Self { + Self::Source { + field: None, + reason, + } + } + + /// A stored-record error with no field named yet. + pub(crate) fn bad_record(reason: Reason) -> Self { + Self::Record { + field: None, + reason, + } + } + + /// Name the field an input error is about, where it names none yet. + /// + /// For a binding that calls something that never sees a field — + /// [`read`] for a query value, [`context`](context()) for a field's + /// context — and knows which field it was for. An error that already + /// names a field, or is not an input error, comes back unchanged. + pub fn in_field(mut self, name: &str) -> Self { + match &mut self { + Self::Context { field, .. } + | Self::Term { field, .. } + | Self::Plan { field, .. } + | Self::Source { field, .. } + | Self::Record { field, .. } + if field.is_none() => + { + *field = Some(name.to_owned()); + } + _ => {} + } + self + } + + /// The field an input error is about, if it names one. + pub fn field(&self) -> Option<&str> { + match self { + Self::Context { field, .. } + | Self::Term { field, .. } + | Self::Plan { field, .. } + | Self::Source { field, .. } + | Self::Record { field, .. } => field.as_deref(), + Self::UntypedIndex { field } => Some(field), + Self::Internal | Self::Target(_) | Self::Cipher(_) => None, + } + } + + /// What was wrong, for a context, plan, source or record error. + pub fn reason(&self) -> Option { + match self { + Self::Context { reason, .. } + | Self::Plan { reason, .. } + | Self::Source { reason, .. } + | Self::Record { reason, .. } => Some(*reason), + Self::Term { .. } + | Self::UntypedIndex { .. } + | Self::Internal + | Self::Target(_) + | Self::Cipher(_) => None, + } + } +} + +impl crate::ErrorPayload for Error { + fn payload(&self) -> serde_json::Map { + let mut fields = match self { + Self::Target(error) => return error.payload(), + Self::Cipher(error) => return error.payload(), + Self::Term { kind, .. } => crate::diagnostic::payload([("index", kind.key().into())]), + _ => serde_json::Map::new(), + }; + if let Some(field) = self.field() { + let _ = fields.insert("field".to_owned(), field.into()); + } + if let Some(reason) = self.reason() { + let _ = fields.insert("reason".to_owned(), reason.as_str().into()); + } + fields + } +} + +/// What was wrong with a context, plan, source or stored record: the reason +/// a dynamic input error carries beside the field it names. +/// +/// One vocabulary for all four, since several reasons apply to more than +/// one (a field given twice is a misfit in a source and in a stored +/// record). [`as_str`](Self::as_str) is the `snake_case` name a binding +/// reports in its payload's `reason` field; `Display` is the phrase the +/// message uses. `#[non_exhaustive]`: a reason added later is not a break, +/// and a binding that switches on one keeps a fallback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Reason { + /// A plan, field spec, source or stored record is not an object (a + /// map), or a list of them, where one is expected. + NotAnObject, + /// A context value is of a kind a context cannot hold: a boolean, + /// float, null, object or passthrough. + ContextKind, + /// A context string is not UTF-8. + ContextNotUtf8, + /// A context renders empty. + EmptyContext, + /// A field's context is not a label of at least two plain segments, + /// optionally extended by scalar parts. + ContextNotLabel, + /// The plan's fields sit under different contexts, or carry different + /// extensions. + MixedContexts, + /// The plan has no fields. + NoFields, + /// A field is named twice in the plan. + DuplicateField, + /// A field spec has a key other than `"context"`, `"outputs"`, + /// `"target"` and `"type"`. + UnknownKey, + /// A key is given twice: in a field spec, or in a map inside a source + /// value or a stored record. + RepeatedKey, + /// A field spec has no `"context"`. + MissingContext, + /// A field spec has neither `"outputs"` nor `"target"`. + MissingOutputs, + /// A field spec has both `"outputs"` and `"target"`, or a field is + /// declared both as a target and with data verbs. + OutputsWithTarget, + /// `"outputs"` is not a list. + OutputsNotList, + /// An output is not `"c"`, `"passthrough"` or an index in its wire + /// form, or a match index's options are not valid. + UnknownOutput, + /// A field's output list, or its index set, is empty. + NoOutputs, + /// A field names one output, or one index, twice. + DuplicateOutput, + /// A field names `"passthrough"` beside another output. + PassthroughWithOutputs, + /// `"target"` is not a non-empty string. + InvalidTarget, + /// `"type"` is not a string naming a value type. + UnknownType, + /// The field's declared type has no such index: match on an integer, + /// equality on a float, any index on a composite. + IndexNotAdmitted, + /// Two fields are keyed under one identity. + SharedIdentity, + /// The plan has no field of the name asked for. + NoSuchField, + /// The field asked for does not name an EQL type. + NotATarget, + /// The field a plan takes its context from (`"context_field"`) is not a + /// string passthrough, or the plan-level `"context_field"` key is not a + /// string. + ContextField, + /// The plan builder refused the plan for a reason none of the above + /// names. + Refused, + /// A field the plan names is not there. + FieldMissing, + /// A field is there twice. + FieldRepeated, + /// A field is there that the plan does not name. + UnknownField, + /// A value is not of the type its field declares. + FieldType, + /// A passthrough sits where a sealed value, or a ciphertext, must be. + Passthrough, + /// A stored field is not a map of outputs. + OutputsNotMap, + /// A stored sealed field has no `"c"` node. + NoCiphertextNode, + /// A stored passthrough field has no `"passthrough"` node. + NoPassthroughNode, + /// A stored target field has no `"eql"` node. + NoEqlNode, + /// A stored `"passthrough"` or `"eql"` node is not a passthrough + /// carrying a value of the kind it holds. + NotPassthrough, +} + +impl Reason { + /// The reason's `snake_case` name, as a binding reports it. + pub fn as_str(self) -> &'static str { + match self { + Self::NotAnObject => "not_an_object", + Self::ContextKind => "context_kind", + Self::ContextNotUtf8 => "context_not_utf8", + Self::EmptyContext => "empty_context", + Self::ContextNotLabel => "context_not_label", + Self::MixedContexts => "mixed_contexts", + Self::NoFields => "no_fields", + Self::DuplicateField => "duplicate_field", + Self::UnknownKey => "unknown_key", + Self::RepeatedKey => "repeated_key", + Self::MissingContext => "missing_context", + Self::MissingOutputs => "missing_outputs", + Self::OutputsWithTarget => "outputs_with_target", + Self::OutputsNotList => "outputs_not_list", + Self::UnknownOutput => "unknown_output", + Self::NoOutputs => "no_outputs", + Self::DuplicateOutput => "duplicate_output", + Self::PassthroughWithOutputs => "passthrough_with_outputs", + Self::InvalidTarget => "invalid_target", + Self::UnknownType => "unknown_type", + Self::IndexNotAdmitted => "index_not_admitted", + Self::SharedIdentity => "shared_identity", + Self::NoSuchField => "no_such_field", + Self::NotATarget => "not_a_target", + Self::ContextField => "context_field", + Self::Refused => "refused", + Self::FieldMissing => "field_missing", + Self::FieldRepeated => "field_repeated", + Self::UnknownField => "unknown_field", + Self::FieldType => "field_type", + Self::Passthrough => "passthrough", + Self::OutputsNotMap => "outputs_not_map", + Self::NoCiphertextNode => "no_ciphertext_node", + Self::NoPassthroughNode => "no_passthrough_node", + Self::NoEqlNode => "no_eql_node", + Self::NotPassthrough => "not_passthrough", + } + } + + /// Every reason, in declaration order: what a binding's test iterates. + pub const ALL: &'static [Reason] = &[ + Self::NotAnObject, + Self::ContextKind, + Self::ContextNotUtf8, + Self::EmptyContext, + Self::ContextNotLabel, + Self::MixedContexts, + Self::NoFields, + Self::DuplicateField, + Self::UnknownKey, + Self::RepeatedKey, + Self::MissingContext, + Self::MissingOutputs, + Self::OutputsWithTarget, + Self::OutputsNotList, + Self::UnknownOutput, + Self::NoOutputs, + Self::DuplicateOutput, + Self::PassthroughWithOutputs, + Self::InvalidTarget, + Self::UnknownType, + Self::IndexNotAdmitted, + Self::SharedIdentity, + Self::NoSuchField, + Self::NotATarget, + Self::ContextField, + Self::Refused, + Self::FieldMissing, + Self::FieldRepeated, + Self::UnknownField, + Self::FieldType, + Self::Passthrough, + Self::OutputsNotMap, + Self::NoCiphertextNode, + Self::NoPassthroughNode, + Self::NoEqlNode, + Self::NotPassthrough, + ]; +} + +impl fmt::Display for Reason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::NotAnObject => "not an object where one is expected", + Self::ContextKind => { + "a boolean, float, null, object or passthrough cannot be a context" + } + Self::ContextNotUtf8 => "a context string is not UTF-8", + Self::EmptyContext => "the context renders empty", + Self::ContextNotLabel => { + "the context is not a label of at least two plain segments, optionally extended" + } + Self::MixedContexts => "the fields sit under different contexts or extensions", + Self::NoFields => "the plan has no fields", + Self::DuplicateField => "a field is named twice", + Self::UnknownKey => r#"a key other than "context", "outputs", "target" and "type""#, + Self::RepeatedKey => "a key is given twice", + Self::MissingContext => r#"no "context""#, + Self::MissingOutputs => r#"neither "outputs" nor "target""#, + Self::OutputsWithTarget => r#"both "outputs" and "target""#, + Self::OutputsNotList => r#""outputs" is not a list"#, + Self::UnknownOutput => r#"an output is not "c", "passthrough" or a valid index"#, + Self::NoOutputs => "no outputs", + Self::DuplicateOutput => "an output is named twice", + Self::PassthroughWithOutputs => r#""passthrough" beside another output"#, + Self::InvalidTarget => r#""target" is not a non-empty string"#, + Self::UnknownType => r#""type" does not name a value type"#, + Self::IndexNotAdmitted => "the declared type has no such index", + Self::SharedIdentity => "two fields are keyed under one identity", + Self::NoSuchField => "the plan has no such field", + Self::NotATarget => "the field does not name an EQL type", + Self::ContextField => { + r#"the context field is not a string passthrough, or "context_field" is not a string"# + } + Self::Refused => "the plan builder refused it", + Self::FieldMissing => "a field the plan names is missing", + Self::FieldRepeated => "a field is given twice", + Self::UnknownField => "a field the plan does not name", + Self::FieldType => "a value is not of the type its field declares", + Self::Passthrough => "a passthrough where a sealed value must be", + Self::OutputsNotMap => "a stored field is not a map of outputs", + Self::NoCiphertextNode => r#"no "c" node"#, + Self::NoPassthroughNode => r#"no "passthrough" node"#, + Self::NoEqlNode => r#"no "eql" node"#, + Self::NotPassthrough => "a node does not carry a value of the kind it holds", + }) + } +} diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index d8bb78eb7..332789579 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -151,7 +151,8 @@ use vitaminc_aead_value::{FfiValue, ValueKind}; use vitaminc_protected::Controlled; use super::{ - admits, utf8, Error, NoTargets, Scalar, Scope, TargetError, TargetResolver, TermBytes, Value, + admits, utf8, Error, NoTargets, Reason, Scalar, Scope, TargetError, TargetResolver, TermBytes, + Value, }; use crate::plan::{FieldValues, FieldsBuilder, Opens, Runs}; use crate::target::{CallerContext, DeclaredContext, Decryption, Encrypted, IndexSpec}; @@ -279,23 +280,28 @@ impl FieldPlan { context: NonEmpty>, outputs: Vec, ) -> Result { + let name = name.into(); + let refuse = |reason| Error::Plan { + field: Some(name.clone()), + reason, + }; if outputs.is_empty() { - return Err(Error::Plan); + return Err(refuse(Reason::NoOutputs)); } for (at, output) in outputs.iter().enumerate() { if outputs[..at] .iter() .any(|prior| prior.key() == output.key()) { - return Err(Error::Plan); + return Err(refuse(Reason::DuplicateOutput)); } } if outputs.contains(&Output::Passthrough) && outputs.len() > 1 { - return Err(Error::Plan); + return Err(refuse(Reason::PassthroughWithOutputs)); } - let (label, extension) = split_context(context.get())?; + let (label, extension) = field_context(&name, context.get())?; Ok(Self { - name: name.into(), + name, context, label, extension, @@ -320,13 +326,17 @@ impl FieldPlan { context: NonEmpty>, target: impl Into, ) -> Result { + let name = name.into(); let target = target.into(); if target.is_empty() { - return Err(Error::Plan); + return Err(Error::Plan { + field: Some(name), + reason: Reason::InvalidTarget, + }); } - let (label, extension) = split_context(context.get())?; + let (label, extension) = field_context(&name, context.get())?; Ok(Self { - name: name.into(), + name, context, label, extension, @@ -347,7 +357,10 @@ impl FieldPlan { for output in &self.outputs { if let Output::Term(index) = output { if !admits(field_type, index) { - return Err(Error::Plan); + return Err(Error::Plan { + field: Some(self.name.clone()), + reason: Reason::IndexNotAdmitted, + }); } } } @@ -444,7 +457,10 @@ impl FieldPlan { let Some((_, prefix)) = segments.split_last() else { return Err(Error::Internal); }; - Label::new(prefix).map_err(|_| Error::Plan) + Label::new(prefix).map_err(|_| Error::Plan { + field: Some(self.name.clone()), + reason: Reason::ContextNotLabel, + }) } /// What the output adapters need of the field: no context, which is the @@ -483,23 +499,66 @@ fn text_of<'a>(piece: &'a ContextPiece<'_>) -> Option<&'a str> { /// plan can give a field. A one-segment label is a label here; whether the /// plan admits one is [`Plan::new`]'s and [`Plan::with_context_field`]'s /// rule. -fn split_context(piece: &ContextPiece<'_>) -> Result<(Label, Vec>), Error> { +fn split_context(piece: &ContextPiece<'_>) -> Option<(Label, Vec>)> { let ContextPiece::List(parts) = piece else { - return Err(Error::Plan); + return None; }; if let Some(segments) = parts.iter().map(text_of).collect::>>() { if !segments.is_empty() { - let label = Label::new(segments).map_err(|_| Error::Plan)?; - return Ok((label, Vec::new())); + let label = Label::new(segments).ok()?; + return Some((label, Vec::new())); } } match parts.as_slice() { [inner, part] if !matches!(part, ContextPiece::List(_)) => { let (label, mut extension) = split_context(inner)?; extension.push(part.clone().into_owned()); - Ok((label, extension)) + Some((label, extension)) + } + _ => None, + } +} + +/// [`split_context`] for the field `name`, refusing a context that is not +/// a label, optionally extended, as that field's. +fn field_context( + name: &str, + piece: &ContextPiece<'_>, +) -> Result<(Label, Vec>), Error> { + split_context(piece).ok_or_else(|| Error::Plan { + field: Some(name.to_owned()), + reason: Reason::ContextNotLabel, + }) +} + +/// A refusal of the plan builder or the engine's own check, as the field it +/// names and the nearest [`Reason`]. Matched exhaustively, so a new +/// [`PlanError`](crate::PlanError) says here which reason it reads as; an +/// engine failure that is not a plan refusal is [`Reason::Refused`]. +fn refusal(error: crate::Error) -> (Option, Reason) { + use crate::PlanError as P; + let crate::Error::Plan(error) = error else { + return (None, Reason::Refused); + }; + match error { + P::ContextLabel(_) => (None, Reason::ContextNotLabel), + P::FieldLabel { field, .. } => (Some(field), Reason::ContextNotLabel), + P::DuplicateField { field } => (Some(field), Reason::DuplicateField), + P::SharedIdentity { second, .. } => (Some(second), Reason::SharedIdentity), + P::PassthroughIndexed { field } => (Some(field), Reason::PassthroughWithOutputs), + P::DuplicateIndex { at, .. } => (Some(at), Reason::DuplicateOutput), + P::EmptyIndexes => (None, Reason::NoOutputs), + P::NotInPlan { field } => (Some(field), Reason::UnknownField), + P::NotInValue { field } => (Some(field), Reason::FieldMissing), + P::FieldType { field, .. } => (Some(field), Reason::FieldType), + P::NoSuchField { field } => (Some(field), Reason::NoSuchField), + P::TargetWithVerbs { field } => (Some(field), Reason::OutputsWithTarget), + P::IndexNotDeclared { field, .. } | P::IndexOptions { field, .. } => { + (Some(field), Reason::Refused) + } + P::IdentityWithoutField | P::MixedCiphers | P::TwoContextSources { .. } | P::NoContext => { + (None, Reason::Refused) } - _ => Err(Error::Plan), } } @@ -568,7 +627,7 @@ impl Plan { resolver: &(impl TargetResolver + ?Sized), ) -> Result { let Some(first) = fields.first() else { - return Err(Error::Plan); + return Err(Error::bad_plan(Reason::NoFields)); }; let context = first.prefix()?; let extension = first.extension.clone(); @@ -576,7 +635,10 @@ impl Plan { // `prefix` refuses a one-segment label, which has nothing to // sit under; a longer one must sit under the first field's. if field.prefix()? != context { - return Err(Error::Plan); + return Err(Error::Plan { + field: Some(field.name.clone()), + reason: Reason::MixedContexts, + }); } } for field in fields.iter_mut().filter(|field| field.is_target()) { @@ -645,23 +707,28 @@ impl Plan { fields: Vec, ) -> Result { let context_field = context_field.into(); + let refuse = |reason| Error::Plan { + field: Some(context_field.clone()), + reason, + }; let Some(field) = fields.iter().find(|field| field.name == context_field) else { - return Err(Error::Plan); + return Err(refuse(Reason::NoSuchField)); }; - if field.outputs != [Output::Passthrough] { - return Err(Error::Plan); - } - if field - .field_type - .is_some_and(|kind| kind != ValueKind::String) + if field.outputs != [Output::Passthrough] + || field + .field_type + .is_some_and(|kind| kind != ValueKind::String) { - return Err(Error::Plan); + return Err(refuse(Reason::ContextField)); } - if fields + if let Some(field) = fields .iter() - .any(|field| field.label.segments().count() != 1) + .find(|field| field.label.segments().count() != 1) { - return Err(Error::Plan); + return Err(Error::Plan { + field: Some(field.name.clone()), + reason: Reason::ContextNotLabel, + }); } // A target field's label must be a column, `/`, // which a context field's one-segment identity is not: the table @@ -693,15 +760,19 @@ impl Plan { /// every field with a term output typed, and a whole the builder accepts. fn build(context: Context, fields: Vec) -> Result { let Some(first) = fields.first() else { - return Err(Error::Plan); + return Err(Error::bad_plan(Reason::NoFields)); }; let extension = first.extension.clone(); for (at, field) in fields.iter().enumerate() { + let refuse = |reason| Error::Plan { + field: Some(field.name.clone()), + reason, + }; if fields[..at].iter().any(|prior| prior.name == field.name) { - return Err(Error::Plan); + return Err(refuse(Reason::DuplicateField)); } if field.extension != extension { - return Err(Error::Plan); + return Err(refuse(Reason::MixedContexts)); } // An indexed field declares its type: every value's term derives // from the one declared kind, never from whatever tag each value @@ -719,14 +790,14 @@ impl Plan { .iter() .any(|prior| prior.identity() == field.identity()) { - return Err(Error::Plan); + return Err(refuse(Reason::SharedIdentity)); } if !field.is_target() && fields[..at] .iter() .any(|prior| prior.is_target() && prior.identity() == field.identity()) { - return Err(Error::Plan); + return Err(refuse(Reason::SharedIdentity)); } } let plan = Self { @@ -738,7 +809,10 @@ impl Plan { // no shared identity) are checked by building, so a plan in hand // lowers. `()` stands in for the key source: the check does not // depend on it. - let _ = plan.lower::<()>().map_err(|_| Error::Plan)?; + let _ = plan.lower::<()>().map_err(|error| { + let (field, reason) = refusal(error); + Error::Plan { field, reason } + })?; Ok(plan) } @@ -992,7 +1066,7 @@ pub fn plan_with( resolver: &(impl TargetResolver + ?Sized), ) -> Result { let FfiValue::Object(entries) = value else { - return Err(Error::Plan); + return Err(Error::bad_plan(Reason::NotAnObject)); }; let mut fields: Vec = Vec::with_capacity(entries.len()); let mut context_field: Option = None; @@ -1001,14 +1075,22 @@ pub fn plan_with( // Reserved: the plan-level key, never a field. A second one, or // one that is not a string, is refused rather than read as a // field spec. - let (None, FfiValue::String(s)) = (&context_field, &spec) else { - return Err(Error::Plan); + if context_field.is_some() { + return Err(Error::bad_plan(Reason::RepeatedKey)); + } + let FfiValue::String(s) = &spec else { + return Err(Error::bad_plan(Reason::ContextField)); }; - context_field = Some(utf8(s).ok_or(Error::Plan)?.to_owned()); + let s = utf8(s).ok_or(Error::bad_plan(Reason::ContextField))?; + context_field = Some(s.to_owned()); continue; } + let refuse = |reason| Error::Plan { + field: Some(name.clone()), + reason, + }; let FfiValue::Object(spec) = spec else { - return Err(Error::Plan); + return Err(refuse(Reason::NotAnObject)); }; let mut context: Option>> = None; let mut outputs: Option> = None; @@ -1016,40 +1098,51 @@ pub fn plan_with( let mut field_type: Option = None; for (key, value) in spec { match key.as_str() { - "context" if context.is_none() => context = Some(super::context(value)?), + "context" if context.is_none() => { + context = Some(super::context(value).map_err(|error| error.in_field(&name))?); + } "target" if target.is_none() => { let FfiValue::String(s) = &value else { - return Err(Error::Plan); + return Err(refuse(Reason::InvalidTarget)); }; - target = Some(utf8(s).ok_or(Error::Plan)?.to_owned()); + target = Some( + utf8(s) + .ok_or_else(|| refuse(Reason::InvalidTarget))? + .to_owned(), + ); } "outputs" if outputs.is_none() => { let FfiValue::Array(items) = value else { - return Err(Error::Plan); + return Err(refuse(Reason::OutputsNotList)); }; let parsed = items .iter() .map(Output::from_value) - .collect::, _>>()?; + .collect::, _>>() + .map_err(|error| error.in_field(&name))?; outputs = Some(parsed); } "type" if field_type.is_none() => { let FfiValue::String(s) = &value else { - return Err(Error::Plan); + return Err(refuse(Reason::UnknownType)); }; - let name = utf8(s).ok_or(Error::Plan)?; - field_type = Some(name.parse().map_err(|_| Error::Plan)?); + let kind = utf8(s).ok_or_else(|| refuse(Reason::UnknownType))?; + field_type = Some(kind.parse().map_err(|_| refuse(Reason::UnknownType))?); + } + // One of the four given twice. + "context" | "target" | "outputs" | "type" => { + return Err(refuse(Reason::RepeatedKey)) } - // An unknown key, or one of the four given twice. - _ => return Err(Error::Plan), + _ => return Err(refuse(Reason::UnknownKey)), } } - let context = context.ok_or(Error::Plan)?; + let context = context.ok_or_else(|| refuse(Reason::MissingContext))?; // One form or the other: a field with both, or neither, is refused. let field = match (outputs, target) { (Some(outputs), None) => FieldPlan::new(name, context, outputs)?, (None, Some(target)) => FieldPlan::with_target(name, context, target)?, - _ => return Err(Error::Plan), + (Some(_), Some(_)) => return Err(refuse(Reason::OutputsWithTarget)), + (None, None) => return Err(refuse(Reason::MissingOutputs)), }; fields.push(match field_type { Some(field_type) => field.with_type(field_type)?, @@ -1241,8 +1334,14 @@ pub fn query<'a, K: 'static>( .fields .iter() .find(|candidate| candidate.name == field) - .ok_or(Error::Plan)?; - let name = field.target().ok_or(Error::Plan)?; + .ok_or_else(|| Error::Plan { + field: Some(field.to_owned()), + reason: Reason::NoSuchField, + })?; + let name = field.target().ok_or_else(|| Error::Plan { + field: Some(field.name.clone()), + reason: Reason::NotATarget, + })?; check_field(&value, field)?; resolver .query(name, cipher, field.label(), value) @@ -1440,7 +1539,10 @@ pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { let rows = source_rows(source, plan)?; let lowered = plan.lower::<()>().map_err(|_| Error::Internal)?; let check = |row: &Row| { - Runs::::check(&lowered, &row.values, None).map_err(|_| Error::Source) + Runs::::check(&lowered, &row.values, None).map_err(|error| { + let (field, reason) = refusal(error); + Error::Source { field, reason } + }) }; match &rows { Rows::One(row) => check(row), @@ -1477,7 +1579,10 @@ pub fn check_record( Opens::::check(&lowered, &row.values, expected).map_err( |error| match error { mismatch @ crate::Error::ContextMismatch { .. } => Error::Cipher(mismatch), - _ => Error::Record, + error => { + let (field, reason) = refusal(error); + Error::Record { field, reason } + } }, ) }; @@ -1495,8 +1600,9 @@ pub fn check_record( /// inside it: a source value ([`FfiValue`]) or a stored ciphertext /// ([`StackCipherText`]), walked the one way the record rules need. trait RecordTree: Sized { - /// The error a tree that does not fit its plan reports. - const MISFIT: Error; + /// The error a tree that does not fit its plan reports, for the field + /// at fault. + fn misfit(field: &str, reason: Reason) -> Error; /// Whether this node is a passthrough. fn is_passthrough(&self) -> bool; @@ -1513,7 +1619,12 @@ enum Children<'a, T> { } impl RecordTree for FfiValue { - const MISFIT: Error = Error::Source; + fn misfit(field: &str, reason: Reason) -> Error { + Error::Source { + field: Some(field.to_owned()), + reason, + } + } fn is_passthrough(&self) -> bool { matches!(self, FfiValue::Passthrough(_)) @@ -1529,7 +1640,12 @@ impl RecordTree for FfiValue { } impl RecordTree for StackCipherText { - const MISFIT: Error = Error::Record; + fn misfit(field: &str, reason: Reason) -> Error { + Error::Record { + field: Some(field.to_owned()), + reason, + } + } fn is_passthrough(&self) -> bool { matches!(self, CipherText::Passthrough(_)) @@ -1587,6 +1703,17 @@ fn take(row: &mut Vec<(String, T)>, name: &str) -> Option<(String, T)> { Some(row.swap_remove(at)) } +/// Why [`take`] found no single `name` in `entries`: `repeated` if the key +/// is there more than once, `missing` if it is not there at all. Read before +/// anything is taken, so the count is the row's own. +fn absence(entries: &[(String, T)], name: &str, missing: Reason, repeated: Reason) -> Reason { + if entries.iter().any(|(key, _)| key == name) { + repeated + } else { + missing + } +} + /// Whether no key in `entries` repeats. fn keys_are_unique(entries: &[(String, T)]) -> bool { let mut seen = std::collections::HashSet::with_capacity(entries.len()); @@ -1610,17 +1737,19 @@ fn keys_are_unique(entries: &[(String, T)]) -> bool { /// on the encrypt side a passthrough inside a sealed field's value would /// produce a `"c"` subtree whose bytes verify nothing; on the decrypt side a /// passthrough under `"c"` would be handed back as if it had been opened. -fn check_tree(tree: &T) -> Result<(), Error> { +fn check_tree(tree: &T, field: &str) -> Result<(), Error> { if tree.is_passthrough() { - return Err(T::MISFIT); + return Err(T::misfit(field, Reason::Passthrough)); } match tree.children() { - Children::Sequence(items) => items.iter().try_for_each(check_tree), + Children::Sequence(items) => items.iter().try_for_each(|item| check_tree(item, field)), Children::Map(entries) => { if !keys_are_unique(entries) { - return Err(T::MISFIT); + return Err(T::misfit(field, Reason::RepeatedKey)); } - entries.iter().try_for_each(|(_, node)| check_tree(node)) + entries + .iter() + .try_for_each(|(_, node)| check_tree(node, field)) } Children::None => Ok(()), } @@ -1645,22 +1774,29 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result, Error> { .into_iter() .map(|item| match item { FfiValue::Object(row) => source_row(row, plan), - _ => Err(Error::Source), + _ => Err(Error::bad_source(Reason::NotAnObject)), }) .collect::, _>>()?, )), - _ => Err(Error::Source), + _ => Err(Error::bad_source(Reason::NotAnObject)), } } fn source_row(mut row: Vec<(String, FfiValue)>, plan: &Plan) -> Result { - if row.len() != plan.fields.len() { - return Err(Error::Source); - } let mut values = FieldValues::new(); let mut targets = Vec::new(); for field in &plan.fields { - let (_, value) = take(&mut row, &field.name).ok_or(Error::Source)?; + let Some((_, value)) = take(&mut row, &field.name) else { + return Err(FfiValue::misfit( + &field.name, + absence( + &row, + &field.name, + Reason::FieldMissing, + Reason::FieldRepeated, + ), + )); + }; check_field(&value, field)?; if field.is_target() { targets.push(value); @@ -1668,6 +1804,11 @@ fn source_row(mut row: Vec<(String, FfiValue)>, plan: &Plan) -> Result, plan: &Plan) -> Result Result<(), Error> { if let Some(declared) = field.field_type { if !declared.holds(value) { - return Err(Error::Source); + return Err(FfiValue::misfit(&field.name, Reason::FieldType)); } } // A target field's value is sealed by the type's own plan, which // refuses a passthrough as any ciphertext does; the same walk here. if field.is_target() { - check_tree(value)?; + check_tree(value, &field.name)?; } for output in &field.outputs { match output { - Output::Ciphertext => check_tree(value)?, + Output::Ciphertext => check_tree(value, &field.name)?, Output::Term(kind) => { - let scalar = Scalar::of(value, kind)?; + let scalar = + Scalar::of(value, kind).map_err(|error| error.in_field(&field.name))?; if !kind.supports(&scalar) { - return Err(Error::Term { kind: kind.clone() }); + return Err(Error::Term { + field: Some(field.name.clone()), + kind: kind.clone(), + }); } } Output::Passthrough => {} @@ -1851,11 +1996,11 @@ fn record_rows(tree: StackCipherText, plan: &Plan) -> Result, Er .into_iter() .map(|item| match item { CipherText::Map(row) => record_row(row, plan), - _ => Err(Error::Record), + _ => Err(Error::bad_record(Reason::NotAnObject)), }) .collect::, _>>()?, )), - _ => Err(Error::Record), + _ => Err(Error::bad_record(Reason::NotAnObject)), } } @@ -1863,38 +2008,56 @@ fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result { // The EQL value: a passthrough carrying bytes, exactly once. // What it opens to is the type's own decryption's to decide. - let (_, node) = take(&mut outputs, EQL_KEY).ok_or(Error::Record)?; - let CipherText::Passthrough(payload) = node else { - return Err(Error::Record); + let CipherText::Passthrough(payload) = node(EQL_KEY, Reason::NoEqlNode)? else { + return Err(misfit(Reason::NotPassthrough)); }; - let value = *payload.downcast::().map_err(|_| Error::Record)?; + let value = *payload + .downcast::() + .map_err(|_| misfit(Reason::NotPassthrough))?; let FfiValue::Bytes(bytes) = value else { - return Err(Error::Record); + return Err(misfit(Reason::NotPassthrough)); }; targets.push(bytes.risky_unwrap()); } Verb::Passthrough => { - let (_, node) = take(&mut outputs, "passthrough").ok_or(Error::Record)?; - let CipherText::Passthrough(payload) = node else { - return Err(Error::Record); + let CipherText::Passthrough(payload) = + node("passthrough", Reason::NoPassthroughNode)? + else { + return Err(misfit(Reason::NotPassthrough)); }; - let value = *payload.downcast::().map_err(|_| Error::Record)?; + let value = *payload + .downcast::() + .map_err(|_| misfit(Reason::NotPassthrough))?; if field.field_type.is_some_and(|kind| !kind.holds(&value)) { - return Err(Error::Record); + return Err(misfit(Reason::FieldType)); } let _ = values.insert(&field.name, Value::new(value)); } Verb::Encrypt | Verb::EncryptIndex | Verb::Index => { - let (_, ciphertext) = take(&mut outputs, "c").ok_or(Error::Record)?; - check_tree(&ciphertext)?; + let ciphertext = node("c", Reason::NoCiphertextNode)?; + check_tree(&ciphertext, &field.name)?; let _ = values.insert(&field.name, ciphertext); } } @@ -2267,13 +2430,15 @@ mod tests { fn refuses_a_malformed_plan_before_any_field_is_built() { let cases: Vec = vec![ ("a plan that is not an object", s("x"), |e| { - matches!(e, Error::Plan) + matches!(e, Error::Plan { .. }) + }), + ("an empty plan", obj(vec![]), |e| { + matches!(e, Error::Plan { .. }) }), - ("an empty plan", obj(vec![]), |e| matches!(e, Error::Plan)), ( "a field spec that is not an object", obj(vec![("age", s("x"))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a field spec with an unknown key", @@ -2285,22 +2450,22 @@ mod tests { ("nullable", FfiValue::Bool(true)), ]), )]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a field spec with no context", obj(vec![("age", obj(vec![("outputs", strings(&["c"]))]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a field spec with no outputs", obj(vec![("age", obj(vec![("context", label("age"))]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "outputs that are not a list", obj(vec![("age", spec(label("age"), &[]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "an output that is not a string", @@ -2311,27 +2476,27 @@ mod tests { ("outputs", FfiValue::Array(vec![FfiValue::UInt32(1)])), ]), )]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "an unknown output", obj(vec![("age", spec(label("age"), &["c", "sum"]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "an output named twice", obj(vec![("age", spec(label("age"), &["c", "eq", "c"]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "passthrough beside a ciphertext", obj(vec![("age", spec(label("age"), &["passthrough", "c"]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "passthrough beside an index", obj(vec![("age", spec(label("age"), &["eq", "passthrough"]))]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a field named twice", @@ -2339,7 +2504,7 @@ mod tests { ("age".to_string(), spec(label("age"), &["c"])), ("age".to_string(), spec(label("age"), &["eq"])), ]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a context given twice", @@ -2351,7 +2516,7 @@ mod tests { ("context", label("other")), ]), )]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "outputs given twice", @@ -2363,17 +2528,17 @@ mod tests { ("outputs", strings(&["eq"])), ]), )]), - |e| matches!(e, Error::Plan), + |e| matches!(e, Error::Plan { .. }), ), ( "a context that is not one", obj(vec![("age", spec(FfiValue::Bool(true), &["c"]))]), - |e| matches!(e, Error::Context), + |e| matches!(e, Error::Context { .. }), ), ( "a context that renders empty", obj(vec![("age", spec(s(""), &["c"]))]), - |e| matches!(e, Error::Context), + |e| matches!(e, Error::Context { .. }), ), ]; for (label, value, expected) in cases { @@ -2416,7 +2581,10 @@ mod tests { ]; for (what, context) in refused { let result = plan(obj(vec![("age", spec(context, &["c"]))])); - assert!(matches!(result, Err(Error::Plan)), "{what}: {result:?}"); + assert!( + matches!(result, Err(Error::Plan { .. })), + "{what}: {result:?}" + ); } } @@ -2429,7 +2597,7 @@ mod tests { ("total", spec(strings(&["orders", "total"]), &["c"])), ])); assert!( - matches!(parsed, Err(Error::Plan)), + matches!(parsed, Err(Error::Plan { .. })), "two contexts: {parsed:?}" ); let parsed = plan(obj(vec![ @@ -2443,7 +2611,7 @@ mod tests { ("email", spec(label("email"), &["c"])), ])); assert!( - matches!(parsed, Err(Error::Plan)), + matches!(parsed, Err(Error::Plan { .. })), "one field extended, one not: {parsed:?}" ); let parsed = plan(obj(vec![ @@ -2463,7 +2631,7 @@ mod tests { ), ])); assert!( - matches!(parsed, Err(Error::Plan)), + matches!(parsed, Err(Error::Plan { .. })), "two extensions: {parsed:?}" ); // The same prefix spelled deeper is still one context. @@ -2486,7 +2654,7 @@ mod tests { ("mail", typed(label("email"), &["c", "eq"], "string")), ("mail2", typed(label("email"), &["c", "eq"], "string")), ])); - assert!(matches!(parsed, Err(Error::Plan)), "{parsed:?}"); + assert!(matches!(parsed, Err(Error::Plan { .. })), "{parsed:?}"); // Two passthrough fields key nothing, so they may share a label. let parsed = plan(obj(vec![ ("a", spec(label("meta"), &["passthrough"])), @@ -2518,7 +2686,10 @@ mod tests { fn a_field_plan_refuses_no_outputs_and_a_repeated_output() { let ctx = context(label("age")).expect("context"); assert!( - matches!(FieldPlan::new("age", ctx.clone(), vec![]), Err(Error::Plan)), + matches!( + FieldPlan::new("age", ctx.clone(), vec![]), + Err(Error::Plan { .. }) + ), "a field must produce something" ); assert!( @@ -2528,7 +2699,7 @@ mod tests { ctx.clone(), vec![Output::Term(IndexSpec::Ore), Output::Term(IndexSpec::Ore)] ), - Err(Error::Plan) + Err(Error::Plan { .. }) ), "an output cannot be produced twice under one key" ); @@ -2539,7 +2710,7 @@ mod tests { ctx.clone(), vec![Output::Passthrough, Output::Ciphertext] ), - Err(Error::Plan) + Err(Error::Plan { .. }) ), "a passthrough field has no other output" ); @@ -2598,7 +2769,7 @@ mod tests { ]), )])); assert!( - matches!(parsed, Err(Error::Plan)), + matches!(parsed, Err(Error::Plan { .. })), "two match outputs are one key twice" ); let ctx = context(label("nick")).expect("context"); @@ -2613,7 +2784,10 @@ mod tests { })), ], ); - assert!(matches!(by_hand, Err(Error::Plan)), "and by hand alike"); + assert!( + matches!(by_hand, Err(Error::Plan { .. })), + "and by hand alike" + ); } /// An output list entry is `"c"`, `"passthrough"` or an index in its @@ -2640,7 +2814,7 @@ mod tests { ("outputs", FfiValue::Array(vec![output])), ]), )])); - assert!(matches!(parsed, Err(Error::Plan)), "{label_}"); + assert!(matches!(parsed, Err(Error::Plan { .. })), "{label_}"); } assert_eq!(Output::parse("c"), Some(Output::Ciphertext)); assert_eq!(Output::parse("passthrough"), Some(Output::Passthrough)); @@ -2664,13 +2838,13 @@ mod tests { .expect("field") }; assert!( - matches!(Plan::new(vec![]), Err(Error::Plan)), + matches!(Plan::new(vec![]), Err(Error::Plan { .. })), "a plan must have a field" ); assert!( matches!( Plan::new(vec![field("age"), field("age")]), - Err(Error::Plan) + Err(Error::Plan { .. }) ), "a field cannot be planned twice" ); @@ -2806,12 +2980,12 @@ mod tests { FfiValue::Array(vec![s("a@x"), FfiValue::Passthrough(Box::new(s("b@x")))]); let cases: Vec = vec![ ("a source that is not an object", FfiValue::UInt32(1), |e| { - matches!(e, Error::Source) + matches!(e, Error::Source { .. }) }), ( "a batch with an item that is not an object", FfiValue::Array(vec![row(1), FfiValue::UInt32(2)]), - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a row missing a plan field", @@ -2820,7 +2994,7 @@ mod tests { ("email", s("a@x")), ("nick", s("al")), ]), - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a row with a field the plan does not name", @@ -2829,7 +3003,7 @@ mod tests { entries.push(("extra".to_string(), s("x"))); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a row with a field the plan does not name in place of one it does", @@ -2838,7 +3012,7 @@ mod tests { entries[3].0 = "extra".to_string(); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a passthrough under a sealed field", @@ -2847,12 +3021,12 @@ mod tests { entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a passthrough inside a list under a sealed field", FfiValue::Object(with_passthrough_in_a_list), - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a plan field given twice", @@ -2862,7 +3036,7 @@ mod tests { entries.push(("age".to_string(), FfiValue::UInt32(2))); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a repeated key inside an object under a sealed field", @@ -2871,7 +3045,7 @@ mod tests { entries[1].1 = obj(vec![("k", s("a@x")), ("k", s("b@x"))]); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a container under an indexed field, which declares a scalar kind", @@ -2880,7 +3054,7 @@ mod tests { entries[2].1 = FfiValue::Array(vec![s("al")]); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ( "a scalar of another kind under an indexed field", @@ -2889,7 +3063,7 @@ mod tests { entries[2].1 = FfiValue::UInt32(3); FfiValue::Object(entries) }, - |e| matches!(e, Error::Source), + |e| matches!(e, Error::Source { .. }), ), ]; for (label_, source, expected) in cases { @@ -2906,21 +3080,21 @@ mod tests { ]); let err = encrypt(&keyset, missing, &plan).err(); assert!( - matches!(err, Some(Error::Source)), + matches!(err, Some(Error::Source { .. })), "encrypt refuses a row missing a plan field: {err:?}" ); let mut entries = object(row(1)); entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); let err = encrypt(&keyset, FfiValue::Object(entries), &plan).err(); assert!( - matches!(err, Some(Error::Source)), + matches!(err, Some(Error::Source { .. })), "encrypt refuses a passthrough under a sealed field: {err:?}" ); let mut entries = object(row(1)); entries[2].1 = FfiValue::UInt32(3); let err = encrypt(&keyset, FfiValue::Object(entries), &plan).err(); assert!( - matches!(err, Some(Error::Source)), + matches!(err, Some(Error::Source { .. })), "encrypt refuses a value of another kind than the indexed field declares: {err:?}" ); assert_eq!( @@ -2944,7 +3118,7 @@ mod tests { typed(label("score"), &["c", "eq"], "float64"), )])); assert!( - matches!(as_float, Err(Error::Plan)), + matches!(as_float, Err(Error::Plan { .. })), "no PRF encoding exists for a float: {as_float:?}" ); let as_u32 = plan(obj(vec![( @@ -2955,7 +3129,7 @@ mod tests { let source = obj(vec![("score", FfiValue::Float64(1.5))]); let err = encrypt(&keyset, source, &as_u32).err(); assert!( - matches!(err, Some(Error::Source)), + matches!(err, Some(Error::Source { .. })), "a float is not the declared kind: {err:?}" ); assert_eq!(generates(&cipher), 0, "refused before any key request"); @@ -3688,7 +3862,7 @@ mod tests { for (label_, record) in cases { let err = decrypt(Scope::Client(&cipher), record, &plan, None).err(); assert!( - matches!(err, Some(Error::Record)), + matches!(err, Some(Error::Record { .. })), "{label_}: decrypt must refuse it as a misfit record: {err:?}" ); } @@ -3706,7 +3880,7 @@ mod tests { fields.push(("age".to_string(), CipherText::Map(age))); let err = check_record(CipherText::Map(fields), &plan, None).err(); assert!( - matches!(err, Some(Error::Record)), + matches!(err, Some(Error::Record { .. })), "check_record refuses a forged ciphertext the same way: {err:?}" ); let mut fields = sealed(&keyset).await; @@ -3717,7 +3891,7 @@ mod tests { fields.push(("age".to_string(), CipherText::Map(age))); let err = check_record(CipherText::Map(fields), &plan, None).err(); assert!( - matches!(err, Some(Error::Record)), + matches!(err, Some(Error::Record { .. })), "check_record refuses a twice-given ciphertext the same way: {err:?}" ); } @@ -3955,7 +4129,10 @@ mod tests { ]; for (what, value) in refused { let result = plan(value); - assert!(matches!(result, Err(Error::Plan)), "{what}: {result:?}"); + assert!( + matches!(result, Err(Error::Plan { .. })), + "{what}: {result:?}" + ); } } @@ -3980,19 +4157,19 @@ mod tests { assert!( matches!( Plan::with_context_field("nope", vec![tenant(), age()]), - Err(Error::Plan) + Err(Error::Plan { .. }) ), "a field the plan does not name" ); assert!( matches!( Plan::with_context_field("age", vec![tenant(), age()]), - Err(Error::Plan) + Err(Error::Plan { .. }) ), "a sealed field as the context" ); assert!( - matches!(Plan::new(vec![tenant(), age()]), Err(Error::Plan)), + matches!(Plan::new(vec![tenant(), age()]), Err(Error::Plan { .. })), "one-segment labels need a context field" ); } @@ -4223,17 +4400,17 @@ mod tests { }; assert!(matches!( check_source(not_text(), &plan), - Err(Error::Source) + Err(Error::Source { .. }) )); assert!(matches!( encrypt(&keyset, not_text(), &plan), - Err(Error::Source) + Err(Error::Source { .. }) )); let not_a_label = || row("tenants/(acme)", 34); assert!(matches!( check_source(not_a_label(), &plan), - Err(Error::Source) + Err(Error::Source { .. }) )); let failed = encrypt(&keyset, not_a_label(), &plan) .expect("the source fits the plan's shape") @@ -4249,7 +4426,7 @@ mod tests { let sealed = seal(&keyset, row("tenants/acme", 34), &plan).await; assert!(matches!( check_record(with_stored_context(sealed, "tenants/(acme)"), &plan, None), - Err(Error::Record) + Err(Error::Record { .. }) )); let sealed = seal(&keyset, row("tenants/acme", 34), &plan).await; let retrieved = retrieves(&cipher); @@ -4279,7 +4456,7 @@ mod tests { let sealed = seal(&keyset, super::row(34), &plan).await; assert!(matches!( check_record(sealed, &plan, expected("users").as_ref()), - Err(Error::Record) + Err(Error::Record { .. }) )); let sealed = seal(&keyset, super::row(34), &plan).await; let failed = decrypt(Scope::Client(&cipher), sealed, &plan, expected("users")) @@ -4466,7 +4643,7 @@ mod tests { ); let refused = plan(obj(vec![("age", early("float64"))])); assert!( - matches!(refused, Err(Error::Plan)), + matches!(refused, Err(Error::Plan { .. })), "equality on a float is refused whatever the key order: {refused:?}" ); } @@ -4512,7 +4689,10 @@ mod tests { ]; for (label_, field) in refused { let result = plan(obj(vec![("x", field)])); - assert!(matches!(result, Err(Error::Plan)), "{label_}: {result:?}"); + assert!( + matches!(result, Err(Error::Plan { .. })), + "{label_}: {result:?}" + ); } } @@ -4532,7 +4712,7 @@ mod tests { assert_eq!(typed.field_type(), Some(ValueKind::UInt32)); assert!(matches!( field.with_type(ValueKind::Float32), - Err(Error::Plan) + Err(Error::Plan { .. }) )); let sealed_only = FieldPlan::new( "doc", @@ -4556,7 +4736,7 @@ mod tests { let u64_plan = age_plan("uint64"); let check = check_source(obj(vec![("age", FfiValue::UInt32(34))]), &u64_plan); assert!( - matches!(check, Err(Error::Source)), + matches!(check, Err(Error::Source { .. })), "check_source refuses it too" ); for (label_, value) in [ @@ -4566,14 +4746,14 @@ mod tests { ] { let result = encrypt(&keyset, obj(vec![("age", value)]), &u64_plan).err(); assert!( - matches!(result, Some(Error::Source)), + matches!(result, Some(Error::Source { .. })), "{label_}: {result:?}" ); } let as_u32 = age_plan("uint32"); let result = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &as_u32).err(); assert!( - matches!(result, Some(Error::Source)), + matches!(result, Some(Error::Source { .. })), "a u64 for a uint32 field: {result:?}" ); let as_string = @@ -4585,7 +4765,7 @@ mod tests { ) .err(); assert!( - matches!(result, Some(Error::Source)), + matches!(result, Some(Error::Source { .. })), "a u32 for a string field: {result:?}" ); assert_eq!(generates(&cipher), 0, "refused before any key request"); @@ -4915,7 +5095,7 @@ mod tests { &plan, ) .err(); - assert!(matches!(refused, Some(Error::Source)), "{refused:?}"); + assert!(matches!(refused, Some(Error::Source { .. })), "{refused:?}"); let sealed = seal( &keyset, obj(vec![ @@ -4938,7 +5118,7 @@ mod tests { )); let refused = decrypt(Scope::Client(&cipher), CipherText::Map(fields), &plan, None).err(); - assert!(matches!(refused, Some(Error::Record)), "{refused:?}"); + assert!(matches!(refused, Some(Error::Record { .. })), "{refused:?}"); } } @@ -5382,7 +5562,7 @@ mod tests { let one = FieldPlan::with_target("email", ctx(&["users"]), TEXT_EQ) .expect("one segment is a label; the plan decides"); assert!( - matches!(Plan::new_with(vec![one], &FakeEql), Err(Error::Plan)), + matches!(Plan::new_with(vec![one], &FakeEql), Err(Error::Plan { .. })), "under a plan with a context of its own, one segment has nothing to sit under" ); let two = FieldPlan::with_target("email", ctx(&["users", "email"]), TEXT_EQ) @@ -5490,7 +5670,7 @@ mod tests { )); assert!(matches!( check_record(CipherText::Map(row), &plan, None), - Err(Error::Record) + Err(Error::Record { .. }) )); let mut row = seal_mixed(&keyset, &plan).await; row.retain(|(k, _)| k != "email"); @@ -5505,7 +5685,7 @@ mod tests { None, &FakeEql, )); - assert!(matches!(error, Error::Record), "{error:?}"); + assert!(matches!(error, Error::Record { .. }), "{error:?}"); assert_eq!(retrieves(&cipher), 0); } @@ -5546,12 +5726,12 @@ mod tests { ]); assert!(matches!( plan_with(obj(vec![("email", both)]), &FakeEql), - Err(Error::Plan) + Err(Error::Plan { .. }) )); let neither = obj(vec![("context", label("email"))]); assert!(matches!( plan_with(obj(vec![("email", neither)]), &FakeEql), - Err(Error::Plan) + Err(Error::Plan { .. }) )); let not_text = obj(vec![ ("context", label("email")), @@ -5559,12 +5739,12 @@ mod tests { ]); assert!(matches!( plan_with(obj(vec![("email", not_text)]), &FakeEql), - Err(Error::Plan) + Err(Error::Plan { .. }) )); let empty = obj(vec![("context", label("email")), ("target", s(""))]); assert!(matches!( plan_with(obj(vec![("email", empty)]), &FakeEql), - Err(Error::Plan) + Err(Error::Plan { .. }) )); let twice = obj(vec![ ("context", label("email")), @@ -5573,7 +5753,7 @@ mod tests { ]); assert!(matches!( plan_with(obj(vec![("email", twice)]), &FakeEql), - Err(Error::Plan) + Err(Error::Plan { .. }) )); } @@ -5602,17 +5782,20 @@ mod tests { ); // A value of another kind is refused before the resolver runs. let wrong = obj(vec![("email", FfiValue::UInt32(7))]); - assert!(matches!(check_source(wrong, &plan), Err(Error::Source))); + assert!(matches!( + check_source(wrong, &plan), + Err(Error::Source { .. }) + )); let wrong = obj(vec![("email", FfiValue::UInt32(7))]); assert!(matches!( refused(encrypt_with(&keyset, wrong, &plan, &FakeEql)), - Error::Source + Error::Source { .. } )); // A passthrough is refused as it is for any sealed field. let forged = obj(vec![("email", FfiValue::Passthrough(Box::new(s("a@x"))))]); assert!(matches!( refused(encrypt_with(&keyset, forged, &plan, &FakeEql)), - Error::Source + Error::Source { .. } )); assert_eq!(generates(&cipher), 0); } @@ -5690,7 +5873,10 @@ mod tests { .unwrap(), FieldPlan::with_target("email", context(label("email")).unwrap(), TEXT_EQ).unwrap(), ]; - assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + assert!(matches!( + Plan::new_with(fields, &FakeEql), + Err(Error::Plan { .. }) + )); let fields = vec![ FieldPlan::with_target("email", context(label("email")).unwrap(), TEXT_EQ).unwrap(), FieldPlan::new( @@ -5700,12 +5886,18 @@ mod tests { ) .unwrap(), ]; - assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + assert!(matches!( + Plan::new_with(fields, &FakeEql), + Err(Error::Plan { .. }) + )); let fields = vec![ FieldPlan::with_target("a", context(label("email")).unwrap(), TEXT_EQ).unwrap(), FieldPlan::with_target("b", context(label("email")).unwrap(), TEXT_EQ).unwrap(), ]; - assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + assert!(matches!( + Plan::new_with(fields, &FakeEql), + Err(Error::Plan { .. }) + )); } #[tokio::test] @@ -5732,7 +5924,7 @@ mod tests { let record = with_email(seal_mixed(&keyset, &plan).await, misplaced); assert!(matches!( check_record(record, &plan, None), - Err(Error::Record) + Err(Error::Record { .. }) )); // The node twice. let mut row = seal_mixed(&keyset, &plan).await; @@ -5747,7 +5939,7 @@ mod tests { row.push(("email".to_string(), CipherText::Map(outputs))); assert!(matches!( check_record(CipherText::Map(row), &plan, None), - Err(Error::Record) + Err(Error::Record { .. }) )); // A payload that is not bytes. let null = CipherText::Map(vec![( @@ -5757,7 +5949,7 @@ mod tests { let record = with_email(seal_mixed(&keyset, &plan).await, null); assert!(matches!( check_record(record, &plan, None), - Err(Error::Record) + Err(Error::Record { .. }) )); // Bytes that are not the type: the shape fits, and the resolver // refuses them before any key is retrieved. @@ -5811,11 +6003,11 @@ mod tests { // bare build: each refused before the resolver runs. assert!(matches!( refused(query(&keyset, &plan, "age", s("x"), &FakeEql)), - Error::Plan + Error::Plan { .. } )); assert!(matches!( refused(query(&keyset, &plan, "nope", s("x"), &FakeEql)), - Error::Plan + Error::Plan { .. } )); assert!(matches!( refused(query( @@ -5825,7 +6017,7 @@ mod tests { FfiValue::UInt32(1), &FakeEql )), - Error::Source + Error::Source { .. } )); assert!(matches!( refused(query(&keyset, &plan, "email", s("x"), &NoTargets)), @@ -6056,4 +6248,404 @@ mod tests { assert_eq!(bare, "\nalice", "the string tag, U+000A, read as text"); } } + + /// Every refusal names the field it is about, where there is one, and + /// says why with a [`Reason`]: the message, the accessors and the + /// payload all carry the same two facts. + mod every_refusal_names_its_field_and_reason { + use super::*; + use crate::ErrorPayload; + + /// The variant, the field and the reason an error must report. + fn expect(error: &Error, variant: &str, field: Option<&str>, reason: Reason) { + let actual = match error { + Error::Context { .. } => "Context", + Error::Plan { .. } => "Plan", + Error::Source { .. } => "Source", + Error::Record { .. } => "Record", + other => panic!("expected a {variant} error, got {other:?}"), + }; + assert_eq!(actual, variant, "{error}"); + assert_eq!(error.field(), field, "{error}"); + assert_eq!(error.reason(), Some(reason), "{error}"); + let fields = error.payload(); + assert_eq!(fields["reason"], reason.as_str(), "{error}"); + assert_eq!( + fields.get("field").and_then(|f| f.as_str()), + field, + "{error}" + ); + if let Some(field) = field { + assert!(error.to_string().contains(field), "{error}"); + } + } + + fn refused_plan(value: FfiValue) -> Error { + plan(value).expect_err("the plan is refused") + } + + fn age(spec: Vec<(&str, FfiValue)>) -> FfiValue { + obj(vec![("age", obj(spec))]) + } + + #[test] + fn a_malformed_plan() { + use Reason::*; + let cases: Vec<(&str, FfiValue, Option<&str>, Reason, &str)> = vec![ + ("not an object", s("x"), None, NotAnObject, "Plan"), + ("no fields", obj(vec![]), None, NoFields, "Plan"), + ( + "a spec that is not an object", + obj(vec![("age", s("x"))]), + Some("age"), + NotAnObject, + "Plan", + ), + ( + "an unknown key", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c"])), + ("nullable", FfiValue::Bool(true)), + ]), + Some("age"), + UnknownKey, + "Plan", + ), + ( + "a key given twice", + age(vec![ + ("context", label("age")), + ("context", label("age")), + ("outputs", strings(&["c"])), + ]), + Some("age"), + RepeatedKey, + "Plan", + ), + ( + "no context", + age(vec![("outputs", strings(&["c"]))]), + Some("age"), + MissingContext, + "Plan", + ), + ( + "no outputs or target", + age(vec![("context", label("age"))]), + Some("age"), + MissingOutputs, + "Plan", + ), + ( + "outputs and a target", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c"])), + ("target", s("TextEq")), + ]), + Some("age"), + OutputsWithTarget, + "Plan", + ), + ( + "outputs that are not a list", + age(vec![("context", label("age")), ("outputs", s("c"))]), + Some("age"), + OutputsNotList, + "Plan", + ), + ( + "an output that is not one", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c", "zz"])), + ]), + Some("age"), + UnknownOutput, + "Plan", + ), + ( + "no outputs", + age(vec![("context", label("age")), ("outputs", strings(&[]))]), + Some("age"), + NoOutputs, + "Plan", + ), + ( + "an output named twice", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c", "c"])), + ]), + Some("age"), + DuplicateOutput, + "Plan", + ), + ( + "passthrough beside another output", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c", "passthrough"])), + ]), + Some("age"), + PassthroughWithOutputs, + "Plan", + ), + ( + "a target that is not a string", + age(vec![ + ("context", label("age")), + ("target", FfiValue::UInt32(1)), + ]), + Some("age"), + InvalidTarget, + "Plan", + ), + ( + "a type that is not one", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c"])), + ("type", s("decimal")), + ]), + Some("age"), + UnknownType, + "Plan", + ), + ( + "an index the type has not", + age(vec![ + ("context", label("age")), + ("outputs", strings(&["c", "match"])), + ("type", s("uint32")), + ]), + Some("age"), + IndexNotAdmitted, + "Plan", + ), + ( + "a context that is not a label", + age(vec![("context", s("users")), ("outputs", strings(&["c"]))]), + Some("age"), + ContextNotLabel, + "Plan", + ), + ( + "a context of a kind no context has", + age(vec![ + ("context", FfiValue::Bool(true)), + ("outputs", strings(&["c"])), + ]), + Some("age"), + ContextKind, + "Context", + ), + ( + "a field named twice", + FfiValue::Object(vec![ + ("age".to_string(), spec(label("age"), &["c"])), + ("age".to_string(), spec(label("age"), &["c"])), + ]), + Some("age"), + DuplicateField, + "Plan", + ), + ( + "fields under different contexts", + obj(vec![ + ("age", spec(label("age"), &["c"])), + ("email", spec(strings(&["orders", "email"]), &["c"])), + ]), + Some("email"), + MixedContexts, + "Plan", + ), + ]; + for (what, value, field, reason, variant) in cases { + let error = refused_plan(value); + assert!( + !matches!(error, Error::UntypedIndex { .. }), + "{what}: {error}" + ); + expect(&error, variant, field, reason); + } + } + + #[test] + fn a_source_that_does_not_fit() { + use Reason::*; + let plan = the_plan(); + let without = |name: &str| { + let mut entries = object(row(34)); + let _ = take(&mut entries, name); + entries + }; + let mut repeated = object(row(34)); + repeated.push(("age".to_string(), FfiValue::UInt32(2))); + let mut extra = object(row(34)); + extra.push(("extra".to_string(), FfiValue::UInt32(2))); + let mut mistyped = without("age"); + mistyped.push(("age".to_string(), s("old"))); + let mut forged = without("email"); + forged.push(( + "email".to_string(), + FfiValue::Passthrough(Box::new(s("a@x"))), + )); + let cases: Vec<(&str, FfiValue, Option<&str>, Reason)> = vec![ + ("not an object", s("x"), None, NotAnObject), + ( + "a batch row that is not an object", + FfiValue::Array(vec![s("x")]), + None, + NotAnObject, + ), + ( + "a field missing", + FfiValue::Object(without("email")), + Some("email"), + FieldMissing, + ), + ( + "a field given twice", + FfiValue::Object(repeated), + Some("age"), + FieldRepeated, + ), + ( + "a field the plan does not name", + FfiValue::Object(extra), + Some("extra"), + UnknownField, + ), + ( + "a value of another type", + FfiValue::Object(mistyped), + Some("age"), + FieldType, + ), + ( + "a passthrough under a sealed field", + FfiValue::Object(forged), + Some("email"), + Passthrough, + ), + ]; + for (what, source, field, reason) in cases { + let error = check_source(source, &plan).expect_err(what); + expect(&error, "Source", field, reason); + } + } + + #[tokio::test] + async fn a_stored_record_that_does_not_fit() { + use Reason::*; + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let sealed = || async { map(seal(&keyset, row(34), &plan).await) }; + + let mut cases: Vec<(&str, StackCipherText, Option<&str>, Reason)> = Vec::new(); + + let mut fields = sealed().await; + let _ = node(&mut fields, "email"); + cases.push(( + "a field missing", + CipherText::Map(fields), + Some("email"), + FieldMissing, + )); + + let mut fields = sealed().await; + let email = node(&mut fields, "email"); + fields.push(("email".to_string(), forged(FfiValue::UInt32(1)))); + fields.push(("email".to_string(), email)); + cases.push(( + "a field given twice", + CipherText::Map(fields), + Some("email"), + FieldRepeated, + )); + + let mut fields = sealed().await; + let _ = node(&mut fields, "email"); + fields.push(("email".to_string(), forged(FfiValue::UInt32(1)))); + cases.push(( + "a field that is not an output map", + CipherText::Map(fields), + Some("email"), + OutputsNotMap, + )); + + let mut fields = sealed().await; + let mut email = map(node(&mut fields, "email")); + let _ = node(&mut email, "c"); + fields.push(("email".to_string(), CipherText::Map(email))); + cases.push(( + "no ciphertext node", + CipherText::Map(fields), + Some("email"), + NoCiphertextNode, + )); + + let mut fields = sealed().await; + let mut email = map(node(&mut fields, "email")); + let _ = node(&mut email, "c"); + email.push(("c".to_string(), forged(FfiValue::UInt32(1)))); + fields.push(("email".to_string(), CipherText::Map(email))); + cases.push(( + "a passthrough under c", + CipherText::Map(fields), + Some("email"), + Passthrough, + )); + + let mut fields = sealed().await; + let mut id = map(node(&mut fields, "id")); + let _ = node(&mut id, "passthrough"); + fields.push(("id".to_string(), CipherText::Map(id))); + cases.push(( + "no passthrough node", + CipherText::Map(fields), + Some("id"), + NoPassthroughNode, + )); + + let fields = sealed().await; + let mut first = fields; + let mut age = map(node(&mut first, "age")); + cases.push(("not a map", node(&mut age, "c"), None, NotAnObject)); + + for (what, record, field, reason) in cases { + let error = check_record(record, &plan, None).expect_err(what); + expect(&error, "Record", field, reason); + } + } + + /// A binding switches on `as_str`, so no two reasons share a name. + #[test] + fn every_reason_has_a_distinct_snake_case_name() { + let names: std::collections::BTreeSet<&str> = + Reason::ALL.iter().map(|reason| reason.as_str()).collect(); + assert_eq!(names.len(), Reason::ALL.len(), "names are distinct"); + for reason in Reason::ALL { + let name = reason.as_str(); + assert!( + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), + "{name}" + ); + assert!(!reason.to_string().is_empty(), "{name}"); + } + } + + /// `in_field` names the field only where none is named yet. + #[test] + fn in_field_fills_only_an_unnamed_field() { + let named = Error::bad_source(Reason::FieldType).in_field("age"); + assert_eq!(named.field(), Some("age")); + assert_eq!(named.in_field("email").field(), Some("age")); + assert!(Error::Internal.in_field("age").field().is_none()); + } + } } diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index 0df41bf08..c1b94da53 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -143,24 +143,32 @@ impl TargetDescriptor { /// the value or the stored bytes, decided before any key is minted or /// retrieved; a binding maps them to its malformed-input status. `Other` is /// the resolver's own failure. -#[derive(Debug, thiserror::Error)] +/// +/// A resolver writes the `reason` strings, so they are under the rule on +/// [`ErrorPayload`](crate::ErrorPayload) like everything else here: a +/// resolver says what it refused, never a byte of the value or the stored +/// ciphertext it refused. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum TargetError { /// This build holds no EQL types: the plan names one, and only a build /// linked with them can run it. #[error("this build holds no EQL types; a plan cannot name {name} as a target")] + #[diagnostic(code(stack_encrypt::target_none))] NoTargets { /// The name the plan gave. name: String, }, /// No EQL type has this name. #[error("no such EQL type: {name}")] + #[diagnostic(code(stack_encrypt::target_unknown))] Unknown { /// The name the plan gave. name: String, }, /// The type exists and the engine cannot produce it yet. #[error("the engine cannot produce {name} yet: {reason}")] + #[diagnostic(code(stack_encrypt::target_unproducible))] Unproducible { /// The type's name. name: String, @@ -171,6 +179,7 @@ pub enum TargetError { /// has no query twin, so a query on a field that names it derives /// nothing. #[error("{name} answers no query: it is a storage-only type")] + #[diagnostic(code(stack_encrypt::target_no_query))] NoQuery { /// The type's name. name: String, @@ -183,6 +192,7 @@ pub enum TargetError { "{name}: an EQL value is stored under a table and a column, so the label {label} \ cannot be extended by the caller's parts" )] + #[diagnostic(code(stack_encrypt::target_extended))] Extended { /// The target field's name. name: String, @@ -198,6 +208,7 @@ pub enum TargetError { "{name}: an EQL value is stored under a table and a column, so a plan that takes \ its context from its field {context_field} has no table for it" )] + #[diagnostic(code(stack_encrypt::target_context_field))] ContextField { /// The target field's name. name: String, @@ -210,6 +221,10 @@ pub enum TargetError { "{name}: {target} is produced from a {} plaintext, and the field declares {declared}", expected.map_or("unspecified", ValueKind::name) )] + #[diagnostic( + code(stack_encrypt::target_kind), + help("Declare the field's \"type\" as the kind the EQL type is produced from, or leave it out to take that kind.") + )] Kind { /// The field's name. name: String, @@ -222,6 +237,7 @@ pub enum TargetError { }, /// The field's label is not a column the EQL type can be stored under. #[error("{name}: the label {label} is not an EQL column: {reason}")] + #[diagnostic(code(stack_encrypt::target_column))] Column { /// The target field's name. name: String, @@ -236,6 +252,7 @@ pub enum TargetError { expected.map_or("unspecified", ValueKind::name), found.map_or("a value with no kind", ValueKind::name) )] + #[diagnostic(code(stack_encrypt::target_plaintext))] Plaintext { /// The field's name. name: String, @@ -249,6 +266,7 @@ pub enum TargetError { }, /// The stored bytes are not a value of the type. #[error("{name}: the stored value is not a {target}: {reason}")] + #[diagnostic(code(stack_encrypt::target_stored))] Stored { /// The field's name. name: String, @@ -258,11 +276,84 @@ pub enum TargetError { reason: String, }, /// The resolver's own failure: a value that did not serialize, an - /// invariant of the host's that did not hold. + /// invariant of the host's that did not hold. Its message is the + /// resolver's, shown as given, so a resolver writes it under the rule on + /// [`ErrorPayload`](crate::ErrorPayload). #[error(transparent)] + #[diagnostic(code(stack_encrypt::target_other))] Other(Box), } +impl crate::ErrorPayload for TargetError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + let kind = |kind: &Option| -> serde_json::Value { + kind.map_or(serde_json::Value::Null, |kind| kind.name().into()) + }; + let mut fields = match self { + Self::NoTargets { .. } + | Self::Unknown { .. } + | Self::NoQuery { .. } + | Self::Other(_) => serde_json::Map::new(), + Self::Unproducible { reason, .. } => payload([("reason", reason.as_str().into())]), + Self::Extended { label, .. } => payload([("label", label.as_str().into())]), + Self::ContextField { context_field, .. } => { + payload([("context_field", context_field.as_str().into())]) + } + Self::Kind { + target, + expected, + declared, + .. + } => payload([ + ("target", target.as_str().into()), + ("expected", kind(expected)), + ("declared", declared.name().into()), + ]), + Self::Column { label, reason, .. } => payload([ + ("label", label.as_str().into()), + ("reason", reason.as_str().into()), + ]), + Self::Plaintext { + target, + expected, + found, + .. + } => payload([ + ("target", target.as_str().into()), + ("expected", kind(expected)), + ("found", kind(found)), + ]), + Self::Stored { target, reason, .. } => payload([ + ("target", target.as_str().into()), + ("reason", reason.as_str().into()), + ]), + }; + // `name` is the target type for the first three, and the field for + // the rest (the lowering fills it in): keep the two apart. + match self { + Self::NoTargets { name } | Self::Unknown { name } | Self::NoQuery { name } => { + let _ = fields.insert("target".to_owned(), name.as_str().into()); + } + Self::Unproducible { name, .. } => { + let _ = fields.insert("target".to_owned(), name.as_str().into()); + } + Self::Extended { name, .. } + | Self::ContextField { name, .. } + | Self::Kind { name, .. } + | Self::Column { name, .. } + | Self::Plaintext { name, .. } + | Self::Stored { name, .. } => { + if !name.is_empty() { + let _ = fields.insert("field".to_owned(), name.as_str().into()); + } + } + Self::Other(_) => {} + } + fields + } +} + /// The EQL types a build holds, and how to run one. /// /// A host installs one implementation: the guest build linked with the EQL diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 6eccce49a..2c1c7f7ae 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -19,7 +19,7 @@ use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; -use super::{utf8, Error, Value}; +use super::{utf8, Error, Reason, Value}; use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, Tokenizer}; use crate::target::{chosen, CallerContext, ConsumeSource, Encryption, Index, IndexSpec, Pending}; use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; @@ -68,19 +68,21 @@ impl IndexSpec { /// /// # Errors /// - /// [`Error::Plan`] for a value that is neither an index key nor a match - /// options object, or whose options are unknown, repeated, mistyped or - /// out of bounds. + /// [`Error::Plan`] ([`Reason::UnknownOutput`]) for a value that is + /// neither an index key nor a match options object, or whose options are + /// unknown, repeated, mistyped or out of bounds. It names no field: the + /// plan parser names it. pub fn from_value(value: &FfiValue) -> Result { + let unknown = || Error::bad_plan(Reason::UnknownOutput); match value { - FfiValue::String(s) => Self::parse(utf8(s).ok_or(Error::Plan)?).ok_or(Error::Plan), + FfiValue::String(s) => Self::parse(utf8(s).ok_or_else(unknown)?).ok_or_else(unknown), FfiValue::Object(entries) => match entries.as_slice() { [(key, FfiValue::Object(options))] if key == "match" => { Ok(IndexSpec::Match(match_options(options)?)) } - _ => Err(Error::Plan), + _ => Err(unknown()), }, - _ => Err(Error::Plan), + _ => Err(unknown()), } } @@ -135,11 +137,12 @@ impl IndexSpec { /// The options object of a match index's wire form: each key at most once, /// each defaulting, and the whole checked against the scheme's bounds. fn match_options(entries: &[(String, FfiValue)]) -> Result { + let unknown = || Error::bad_plan(Reason::UnknownOutput); let mut options = MatchOptions::default(); let mut seen: Vec<&str> = Vec::with_capacity(entries.len()); for (key, value) in entries { if seen.contains(&key.as_str()) { - return Err(Error::Plan); + return Err(Error::bad_plan(Reason::RepeatedKey)); } seen.push(key); match (key.as_str(), value) { @@ -152,22 +155,22 @@ fn match_options(entries: &[(String, FfiValue)]) -> Result length: integer(length)?, }; } - _ => return Err(Error::Plan), + _ => return Err(unknown()), }, ("downcase", FfiValue::Bool(downcase)) => options.downcase = *downcase, ("k", k) => options.k = integer(k)?, ("m", m) => options.m = integer(m)?, - _ => return Err(Error::Plan), + _ => return Err(unknown()), } } if options.validate().is_err() { - return Err(Error::Plan); + return Err(unknown()); } Ok(options) } /// A non-negative integer leaf of any width, as the target type, or -/// [`Error::Plan`]. +/// [`Error::Plan`] ([`Reason::UnknownOutput`]: it is a match option). fn integer>(value: &FfiValue) -> Result { let wide = match value { FfiValue::Int32(v) => u64::try_from(*v).ok(), @@ -176,7 +179,8 @@ fn integer>(value: &FfiValue) -> Result { FfiValue::UInt64(v) => Some(*v), _ => None, }; - wide.and_then(|v| T::try_from(v).ok()).ok_or(Error::Plan) + wide.and_then(|v| T::try_from(v).ok()) + .ok_or(Error::bad_plan(Reason::UnknownOutput)) } /// A term-able scalar lifted out of an [`FfiValue`] leaf. @@ -230,12 +234,20 @@ impl Scalar { FfiValue::Float64(v) => Scalar::F64(*v), FfiValue::String(s) => Scalar::Text(Zeroizing::new( utf8(s) - .ok_or_else(|| Error::Term { kind: kind.clone() })? + .ok_or_else(|| Error::Term { + field: None, + kind: kind.clone(), + })? .to_string(), )), FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), // Containers, nulls and passthroughs have no term semantics. - _ => return Err(Error::Term { kind: kind.clone() }), + _ => { + return Err(Error::Term { + field: None, + kind: kind.clone(), + }) + } }) } } @@ -380,6 +392,7 @@ where // values is a modelling error) or booleans. Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { return Err(Error::Term { + field: None, kind: IndexSpec::Equality, }) } @@ -405,6 +418,7 @@ where .match_terms_under::(&t, context, options.clone()) .map(|terms| TermBytes(terms.to_bytes()))), _ => Err(Error::Term { + field: None, kind: IndexSpec::Match(options.clone()), }), } @@ -590,7 +604,7 @@ mod tests { Err(crate::Error::Other(inner)) => assert!( matches!( inner.downcast_ref::(), - Some(Error::Term { kind: refused_kind }) if *refused_kind == kind + Some(Error::Term { kind: refused_kind, .. }) if *refused_kind == kind ), "{what}: the lifted error names the index: {inner:?}" ), @@ -831,7 +845,7 @@ mod tests { ); let result = term(&keyset, scalar, &kind, ctx.clone()).await; assert!( - matches!(&result, Err(Error::Term { kind: k }) if *k == kind), + matches!(&result, Err(Error::Term { kind: k, .. }) if *k == kind), "{label} asked for a {kind} term must be refused as that kind: {result:?}" ); } @@ -865,7 +879,7 @@ mod tests { ] { let result = Scalar::of(&value, &kind); assert!( - matches!(&result, Err(Error::Term { kind: k }) if *k == kind), + matches!(&result, Err(Error::Term { kind: k, .. }) if *k == kind), "{label} has no {kind} term: {result:?}" ); } @@ -1093,7 +1107,7 @@ mod tests { ]; for (label, wire) in refused { assert!( - matches!(IndexSpec::from_value(&wire), Err(Error::Plan)), + matches!(IndexSpec::from_value(&wire), Err(Error::Plan { .. })), "{label} is not an index" ); } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index df300fe4b..61520fb8b 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -271,6 +271,7 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod cipher; +mod codes; pub mod descriptor; #[cfg(feature = "dynamic")] pub mod dynamic; @@ -291,7 +292,13 @@ pub use plan::{all, Plan, PlanError}; // versioned on its own (release-plz.toml), so a caller reaches it through // here and always gets the version this crate was built against, never a // second copy whose types do not fit `StackCipher`'s bounds. +pub use codes::ERROR_CODES; pub use stack_kms as kms; +/// The trait every error here implements to hand over its structured +/// fields, with the rule for what an error may contain, and the helpers that +/// go with it. Shared by `stack-profile`, `stack-auth`, `stack-kms` and this +/// crate, so a binding encodes an error from any of them the same way. +pub use stack_kms::{diagnostic, ErrorPayload}; pub use target::{ CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, EncryptFrom, EncryptInto, Encrypted, Encryption, Equality, Index, IndexSpec, Indexes, Match, diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index ec32afe65..fe82b4586 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -12,30 +12,47 @@ use crate::LabelError; /// record already in hand. Carried in [`Error::Plan`]. /// /// [`Error::Plan`]: crate::Error::Plan -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +/// +/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES), +/// and names the field it is about where there is one, in its message and +/// its [`ErrorPayload`](crate::ErrorPayload). Field names describe the +/// schema, not the data, so a message may carry them. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum PlanError { /// The plan's context is not a plain label. #[error("the plan's context is not a plain label: {0}")] - ContextLabel(#[source] LabelError), + #[diagnostic(code(stack_encrypt::plan_context_label))] + ContextLabel( + #[source] + #[diagnostic_source] + LabelError, + ), /// A sealed or indexed field is keyed under an identity (its name, /// unless one is pinned) that is not a plain label segment. A /// passthrough field is under no label, so its name is never refused /// for this. #[error("field {field:?} is not keyed under a plain label segment: {source}")] + #[diagnostic( + code(stack_encrypt::plan_field_label), + help("A field's name, or the identity pinned for it, is a plain label segment: no `/`, control characters or parentheses, and not starting with `b64:`, a digit or `-`. Pin a plain identity with `identity` to keep the name.") + )] FieldLabel { /// The field. field: String, /// Why its name or identity is not plain. #[source] + #[diagnostic_source] source: LabelError, }, /// `identity` was called before any field was declared, so there was no /// field for it to pin. #[error("identity was set before any field was declared")] + #[diagnostic(code(stack_encrypt::plan_identity_without_field))] IdentityWithoutField, /// A field was named twice. #[error("field {field:?} is named twice")] + #[diagnostic(code(stack_encrypt::plan_duplicate_field))] DuplicateField { /// The field. field: String, @@ -45,6 +62,10 @@ pub enum PlanError { /// interchangeable. A passthrough field keys nothing and shares no /// identity. #[error("fields {first:?} and {second:?} are both keyed under identity {identity:?}")] + #[diagnostic( + code(stack_encrypt::plan_shared_identity), + help("Give each sealed or indexed field its own identity: rename one, or pin another with `identity`.") + )] SharedIdentity { /// The identity both use. identity: String, @@ -57,12 +78,17 @@ pub enum PlanError { /// field is carried unsealed and unauthenticated; one that must be /// searchable is sealed, with its indexes beside it. #[error("field {field:?} is declared both passthrough and indexed")] + #[diagnostic( + code(stack_encrypt::plan_passthrough_indexed), + help("A passthrough field is stored unsealed. Seal the field to index it.") + )] PassthroughIndexed { /// The field. field: String, }, /// A field, or a one-value plan, names the same index twice. #[error("{at:?} names the {index} index twice")] + #[diagnostic(code(stack_encrypt::plan_duplicate_index))] DuplicateIndex { /// The field, or the context of a one-value plan. at: String, @@ -74,9 +100,14 @@ pub enum PlanError { /// an index set sized at run time (a `Vec`), as a plan lowered from data /// builds. #[error("an indexed field declares no index")] + #[diagnostic(code(stack_encrypt::plan_empty_indexes))] EmptyIndexes, /// The value has a field the plan does not name. #[error("the value has a field {field:?} the plan does not name")] + #[diagnostic( + code(stack_encrypt::plan_field_not_in_plan), + help("Add the field to the plan, or leave it out of the value.") + )] NotInPlan { /// The field. field: String, @@ -84,6 +115,7 @@ pub enum PlanError { /// The plan names a field the value, or the stored record, does not /// have. #[error("the plan names a field {field:?} the value does not have")] + #[diagnostic(code(stack_encrypt::plan_field_not_in_value))] NotInValue { /// The field. field: String, @@ -91,6 +123,10 @@ pub enum PlanError { /// A field is not of the type the plan declares for it, so the plan /// cannot resolve how to seal, index or read it. #[error("field {field:?} is not a {expected}")] + #[diagnostic( + code(stack_encrypt::plan_field_type), + help("Give the field a value of the type the plan declares. A row stored as another type opens only after it is re-encrypted as the declared one.") + )] FieldType { /// The field. field: String, @@ -99,6 +135,7 @@ pub enum PlanError { }, /// The plan has no field of that name. #[error("the plan has no field {field:?}")] + #[diagnostic(code(stack_encrypt::plan_no_such_field))] NoSuchField { /// The field asked for. field: String, @@ -108,10 +145,18 @@ pub enum PlanError { /// every keyset it names there, so a chain from another cipher would /// run under a keyset its own cipher never chose. #[error("the chains in one batch were started on different ciphers")] + #[diagnostic( + code(stack_encrypt::plan_mixed_ciphers), + help("Start every chain in one batch on the same cipher.") + )] MixedCiphers, /// A query asked a field for an index the field never declared, so it /// would have matched nothing. #[error("field {field:?} declares no {index} index")] + #[diagnostic( + code(stack_encrypt::plan_index_not_declared), + help("Query the field through an index it declares, or declare the index on the field.") + )] IndexNotDeclared { /// The field, or the context of a one-value plan. field: String, @@ -125,6 +170,10 @@ pub enum PlanError { "field {field:?} declares a {} index with other options: declared {declared:?}, asked {asked:?}", declared.key() )] + #[diagnostic( + code(stack_encrypt::plan_index_options), + help("Query with the options the field declares: terms derived under other options never match.") + )] IndexOptions { /// The field, or the context of a one-value plan. field: String, @@ -137,6 +186,12 @@ pub enum PlanError { /// exactly one place: the plan, the call that runs it, or a context /// field. #[error("the plan's context is given twice: by {first} and by {second}")] + #[diagnostic( + code(stack_encrypt::plan_two_context_sources), + help( + "Give the context in one place: the plan, the call that runs it, or a context field." + ) + )] TwoContextSources { /// Who gave it first: `"the plan"`, `"the call"` or /// `"a context field"`. @@ -149,13 +204,71 @@ pub enum PlanError { #[error( "the plan has no context: build it with one, name one in the call, or use a context field" )] + #[diagnostic(code(stack_encrypt::plan_no_context))] NoContext, /// A field was declared both as a typed target (`encrypt_into`) and with /// a data verb. A field is one or the other: the target's type decides /// its layout and its queries. #[error("field {field:?} is declared both as a typed target and with data verbs")] + #[diagnostic(code(stack_encrypt::plan_target_with_verbs))] TargetWithVerbs { /// The field. field: String, }, } + +impl crate::ErrorPayload for PlanError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::ContextLabel(label) => label.payload(), + Self::FieldLabel { field, source } => { + let mut fields = source.payload(); + let _ = fields.insert("field".to_owned(), field.as_str().into()); + fields + } + Self::DuplicateField { field } + | Self::PassthroughIndexed { field } + | Self::NotInPlan { field } + | Self::NotInValue { field } + | Self::NoSuchField { field } + | Self::TargetWithVerbs { field } => payload([("field", field.as_str().into())]), + Self::SharedIdentity { + identity, + first, + second, + } => payload([ + ("identity", identity.as_str().into()), + ("first", first.as_str().into()), + ("second", second.as_str().into()), + ]), + Self::DuplicateIndex { at, index } => { + payload([("field", at.as_str().into()), ("index", (*index).into())]) + } + Self::FieldType { field, expected } => payload([ + ("field", field.as_str().into()), + ("expected", (*expected).into()), + ]), + Self::IndexNotDeclared { field, index } => { + payload([("field", field.as_str().into()), ("index", (*index).into())]) + } + Self::IndexOptions { + field, + declared, + asked, + } => payload([ + ("field", field.as_str().into()), + ("index", declared.key().into()), + ("declared", format!("{declared:?}").into()), + ("asked", format!("{asked:?}").into()), + ]), + Self::TwoContextSources { first, second } => { + payload([("first", (*first).into()), ("second", (*second).into())]) + } + Self::IdentityWithoutField + | Self::EmptyIndexes + | Self::MixedCiphers + | Self::NoContext => serde_json::Map::new(), + } + } +} diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index bde08aae7..e0d383d9e 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -167,18 +167,24 @@ const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v1"; const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v1"; /// Errors from SEM term generation. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum TermError { /// The PRF backend failed (for a remote 2-party backend this includes - /// transport errors). - #[error("PRF failed: {0}")] + /// transport errors). The backend's error is the + /// [`source`](std::error::Error::source), not part of this message: it + /// is another library's text. + #[error("PRF failed")] + #[diagnostic(code(stack_encrypt::prf_failed))] Prf(#[source] Box), - /// CLLW ORE/OPE encryption failed. + /// CLLW ORE/OPE encryption failed. (`cllw_ore::Error` is deliberately + /// contentless, so its message is fixed text.) #[error("ORE/OPE encryption failed: {0}")] + #[diagnostic(code(stack_encrypt::ore_failed))] Ore(#[from] cllw_ore::Error), /// The supplied [`MatchOptions`] are invalid. #[error("invalid match options: {0}")] + #[diagnostic(code(stack_encrypt::invalid_match_options))] InvalidOptions(&'static str), /// The text produced no tokens under the configured tokenizer — empty or /// separator-only text, or (for n-grams, as in the v1 match indexer) text @@ -189,13 +195,29 @@ pub enum TermError { #[error( "text produces no match tokens (empty, separator-only, or shorter than the n-gram length)" )] + #[diagnostic( + code(stack_encrypt::empty_term_text), + help("A match term needs text with at least one token: a query with none would match every row, and one shorter than the n-gram length could match none. Match on longer text, or skip the match condition for this value.") + )] EmptyTermText, /// Term bytes do not decode under the term kind's frozen encoding — see /// [`TermBytesError`]. #[error(transparent)] + #[diagnostic(transparent)] Bytes(#[from] TermBytesError), } +impl crate::ErrorPayload for TermError { + fn payload(&self) -> serde_json::Map { + match self { + Self::Bytes(error) => error.payload(), + Self::Prf(_) | Self::Ore(_) | Self::InvalidOptions(_) | Self::EmptyTermText => { + serde_json::Map::new() + } + } + } +} + /// A term's frozen byte encoding failed to decode (see the /// [module docs](self#byte-encodings)). Purely structural — a term that /// *decodes* has proven nothing about being a genuine term derived under any @@ -205,22 +227,33 @@ pub enum TermError { /// Kept separate from the rest of [`TermError`] (which it converts into) so /// decoding has an error a caller can compare: the generation variants carry /// boxed and opaque sources that are not [`PartialEq`]. -#[derive(Debug, PartialEq, Eq, thiserror::Error)] +/// +/// No message carries a byte of the term: lengths and the filter size only. +#[derive(Debug, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum TermBytesError { /// Equality-term bytes are not the 32 PRF bytes. #[error("equality-term bytes must be exactly 32 bytes, got {0}")] + #[diagnostic(code(stack_encrypt::equality_term_length))] WrongEqualityTermLength(usize), /// Match-term bytes are not a whole number of little-endian `u16` /// positions. #[error("match-term bytes must be little-endian u16 positions, got an odd length of {0}")] + #[diagnostic(code(stack_encrypt::match_term_length))] OddMatchTermsLength(usize), /// A decoded position lies outside the Bloom filter the term's /// [`MatchConfig`] fixes. Genuine positions are always masked into /// `0..m`, so an out-of-range one means the bytes were not written by /// this encoding — a wrong-endian decoder, most often, which would /// otherwise decode cleanly and then silently never match. - #[error("match position {position} is outside the {filter_size}-bit filter")] + /// + /// `position` is kept for a caller in this process; the message leaves + /// it out, since it is a value read from the term's bytes. + #[error("a match position is outside the {filter_size}-bit filter")] + #[diagnostic( + code(stack_encrypt::match_position_out_of_range), + help("The bytes were not written by this match-term encoding: check they are little-endian, and decoded under the same match options they were derived with.") + )] MatchPositionOutOfRange { /// The offending position. position: u16, @@ -231,9 +264,24 @@ pub enum TermBytesError { /// (The length is all there is to report: `cllw_ore::Error` is /// deliberately contentless, so its message would say strictly less.) #[error("{0} bytes do not fit this CLLW ciphertext shape")] + #[diagnostic(code(stack_encrypt::cllw_ciphertext_length))] MalformedCllwCiphertext(usize), } +impl crate::ErrorPayload for TermBytesError { + fn payload(&self) -> serde_json::Map { + use crate::diagnostic::payload; + match self { + Self::WrongEqualityTermLength(len) + | Self::OddMatchTermsLength(len) + | Self::MalformedCllwCiphertext(len) => payload([("len", (*len).into())]), + Self::MatchPositionOutOfRange { filter_size, .. } => { + payload([("filter_size", (*filter_size).into())]) + } + } + } +} + impl TermError { fn from_prf(err: PrfError) -> Self where From 42baeee7a67692d31356fdb97c2b4501f8eadd67 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 22:34:37 +0000 Subject: [PATCH 05/22] fix(stack-encrypt): pin every error payload in tests and drop Reason's two 36-arm matches CI on the codes PR failed four ways; this answers each. The payload impls added for #1099 had no test that read their fields, so cargo-mutants could replace any of them with an empty map unnoticed, and CRAP scored TargetError::payload, PlanError::payload and refusal() as untested. A table in codes.rs now pins one variant per arm of every payload impl, a table in record.rs pins every arm of refusal(), and device_client gains a test that the Server payload is the status and never the body. Reason::as_str and its Display were 36-arm matches, and CRAP is never below cyclomatic complexity, so no test could clear them. Reason is now declared through a macro from one list of variant, snake_case name and phrase; the enum, ALL and the lookup table all come from it, so a reason cannot be added without both strings, and both methods index the table. Every name, phrase and the ALL order are unchanged. The no-http transport test read the hint from the message, which is now fixed text; it reads it from the source. The publish dry-run adds stack-profile and stack-auth so stack-kms and stack-encrypt build against the tree's versions of them rather than the older ones on crates.io. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- .github/workflows/tests-crates.yml | 24 +- packages/stack-auth/src/device_client.rs | 19 + packages/stack-auth/src/transport.rs | 6 +- packages/stack-encrypt/src/codes.rs | 298 ++++++++++++++++ packages/stack-encrypt/src/dynamic/mod.rs | 354 +++++++------------ packages/stack-encrypt/src/dynamic/record.rs | 122 +++++++ packages/stack-encrypt/src/dynamic/target.rs | 10 +- 7 files changed, 598 insertions(+), 235 deletions(-) diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 6405c5999..40cc572cc 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -141,16 +141,20 @@ jobs: - name: Build the stack-encrypt examples run: cargo build --locked -p stack-encrypt --examples - # The crates release-plz publishes from 0.1.0. `--dry-run` packages and - # compiles each the way crates.io would (the three together, so - # stack-encrypt resolves the other two from cargo's temporary local - # registry), catching a publish-blocker on the PR instead of in - # release-crates on main: missing metadata, or a runtime path dependency - # with no `version`. No token. `--allow-dirty` tolerates files earlier - # steps leave in the tree. Once a version is on crates.io, cargo warns - # rather than fails on a dry run of it. - - name: Verify the stack-encrypt crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-kms -p stack-encrypt-derive -p stack-encrypt + # The crates release-plz publishes. `--dry-run` packages and compiles + # each the way crates.io would (all five together, so each resolves the + # others from cargo's temporary local registry rather than from + # crates.io), catching a publish-blocker on the PR instead of in + # release-crates on main: missing metadata, a runtime path dependency + # with no `version`, or a crate that uses API its dependency's packaged + # source does not have yet. stack-profile and stack-auth are in the list + # because stack-kms and stack-encrypt depend on them: without them, cargo + # resolves the published versions, which lag the tree. No token. + # `--allow-dirty` tolerates files earlier steps leave in the tree. Once a + # version is on crates.io, cargo warns rather than fails on a dry run of + # it. + - name: Verify the stack-* crates package cleanly for crates.io + run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt node-bindings: name: node bindings, napi typings, stack-auth-wasm diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 9173a30f9..7490c1cf6 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -175,6 +175,25 @@ mod tests { use mocktail::prelude::*; use tempfile::TempDir; + /// ZeroKMS's status is the payload; its response body is in neither the + /// payload nor the message. A profile failure carries the profile's. + #[test] + fn a_server_error_carries_the_status_and_not_the_body() { + let error = DeviceClientError::Server { + status: 503, + body: "marker-body".into(), + }; + let payload = error.payload(); + assert_eq!(payload["status"], 503); + let shown = format!("{error} {payload:?}"); + assert!(!shown.contains("marker-body"), "{shown}"); + + let error = DeviceClientError::Profile(stack_profile::ProfileError::NotFound { + path: "/profiles/auth.json".into(), + }); + assert_eq!(error.payload()["path"], "/profiles/auth.json"); + } + fn make_test_jwt(zerokms_url: impl std::fmt::Display) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index eecc2ee2d..5ad41d0b5 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -943,7 +943,11 @@ mod tests { panic!("built a strategy with nothing to send through"); }; assert!(matches!(err, AuthError::Request(_)), "{err:?}"); - assert!(err.to_string().contains("`.transport(..)`"), "{err}"); + // The message stays fixed; what to do about it is the source's. + let source = std::error::Error::source(&err) + .map(ToString::to_string) + .unwrap_or_default(); + assert!(source.contains("`.transport(..)`"), "{source:?}"); } #[cfg(not(feature = "http"))] diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index b68b5eb95..79e46c04e 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -341,4 +341,302 @@ mod tests { ); assert_eq!(error.to_string(), "Unexpected error: kms"); } + + /// Every error type's structured fields, one variant per arm. A binding + /// hands these over as they are, so a field that goes missing or changes + /// name is a break for every caller that reads it. + fn every_payload() -> Vec<(Box, serde_json::Value)> { + use serde_json::json; + let (a, b) = (Uuid::from_u128(1), Uuid::from_u128(2)); + let declared = IndexSpec::Match(MatchOptions::default()); + let asked = IndexSpec::Match(MatchOptions { + downcase: false, + ..MatchOptions::default() + }); + let payloads: Vec<(Box, serde_json::Value)> = vec![ + ( + Box::new(Error::Kms(crate::kms::Error::GenerateKey( + crate::kms::GenerateKeyError::InvalidNumberOfKeys { + expected: 3, + received: 2, + }, + ))), + json!({ "expected": 3, "received": 2 }), + ), + ( + Box::new(Error::Term(TermError::Bytes( + TermBytesError::WrongEqualityTermLength(31), + ))), + json!({ "len": 31 }), + ), + ( + Box::new(Error::Plan(PlanError::NoSuchField { + field: "age".into(), + })), + json!({ "field": "age" }), + ), + ( + Box::new(Error::KeyCountMismatch { + expected: 2, + received: 1, + }), + json!({ "expected": 2, "received": 1 }), + ), + ( + Box::new(Error::DescriptorTooLong { len: 513 }), + json!({ "len": 513, "limit": Descriptor::MAX_LEN }), + ), + ( + Box::new(Error::ContextMismatch { + stored: Descriptor::of(("users", "email")), + }), + json!({ + "stored_len": Descriptor::of(("users", "email")).len(), + "stored_parts": 2, + }), + ), + ( + Box::new(Error::KeysetMismatch { left: a, right: b }), + json!({ "left": a.to_string(), "right": b.to_string() }), + ), + (Box::new(Error::Aead), json!({})), + ( + Box::new(LeafBytesError::UnknownVersion(9)), + json!({ "version": 9 }), + ), + ( + Box::new(LeafBytesError::TagTooLong(70_000)), + json!({ "len": 70_000 }), + ), + (Box::new(LeafBytesError::Truncated), json!({})), + ( + Box::new(TermError::Bytes(TermBytesError::OddMatchTermsLength(3))), + json!({ "len": 3 }), + ), + (Box::new(TermError::EmptyTermText), json!({})), + ( + Box::new(TermBytesError::MalformedCllwCiphertext(5)), + json!({ "len": 5 }), + ), + ( + Box::new(TermBytesError::MatchPositionOutOfRange { + position: 900, + filter_size: 256, + }), + json!({ "filter_size": 256 }), + ), + (Box::new(LabelError::Empty), json!({})), + ( + Box::new(LabelError::EmptySegment { index: 1 }), + json!({ "segment": 1 }), + ), + ( + Box::new(LabelError::Reserved { + index: 0, + found: '(', + }), + json!({ "segment": 0, "character": "(" }), + ), + ( + Box::new(PlanError::ContextLabel(LabelError::Separator { index: 2 })), + json!({ "segment": 2 }), + ), + ( + Box::new(PlanError::FieldLabel { + field: "age".into(), + source: LabelError::ReservedPrefix { index: 1 }, + }), + json!({ "segment": 1, "field": "age" }), + ), + ( + Box::new(PlanError::DuplicateField { + field: "age".into(), + }), + json!({ "field": "age" }), + ), + ( + Box::new(PlanError::SharedIdentity { + identity: "age".into(), + first: "age".into(), + second: "years".into(), + }), + json!({ "identity": "age", "first": "age", "second": "years" }), + ), + ( + Box::new(PlanError::DuplicateIndex { + at: "age".into(), + index: "eq", + }), + json!({ "field": "age", "index": "eq" }), + ), + ( + Box::new(PlanError::FieldType { + field: "age".into(), + expected: "int64", + }), + json!({ "field": "age", "expected": "int64" }), + ), + ( + Box::new(PlanError::IndexNotDeclared { + field: "age".into(), + index: "ore", + }), + json!({ "field": "age", "index": "ore" }), + ), + ( + Box::new(PlanError::IndexOptions { + field: "age".into(), + declared: declared.clone(), + asked: asked.clone(), + }), + json!({ + "field": "age", + "index": "match", + "declared": format!("{declared:?}"), + "asked": format!("{asked:?}"), + }), + ), + ( + Box::new(PlanError::TwoContextSources { + first: "the plan", + second: "the call", + }), + json!({ "first": "the plan", "second": "the call" }), + ), + (Box::new(PlanError::NoContext), json!({})), + ]; + #[cfg(feature = "dynamic")] + let payloads = payloads.into_iter().chain(dynamic_payloads()).collect(); + payloads + } + + #[cfg(feature = "dynamic")] + fn dynamic_payloads() -> Vec<(Box, serde_json::Value)> { + use crate::dynamic::{Error, Reason, TargetError, ValueKind}; + use serde_json::json; + let name = || "email".to_string(); + let target = || "TextEq".to_string(); + vec![ + ( + Box::new(Error::Target(TargetError::Unknown { name: target() })), + json!({ "target": "TextEq" }), + ), + ( + Box::new(Error::Cipher(crate::Error::KeyCountMismatch { + expected: 2, + received: 1, + })), + json!({ "expected": 2, "received": 1 }), + ), + ( + Box::new(Error::Term { + field: Some(name()), + kind: IndexSpec::Equality, + }), + json!({ "index": "eq", "field": "email" }), + ), + ( + Box::new(Error::bad_context(Reason::EmptyContext).in_field("email")), + json!({ "field": "email", "reason": "empty_context" }), + ), + ( + Box::new(Error::bad_record(Reason::NoCiphertextNode)), + json!({ "reason": "no_ciphertext_node" }), + ), + ( + Box::new(Error::UntypedIndex { field: name() }), + json!({ "field": "email" }), + ), + (Box::new(Error::Internal), json!({})), + ( + Box::new(TargetError::NoTargets { name: target() }), + json!({ "target": "TextEq" }), + ), + ( + Box::new(TargetError::Unproducible { + name: target(), + reason: "block ORE".into(), + }), + json!({ "target": "TextEq", "reason": "block ORE" }), + ), + ( + Box::new(TargetError::Extended { + name: name(), + label: "users/email".into(), + }), + json!({ "field": "email", "label": "users/email" }), + ), + // Before the lowering names the field, there is none to give. + ( + Box::new(TargetError::Extended { + name: String::new(), + label: "users/email".into(), + }), + json!({ "label": "users/email" }), + ), + ( + Box::new(TargetError::Kind { + name: name(), + target: target(), + expected: Some(ValueKind::String), + declared: ValueKind::UInt64, + }), + json!({ + "field": "email", + "target": "TextEq", + "expected": "string", + "declared": "uint64", + }), + ), + ( + Box::new(TargetError::Column { + name: name(), + label: "app/users/email".into(), + reason: "two segments".into(), + }), + json!({ + "field": "email", + "label": "app/users/email", + "reason": "two segments", + }), + ), + ( + Box::new(TargetError::Plaintext { + name: name(), + target: target(), + expected: Some(ValueKind::String), + found: None, + }), + json!({ + "field": "email", + "target": "TextEq", + "expected": "string", + "found": null, + }), + ), + ( + Box::new(TargetError::Stored { + name: name(), + target: target(), + reason: "not JSON".into(), + }), + json!({ "field": "email", "target": "TextEq", "reason": "not JSON" }), + ), + ( + Box::new(TargetError::Other(Box::new(std::io::Error::other("boom")))), + json!({}), + ), + ] + } + + #[test] + fn every_payload_carries_its_fields() { + for (error, expected) in every_payload() { + assert_eq!( + serde_json::Value::Object(error.payload()), + expected, + "{error:?}" + ); + } + } } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 89c78861a..8466f6bc9 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -405,234 +405,150 @@ impl crate::ErrorPayload for Error { } } -/// What was wrong with a context, plan, source or stored record: the reason -/// a dynamic input error carries beside the field it names. -/// -/// One vocabulary for all four, since several reasons apply to more than -/// one (a field given twice is a misfit in a source and in a stored -/// record). [`as_str`](Self::as_str) is the `snake_case` name a binding -/// reports in its payload's `reason` field; `Display` is the phrase the -/// message uses. `#[non_exhaustive]`: a reason added later is not a break, -/// and a binding that switches on one keeps a fallback. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum Reason { - /// A plan, field spec, source or stored record is not an object (a - /// map), or a list of them, where one is expected. - NotAnObject, - /// A context value is of a kind a context cannot hold: a boolean, - /// float, null, object or passthrough. - ContextKind, - /// A context string is not UTF-8. - ContextNotUtf8, - /// A context renders empty. - EmptyContext, - /// A field's context is not a label of at least two plain segments, - /// optionally extended by scalar parts. - ContextNotLabel, - /// The plan's fields sit under different contexts, or carry different - /// extensions. - MixedContexts, - /// The plan has no fields. - NoFields, - /// A field is named twice in the plan. - DuplicateField, - /// A field spec has a key other than `"context"`, `"outputs"`, - /// `"target"` and `"type"`. - UnknownKey, - /// A key is given twice: in a field spec, or in a map inside a source - /// value or a stored record. - RepeatedKey, - /// A field spec has no `"context"`. - MissingContext, - /// A field spec has neither `"outputs"` nor `"target"`. - MissingOutputs, - /// A field spec has both `"outputs"` and `"target"`, or a field is - /// declared both as a target and with data verbs. - OutputsWithTarget, - /// `"outputs"` is not a list. - OutputsNotList, - /// An output is not `"c"`, `"passthrough"` or an index in its wire - /// form, or a match index's options are not valid. - UnknownOutput, - /// A field's output list, or its index set, is empty. - NoOutputs, - /// A field names one output, or one index, twice. - DuplicateOutput, - /// A field names `"passthrough"` beside another output. - PassthroughWithOutputs, - /// `"target"` is not a non-empty string. - InvalidTarget, - /// `"type"` is not a string naming a value type. - UnknownType, - /// The field's declared type has no such index: match on an integer, - /// equality on a float, any index on a composite. - IndexNotAdmitted, - /// Two fields are keyed under one identity. - SharedIdentity, - /// The plan has no field of the name asked for. - NoSuchField, - /// The field asked for does not name an EQL type. - NotATarget, - /// The field a plan takes its context from (`"context_field"`) is not a - /// string passthrough, or the plan-level `"context_field"` key is not a - /// string. - ContextField, - /// The plan builder refused the plan for a reason none of the above - /// names. - Refused, - /// A field the plan names is not there. - FieldMissing, - /// A field is there twice. - FieldRepeated, - /// A field is there that the plan does not name. - UnknownField, - /// A value is not of the type its field declares. - FieldType, - /// A passthrough sits where a sealed value, or a ciphertext, must be. - Passthrough, - /// A stored field is not a map of outputs. - OutputsNotMap, - /// A stored sealed field has no `"c"` node. - NoCiphertextNode, - /// A stored passthrough field has no `"passthrough"` node. - NoPassthroughNode, - /// A stored target field has no `"eql"` node. - NoEqlNode, - /// A stored `"passthrough"` or `"eql"` node is not a passthrough - /// carrying a value of the kind it holds. - NotPassthrough, +/// Declares [`Reason`] from one list: each variant with its `snake_case` +/// name and the phrase its `Display` writes. One list, so a reason cannot be +/// added without both, and [`Reason::ALL`] cannot miss one. +macro_rules! reasons { + ( + $(#[$meta:meta])* + pub enum $name:ident { + $( + $(#[$variant_meta:meta])* + $variant:ident => $snake:literal, $phrase:literal; + )* + } + ) => { + $(#[$meta])* + pub enum $name { + $( + $(#[$variant_meta])* + $variant, + )* + } + + impl $name { + /// Every reason, in declaration order: what a binding's test + /// iterates. + pub const ALL: &'static [$name] = &[$(Self::$variant,)*]; + + /// Each reason's `snake_case` name and phrase, at its + /// discriminant: the variants and these entries come from one + /// list, in one order. + const WORDS: &'static [(&'static str, &'static str)] = &[$(($snake, $phrase),)*]; + } + }; +} + +reasons! { + /// What was wrong with a context, plan, source or stored record: the reason + /// a dynamic input error carries beside the field it names. + /// + /// One vocabulary for all four, since several reasons apply to more than + /// one (a field given twice is a misfit in a source and in a stored + /// record). [`as_str`](Self::as_str) is the `snake_case` name a binding + /// reports in its payload's `reason` field; `Display` is the phrase the + /// message uses. `#[non_exhaustive]`: a reason added later is not a break, + /// and a binding that switches on one keeps a fallback. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + #[non_exhaustive] + pub enum Reason { + /// A plan, field spec, source or stored record is not an object (a + /// map), or a list of them, where one is expected. + NotAnObject => "not_an_object", "not an object where one is expected"; + /// A context value is of a kind a context cannot hold: a boolean, + /// float, null, object or passthrough. + ContextKind => "context_kind", "a boolean, float, null, object or passthrough cannot be a context"; + /// A context string is not UTF-8. + ContextNotUtf8 => "context_not_utf8", "a context string is not UTF-8"; + /// A context renders empty. + EmptyContext => "empty_context", "the context renders empty"; + /// A field's context is not a label of at least two plain segments, + /// optionally extended by scalar parts. + ContextNotLabel => "context_not_label", "the context is not a label of at least two plain segments, optionally extended"; + /// The plan's fields sit under different contexts, or carry different + /// extensions. + MixedContexts => "mixed_contexts", "the fields sit under different contexts or extensions"; + /// The plan has no fields. + NoFields => "no_fields", "the plan has no fields"; + /// A field is named twice in the plan. + DuplicateField => "duplicate_field", "a field is named twice"; + /// A field spec has a key other than `"context"`, `"outputs"`, + /// `"target"` and `"type"`. + UnknownKey => "unknown_key", r#"a key other than "context", "outputs", "target" and "type""#; + /// A key is given twice: in a field spec, or in a map inside a source + /// value or a stored record. + RepeatedKey => "repeated_key", "a key is given twice"; + /// A field spec has no `"context"`. + MissingContext => "missing_context", r#"no "context""#; + /// A field spec has neither `"outputs"` nor `"target"`. + MissingOutputs => "missing_outputs", r#"neither "outputs" nor "target""#; + /// A field spec has both `"outputs"` and `"target"`, or a field is + /// declared both as a target and with data verbs. + OutputsWithTarget => "outputs_with_target", r#"both "outputs" and "target""#; + /// `"outputs"` is not a list. + OutputsNotList => "outputs_not_list", r#""outputs" is not a list"#; + /// An output is not `"c"`, `"passthrough"` or an index in its wire + /// form, or a match index's options are not valid. + UnknownOutput => "unknown_output", r#"an output is not "c", "passthrough" or a valid index"#; + /// A field's output list, or its index set, is empty. + NoOutputs => "no_outputs", "no outputs"; + /// A field names one output, or one index, twice. + DuplicateOutput => "duplicate_output", "an output is named twice"; + /// A field names `"passthrough"` beside another output. + PassthroughWithOutputs => "passthrough_with_outputs", r#""passthrough" beside another output"#; + /// `"target"` is not a non-empty string. + InvalidTarget => "invalid_target", r#""target" is not a non-empty string"#; + /// `"type"` is not a string naming a value type. + UnknownType => "unknown_type", r#""type" does not name a value type"#; + /// The field's declared type has no such index: match on an integer, + /// equality on a float, any index on a composite. + IndexNotAdmitted => "index_not_admitted", "the declared type has no such index"; + /// Two fields are keyed under one identity. + SharedIdentity => "shared_identity", "two fields are keyed under one identity"; + /// The plan has no field of the name asked for. + NoSuchField => "no_such_field", "the plan has no such field"; + /// The field asked for does not name an EQL type. + NotATarget => "not_a_target", "the field does not name an EQL type"; + /// The field a plan takes its context from (`"context_field"`) is not a + /// string passthrough, or the plan-level `"context_field"` key is not a + /// string. + ContextField => "context_field", r#"the context field is not a string passthrough, or "context_field" is not a string"#; + /// The plan builder refused the plan for a reason none of the above + /// names. + Refused => "refused", "the plan builder refused it"; + /// A field the plan names is not there. + FieldMissing => "field_missing", "a field the plan names is missing"; + /// A field is there twice. + FieldRepeated => "field_repeated", "a field is given twice"; + /// A field is there that the plan does not name. + UnknownField => "unknown_field", "a field the plan does not name"; + /// A value is not of the type its field declares. + FieldType => "field_type", "a value is not of the type its field declares"; + /// A passthrough sits where a sealed value, or a ciphertext, must be. + Passthrough => "passthrough", "a passthrough where a sealed value must be"; + /// A stored field is not a map of outputs. + OutputsNotMap => "outputs_not_map", "a stored field is not a map of outputs"; + /// A stored sealed field has no `"c"` node. + NoCiphertextNode => "no_ciphertext_node", r#"no "c" node"#; + /// A stored passthrough field has no `"passthrough"` node. + NoPassthroughNode => "no_passthrough_node", r#"no "passthrough" node"#; + /// A stored target field has no `"eql"` node. + NoEqlNode => "no_eql_node", r#"no "eql" node"#; + /// A stored `"passthrough"` or `"eql"` node is not a passthrough + /// carrying a value of the kind it holds. + NotPassthrough => "not_passthrough", "a node does not carry a value of the kind it holds"; + } } impl Reason { /// The reason's `snake_case` name, as a binding reports it. pub fn as_str(self) -> &'static str { - match self { - Self::NotAnObject => "not_an_object", - Self::ContextKind => "context_kind", - Self::ContextNotUtf8 => "context_not_utf8", - Self::EmptyContext => "empty_context", - Self::ContextNotLabel => "context_not_label", - Self::MixedContexts => "mixed_contexts", - Self::NoFields => "no_fields", - Self::DuplicateField => "duplicate_field", - Self::UnknownKey => "unknown_key", - Self::RepeatedKey => "repeated_key", - Self::MissingContext => "missing_context", - Self::MissingOutputs => "missing_outputs", - Self::OutputsWithTarget => "outputs_with_target", - Self::OutputsNotList => "outputs_not_list", - Self::UnknownOutput => "unknown_output", - Self::NoOutputs => "no_outputs", - Self::DuplicateOutput => "duplicate_output", - Self::PassthroughWithOutputs => "passthrough_with_outputs", - Self::InvalidTarget => "invalid_target", - Self::UnknownType => "unknown_type", - Self::IndexNotAdmitted => "index_not_admitted", - Self::SharedIdentity => "shared_identity", - Self::NoSuchField => "no_such_field", - Self::NotATarget => "not_a_target", - Self::ContextField => "context_field", - Self::Refused => "refused", - Self::FieldMissing => "field_missing", - Self::FieldRepeated => "field_repeated", - Self::UnknownField => "unknown_field", - Self::FieldType => "field_type", - Self::Passthrough => "passthrough", - Self::OutputsNotMap => "outputs_not_map", - Self::NoCiphertextNode => "no_ciphertext_node", - Self::NoPassthroughNode => "no_passthrough_node", - Self::NoEqlNode => "no_eql_node", - Self::NotPassthrough => "not_passthrough", - } + Self::WORDS[self as usize].0 } - - /// Every reason, in declaration order: what a binding's test iterates. - pub const ALL: &'static [Reason] = &[ - Self::NotAnObject, - Self::ContextKind, - Self::ContextNotUtf8, - Self::EmptyContext, - Self::ContextNotLabel, - Self::MixedContexts, - Self::NoFields, - Self::DuplicateField, - Self::UnknownKey, - Self::RepeatedKey, - Self::MissingContext, - Self::MissingOutputs, - Self::OutputsWithTarget, - Self::OutputsNotList, - Self::UnknownOutput, - Self::NoOutputs, - Self::DuplicateOutput, - Self::PassthroughWithOutputs, - Self::InvalidTarget, - Self::UnknownType, - Self::IndexNotAdmitted, - Self::SharedIdentity, - Self::NoSuchField, - Self::NotATarget, - Self::ContextField, - Self::Refused, - Self::FieldMissing, - Self::FieldRepeated, - Self::UnknownField, - Self::FieldType, - Self::Passthrough, - Self::OutputsNotMap, - Self::NoCiphertextNode, - Self::NoPassthroughNode, - Self::NoEqlNode, - Self::NotPassthrough, - ]; } impl fmt::Display for Reason { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - Self::NotAnObject => "not an object where one is expected", - Self::ContextKind => { - "a boolean, float, null, object or passthrough cannot be a context" - } - Self::ContextNotUtf8 => "a context string is not UTF-8", - Self::EmptyContext => "the context renders empty", - Self::ContextNotLabel => { - "the context is not a label of at least two plain segments, optionally extended" - } - Self::MixedContexts => "the fields sit under different contexts or extensions", - Self::NoFields => "the plan has no fields", - Self::DuplicateField => "a field is named twice", - Self::UnknownKey => r#"a key other than "context", "outputs", "target" and "type""#, - Self::RepeatedKey => "a key is given twice", - Self::MissingContext => r#"no "context""#, - Self::MissingOutputs => r#"neither "outputs" nor "target""#, - Self::OutputsWithTarget => r#"both "outputs" and "target""#, - Self::OutputsNotList => r#""outputs" is not a list"#, - Self::UnknownOutput => r#"an output is not "c", "passthrough" or a valid index"#, - Self::NoOutputs => "no outputs", - Self::DuplicateOutput => "an output is named twice", - Self::PassthroughWithOutputs => r#""passthrough" beside another output"#, - Self::InvalidTarget => r#""target" is not a non-empty string"#, - Self::UnknownType => r#""type" does not name a value type"#, - Self::IndexNotAdmitted => "the declared type has no such index", - Self::SharedIdentity => "two fields are keyed under one identity", - Self::NoSuchField => "the plan has no such field", - Self::NotATarget => "the field does not name an EQL type", - Self::ContextField => { - r#"the context field is not a string passthrough, or "context_field" is not a string"# - } - Self::Refused => "the plan builder refused it", - Self::FieldMissing => "a field the plan names is missing", - Self::FieldRepeated => "a field is given twice", - Self::UnknownField => "a field the plan does not name", - Self::FieldType => "a value is not of the type its field declares", - Self::Passthrough => "a passthrough where a sealed value must be", - Self::OutputsNotMap => "a stored field is not a map of outputs", - Self::NoCiphertextNode => r#"no "c" node"#, - Self::NoPassthroughNode => r#"no "passthrough" node"#, - Self::NoEqlNode => r#"no "eql" node"#, - Self::NotPassthrough => "a node does not carry a value of the kind it holds", - }) + f.write_str(Self::WORDS[*self as usize].1) } } diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 332789579..699f9524c 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -6639,6 +6639,128 @@ mod tests { } } + /// Each refusal from the plan builder maps to the field it names and + /// the reason a binding reports: the lowering checks most of these + /// first, so not every one is reachable through [`plan`]. + #[test] + fn every_plan_refusal_maps_to_its_field_and_reason() { + use crate::PlanError as P; + use Reason::*; + let field = || "age".to_string(); + let some = |name: &str| Some(name.to_string()); + let cases: Vec<(crate::Error, Option, Reason)> = vec![ + (crate::Error::Aead, None, Refused), + ( + P::ContextLabel(crate::LabelError::Empty).into(), + None, + ContextNotLabel, + ), + ( + P::FieldLabel { + field: field(), + source: crate::LabelError::Empty, + } + .into(), + some("age"), + ContextNotLabel, + ), + ( + P::DuplicateField { field: field() }.into(), + some("age"), + DuplicateField, + ), + ( + P::SharedIdentity { + identity: "age".into(), + first: "age".into(), + second: "years".into(), + } + .into(), + some("years"), + SharedIdentity, + ), + ( + P::PassthroughIndexed { field: field() }.into(), + some("age"), + PassthroughWithOutputs, + ), + ( + P::DuplicateIndex { + at: field(), + index: "eq", + } + .into(), + some("age"), + DuplicateOutput, + ), + (P::EmptyIndexes.into(), None, NoOutputs), + ( + P::NotInPlan { field: field() }.into(), + some("age"), + UnknownField, + ), + ( + P::NotInValue { field: field() }.into(), + some("age"), + FieldMissing, + ), + ( + P::FieldType { + field: field(), + expected: "int64", + } + .into(), + some("age"), + FieldType, + ), + ( + P::NoSuchField { field: field() }.into(), + some("age"), + NoSuchField, + ), + ( + P::TargetWithVerbs { field: field() }.into(), + some("age"), + OutputsWithTarget, + ), + ( + P::IndexNotDeclared { + field: field(), + index: "ore", + } + .into(), + some("age"), + Refused, + ), + ( + P::IndexOptions { + field: field(), + declared: IndexSpec::Equality, + asked: IndexSpec::Equality, + } + .into(), + some("age"), + Refused, + ), + (P::IdentityWithoutField.into(), None, Refused), + (P::MixedCiphers.into(), None, Refused), + ( + P::TwoContextSources { + first: "the plan", + second: "the call", + } + .into(), + None, + Refused, + ), + (P::NoContext.into(), None, Refused), + ]; + for (error, field, reason) in cases { + let shown = format!("{error:?}"); + assert_eq!(refusal(error), (field, reason), "{shown}"); + } + } + /// `in_field` names the field only where none is named yet. #[test] fn in_field_fills_only_an_unnamed_field() { diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index c1b94da53..57de194a7 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -329,13 +329,13 @@ impl crate::ErrorPayload for TargetError { ("reason", reason.as_str().into()), ]), }; - // `name` is the target type for the first three, and the field for + // `name` is the target type for the first four, and the field for // the rest (the lowering fills it in): keep the two apart. match self { - Self::NoTargets { name } | Self::Unknown { name } | Self::NoQuery { name } => { - let _ = fields.insert("target".to_owned(), name.as_str().into()); - } - Self::Unproducible { name, .. } => { + Self::NoTargets { name } + | Self::Unknown { name } + | Self::NoQuery { name } + | Self::Unproducible { name, .. } => { let _ = fields.insert("target".to_owned(), name.as_str().into()); } Self::Extended { name, .. } From c548f9e9c4f4ab61fc6c358437521aba620e9e7e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 22:48:41 +0000 Subject: [PATCH 06/22] fix(stack-auth): a refused exchange's ServerError names the status, never the body The Go binding's test for a 403 from the edge in front of the auth server showed the access-key and OIDC refreshers putting the whole response body into ServerError's message: "Server error: 403: ...nginx CSAK...". A body from the edge is an HTML page, and any body may echo the credential the request carried, so under the rule on ErrorPayload it never belongs in a message. With se_last_error that message now crosses into every binding. ServerError::refused builds the message from the status and, when the body is the auth server's JSON error, its error_description, which the rule allows. ServerError::unparseable replaces serde_json's message, which can quote the body, with where the JSON broke. The refresh-lock join failure no longer repeats tokio's error text. The body is still logged at debug level where it was before. The test that asserted the body was in the message now asserts it is not. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- .changeset/auth-error-codes-and-help.md | 1 + .../stack-auth/src/access_key_refresher.rs | 6 +- packages/stack-auth/src/device_code/mod.rs | 4 +- .../src/device_session_refresher.rs | 10 +-- packages/stack-auth/src/error.rs | 38 ++++++++++++ packages/stack-auth/src/oidc_refresher.rs | 6 +- packages/stack-auth/src/token.rs | 4 +- packages/stack-auth/src/transport.rs | 62 +++++++++++++++---- 8 files changed, 102 insertions(+), 29 deletions(-) diff --git a/.changeset/auth-error-codes-and-help.md b/.changeset/auth-error-codes-and-help.md index ef2b8a636..3fdac5dda 100644 --- a/.changeset/auth-error-codes-and-help.md +++ b/.changeset/auth-error-codes-and-help.md @@ -7,6 +7,7 @@ Auth failures carry more help, and their messages never quote a credential or an - `REQUEST_ERROR`'s message no longer repeats the transport's own error, which can carry a URL with its query string. It gains `help` saying what to check. - `INVALID_TOKEN` for a token whose claims do not decode no longer quotes the decoder's message, which could carry a byte or a claim of the token. - A failed device binding reports ZeroKMS's status, not its response body. +- `SERVER_ERROR` for a refused token exchange names the HTTP status and the auth server's `error_description`, not the response body, which from the edge in front of it is an HTML page and can echo the access key. A body that is not JSON is reported by where it broke, not by the parser's message. - A profile file that is not valid JSON is reported by error kind, line and column, not by the parser's message, which could quote the file. - `INVALID_GRANT`, `INVALID_WORKSPACE_ID` and `ALREADY_CONSUMED` gain `help`, and `NOT_AUTHENTICATED`'s help names `stash auth login`. - A `STORE_ERROR` carries the help of the profile failure underneath it, such as logging in again when the profile file is missing. diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index f7e5ccdc4..5327ceefc 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -70,9 +70,9 @@ impl Refresher for AccessKeyRefresher { if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } - return Err(AuthError::Server(crate::error::ServerError(format!( - "{status}: {body}" - )))); + return Err(AuthError::Server(crate::error::ServerError::refused( + status, &body, + ))); } let auth_resp: AuthoriseResponse = resp.json()?; diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index b0428f8fe..eb8f659d4 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -394,9 +394,7 @@ impl PendingDeviceCode { } let err: ErrorResponse = serde_json::from_str(&body).map_err(|e| { - AuthError::Server(crate::error::ServerError(format!( - "{status}: unparseable error body: {e}" - ))) + AuthError::Server(crate::error::ServerError::unparseable(status, &e)) })?; match err.error.as_str() { "authorization_pending" => { diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 12e8990e9..225de5735 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -126,10 +126,12 @@ impl DeviceSessionRefresher { }; let lock = tokio::task::spawn_blocking(move || store.lock_exclusive(Token::FILENAME)) .await - .map_err(|e| { - AuthError::Server(crate::error::ServerError(format!( - "refresh lock task join failed: {e}" - ))) + // A join error is tokio's: it says only that the task panicked + // or was cancelled, and is not ours to repeat. + .map_err(|_| { + AuthError::Server(crate::error::ServerError( + "refresh lock task join failed".to_owned(), + )) })? .map_err(|e| { AuthError::Server(crate::error::ServerError(format!( diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index ada467fdb..94678966f 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -372,6 +372,12 @@ impl AuthErrorKind for UsageLimitExceeded { } /// An unexpected error was returned by the auth server. +/// +/// The message the crate builds names the HTTP status and, where the auth +/// server gave one, its `error_description`: never the response body. From +/// the edge in front of the auth server a body is an HTML page, and any +/// body may echo the credential the request carried (see +/// [`ErrorPayload`] for the rule). #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Server error: {0}")] #[diagnostic(code(stack_auth::server_error))] @@ -382,6 +388,38 @@ impl AuthErrorKind for ServerError { } } +impl ServerError { + /// A refused exchange no classifier had anything more specific for: the + /// status, and the auth server's `error_description` when the body is + /// its JSON error. + pub(crate) fn refused(status: u16, body: &str) -> Self { + let description = serde_json::from_str::(body) + .ok() + .and_then(|value| { + value + .get("error_description")? + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) + }); + match description { + Some(description) => Self(format!("{status}: {description}")), + None => Self(status.to_string()), + } + } + + /// An error body that is not the JSON the endpoint answers with: the + /// status, and where the JSON broke. serde_json's own message can quote + /// the body, so it is not used. + pub(crate) fn unparseable(status: u16, error: &serde_json::Error) -> Self { + Self(format!( + "{status}: unparseable error body ({})", + stack_profile::diagnostic::describe_json_error(error) + )) + } +} + /// A consumable handle (e.g. a device-code poll) was used after it had already /// been consumed. A caller bug rather than an auth outcome, but surfaced as an /// `AuthError` so it flows through the `Result` contract rather than throwing diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index ae6378fd2..779e89d48 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -189,9 +189,9 @@ impl OidcFederation { if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } - return Err(AuthError::Server(crate::error::ServerError(format!( - "{status}: {body}" - )))); + return Err(AuthError::Server(crate::error::ServerError::refused( + status, &body, + ))); } let auth_resp: AuthoriseResponse = resp.json()?; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 9978c5630..a66d10499 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -313,9 +313,7 @@ impl Token { } let err: RefreshErrorResponse = serde_json::from_str(&body).map_err(|e| { - AuthError::Server(crate::error::ServerError(format!( - "{status}: unparseable error body: {e}" - ))) + AuthError::Server(crate::error::ServerError::unparseable(status, &e)) })?; return Err(match err.error.as_str() { diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index 5ad41d0b5..a742710d3 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -882,22 +882,58 @@ mod tests { assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); } + /// An unclassified failure names its status and never carries the + /// body: from the edge it is an HTML page, and it may echo the + /// credential. The auth server's own error description is kept. #[tokio::test] - async fn an_unclassified_failure_is_a_server_error_with_the_body() { - let transport: SharedTransport = Arc::new(Stub::replying(500, "boom")); - let refresher = AccessKeyRefresher::new( - SecretToken::new("CSAKid.secret"), - base_url(), + async fn an_unclassified_failure_is_a_server_error_without_the_body() { + const PAGE: &str = "

403 Forbidden

nginx CSAKmarker.secret"; + let refused = |body: &'static str| { + let transport: SharedTransport = Arc::new(Stub::replying(403, body)); + AccessKeyRefresher::new( + SecretToken::new("CSAKmarker.secret"), + base_url(), + None, + transport, + ) + }; + let err = refused(PAGE).refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::Server(_)), "{err:?}"); + assert_eq!(err.to_string(), "Server error: 403"); + + let transport: SharedTransport = Arc::new(Stub::replying(403, PAGE)); + let err = OidcFederation::new(workspace_id(), base_url(), transport) + .federate(&SecretToken::new("h.p.s")) + .await + .unwrap_err(); + assert_eq!(err.to_string(), "Server error: 403"); + + let described = r#"{"error":"forbidden","error_description":"client is disabled"}"#; + let err = refused(described).refresh(&()).await.unwrap_err(); + assert_eq!(err.to_string(), "Server error: 403: client is disabled"); + } + + /// An error body that is not JSON says where it broke, not what it held. + #[tokio::test] + async fn an_unparseable_error_body_is_not_quoted() { + let transport: SharedTransport = + Arc::new(Stub::replying(400, r#"{"error": "marker-rt-echo"#)); + let err = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", None, - transport, + ) + .await + .unwrap_err(); + let shown = err.to_string(); + assert!(matches!(err, AuthError::Server(_)), "{err:?}"); + assert!( + shown.starts_with("Server error: 400: unparseable error body"), + "{shown}" ); - let err = refresher.refresh(&()).await.unwrap_err(); - match err { - AuthError::Server(e) => { - assert!(e.to_string().contains("500") && e.to_string().contains("boom")) - } - other => panic!("{other:?}"), - } + assert!(!shown.contains("marker"), "{shown}"); } #[tokio::test] From 2fd206407b37ac9d7e2b9c10a22ecbd2c5679533 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 19:03:15 -0700 Subject: [PATCH 07/22] docs(stack-encrypt): clarify error changelog Explain error details and migration steps in plain language. Refs #1099 --- packages/stack-encrypt/CHANGELOG.md | 76 ++++++++++++++--------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a2fb441a4..68294d84f 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,27 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Every error has a miette code, help where a caller can act, and its - facts as structured fields.** `Error`, `PlanError`, `LabelError`, - `LeafBytesError`, `sem::TermError`, `sem::TermBytesError` and, with - `dynamic`, `dynamic::Error` and `dynamic::TargetError` derive - `miette::Diagnostic` with a path-style code named after the crate - (`stack_encrypt::aead`, `stack_encrypt::foreign_keyset`), listed in - `stack_encrypt::ERROR_CODES` and pinned by a test that builds every - variant. A variant that carries another crate's error forwards its code: - `Error::Kms` shows `stack_kms::keyset_not_found` itself. Each error - implements `ErrorPayload` (re-exported here from `stack-profile`, the - crate `stack-profile`, `stack-auth`, `stack-kms` and this crate share), - whose `payload()` gives the facts a caller branches on — both keyset ids - of a `ForeignKeyset`, the field of a plan refusal — and whose docs hold - the rule for what an error may contain: no plaintext, key material, - tokens, ciphertext or term bytes, or raw context values. Codes are for - crossing a boundary; Rust code keeps matching variants. -- **A dynamic input error names its field and says why.** `dynamic::Reason` - is the fixed vocabulary (`MissingContext`, `DuplicateOutput`, - `FieldMissing`, `NoCiphertextNode`, ...; `as_str()` is its `snake_case` - name), and `dynamic::Error::field()`, `reason()` and `in_field()` read - and fill them. +- **Errors now expose stable codes, help text, and structured details.** + All error types implement `miette::Diagnostic` with codes such as + `stack_encrypt::foreign_keyset`. Help text explains how to resolve + errors callers can fix. `ErrorPayload::payload()` provides details such + as both keyset IDs for a wrong-keyset error or the field rejected by a + plan. `ErrorPayload` is re-exported from `stack-profile`, and + `ERROR_CODES` lists the crate's codes. Codes identify errors across + language boundaries; Rust callers can continue matching enum variants. + The payload policy excludes plaintext, key material, tokens, + ciphertext, search-index bytes, and raw context values. +- **Data-driven input errors identify the field and the reason.** + `dynamic::Reason` provides fixed reasons such as `MissingContext`, + `DuplicateOutput`, and `FieldMissing`, with stable `snake_case` names + through `as_str()`. Use `dynamic::Error::field()` and `reason()` to + inspect these details, or `in_field()` to attach a field name. - **A data plan field may name an EQL type as its target.** Beside the output form, `dynamic::record::plan_with` reads `{"context": [...], "target": "TextEq", "type"?: ...}` — the two forms are exclusive — and @@ -53,25 +47,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- **`dynamic::Error`'s input variants carry the field and a reason.** - `Context`, `Plan`, `Source` and `Record` are struct variants - `{ field: Option, reason: Reason }`, and `Term` gains - `field: Option`: a match on `Error::Plan` becomes - `Error::Plan { .. }`. The messages name both (`record plan is malformed: - an output is named twice (field "age")`). A source with a field the plan - does not name is now refused naming that field (`UnknownField`) after the - plan's own fields are checked, rather than first, on a field count. -- **`Error::Kms` is transparent.** Its message, code and help are the - `stack_kms::Error`'s; the `ZeroKMS data-key operation failed:` prefix is - gone, and `source()` skips to the ZeroKMS error's own cause. -- **Some messages leave out what an error may not contain.** - `Error::ContextMismatch` gives the stored context's length and number of - parts instead of its descriptor, which can be customer data (the - `stored` field still holds it). `sem::TermError::Prf` no longer repeats - the PRF backend's message, and - `sem::TermBytesError::MatchPositionOutOfRange` no longer quotes the - position read from the term's bytes: both stay on the error for a caller - in this process. +- **Update pattern matches for `dynamic::Error`.** `Context`, `Plan`, + `Source`, and `Record` now carry + `{ field: Option, reason: Reason }`; `Term` also gains + `field: Option`. For example, replace `Error::Plan` with + `Error::Plan { .. }`. Messages include the reason and the field when + known. Extra input fields are reported as `UnknownField` after the + plan's declared fields are checked, replacing the earlier field-count + check. +- **Key-management errors pass through directly.** `Error::Kms` now uses + the underlying `stack_kms::Error` message, code, and help. The + `ZeroKMS data-key operation failed:` prefix is removed, and `source()` + returns the underlying error's own cause. ZeroKMS is CipherStash's + key-management service. +- **Error messages omit potentially sensitive values.** + `Error::ContextMismatch` reports the stored context's length and number + of parts instead of its contents. `sem::TermError::Prf` omits the + underlying pseudorandom-function implementation's message, and + `sem::TermBytesError::MatchPositionOutOfRange` omits the position read + from search-index bytes. These details remain available on the Rust + error values for callers in the same process, including the `stored` + field on `ContextMismatch`. - **A data plan field with a term output must declare its `"type"`.** A plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no `"type"` is refused when it is built (`Error::UntypedIndex`, naming the From cef0a1c620159ad13f2af9b80dc537cce5a9c222 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 19:05:18 -0700 Subject: [PATCH 08/22] docs(stack-profile): explain error diagnostics Show developers how to inspect codes, help, and payload fields, with an example and guidance for safely reporting underlying errors. Refs #1099 --- packages/stack-profile/src/diagnostic.rs | 198 +++++++++++++---------- 1 file changed, 109 insertions(+), 89 deletions(-) diff --git a/packages/stack-profile/src/diagnostic.rs b/packages/stack-profile/src/diagnostic.rs index feba52567..a06e06df9 100644 --- a/packages/stack-profile/src/diagnostic.rs +++ b/packages/stack-profile/src/diagnostic.rs @@ -1,93 +1,112 @@ -//! The structured fields an error carries, and what an error may contain. +//! Inspect error codes, recovery hints, and structured details. +//! +//! [`ProfileError`](crate::ProfileError) provides a readable message through +//! `Display`, a code and optional help through [`miette::Diagnostic`], and +//! structured fields through [`ErrorPayload::payload`]. Use these fields when +//! you need details such as a missing profile's path or a JSON error's line +//! and column. You do not need to parse the message. +//! +//! # Read an error's details +//! +//! Import both traits to access their methods: +//! +//! ``` +//! use miette::Diagnostic; +//! use stack_profile::{ErrorPayload, ProfileError}; +//! +//! let error = ProfileError::NotFound { +//! path: "auth.json".into(), +//! }; +//! +//! assert_eq!(error.code().unwrap().to_string(), "stack_profile::not_found"); +//! assert_eq!(error.payload()["path"], "auth.json"); +//! assert!(error.help().is_some()); +//! ``` +//! +//! In Rust, match error variants to decide how to handle a failure. Codes +//! identify errors when reporting them to another language or service; fields +//! provide the details needed to explain or handle that failure. Payload keys +//! use `snake_case`, and errors without additional details return an empty map. +//! +//! `stack-auth`, `stack-kms`, and `stack-encrypt` use the same trait and +//! re-export it, so you can import it from the crate you already use. +//! +//! # Handle underlying errors with care +//! +//! These crates avoid including sensitive input in diagnostic messages, help, +//! and payloads. Wrapped library errors remain available through +//! [`std::error::Error::source`] for local troubleshooting. Their messages may +//! contain input, credentials, or URLs: inspect them before including them in +//! logs or reports. Do not assume that formatting an entire error chain is +//! safe because its top-level message omits sensitive values. +//! +//! For example, [`describe_json_error`] reports the error kind, line, and +//! column instead of the JSON parser's message, which can quote profile +//! contents containing a token. +//! +//! # Implement an error payload +//! +//! Implement [`ErrorPayload`] for an error that already implements +//! [`miette::Diagnostic`]. Override `payload()` to return its structured +//! details, using the [`payload`] helper to build a map from name/value pairs. +//! Keep the default empty map when there are no useful details. +//! +//! The following policy applies to messages, help, payload fields, and any +//! causes reported through a language binding. +//! +//! ## Allowed details +//! +//! - Keyset IDs and names; counts and lengths; index names and kinds. +//! - Schema field names from a plan or Go struct, rather than field values. +//! - Request kinds and HTTP status codes for ZeroKMS, CipherStash's +//! key-management service. +//! - Workspace IDs, workspace resource names (CRNs), and regions. +//! - Profile file paths, which identify a store rather than its contents. +//! - The CipherStash token service's OAuth `error_description`, intended for +//! people to read. +//! - Messages from errors governed by this policy, or fixed library messages +//! such as those from `url::ParseError`. +//! +//! ## Details to exclude +//! +//! Never include plaintext, key material (data, index, or client keys), access +//! or refresh tokens, ciphertext bytes, search-index bytes, or raw context +//! values. Contexts can contain customer data taken from record fields; report +//! their length and number of parts instead of their contents. +//! +//! Do not forward arbitrary library error messages. Report the operation or +//! error kind instead, and retain the original error through `source()`. +//! Likewise, omit ZeroKMS response bodies because they may echo request data; +//! report the request kind and HTTP status instead. +//! +//! Custom implementations that supply boxed errors, such as +//! `stack_encrypt::Error::Other` or `dynamic::TargetError::Other`, are +//! responsible for their messages: these messages are displayed as supplied. +//! Describe what failed without quoting the value or ciphertext, and do not +//! pass through another library's message. +//! +//! If a new field is not covered by this policy, decide whether it is safe +//! and document that decision here before adding it. -/// The structured fields an error carries beside its message, its miette -/// [`code`](miette::Diagnostic::code) and its help. +/// Structured error details that callers can read without parsing a message. /// -/// Every error in `stack-profile`, `stack-auth`, `stack-kms` and -/// `stack-encrypt` implements it. Those are the crates whose errors reach a -/// caller through a language binding. The trait is defined here because this -/// crate is the one all four depend on, and each of the others re-exports -/// it, so a caller names it from the crate it uses. -/// -/// [`payload`](Self::payload) gives the error's facts as data: which keyset a -/// value was sealed under, which plan field was refused, how long a context -/// was. A binding hands them to its caller beside the code, so the caller can -/// branch on a field instead of parsing a message. Codes are for crossing a -/// boundary: Rust code that needs to branch on an error matches the variant. -/// -/// # What an error may contain -/// -/// The rule covers an error's message (its `Display`), its help and every -/// field of its payload, and every error under it that a binding reports as -/// a cause. The Go guests enforce it with a leak test: every error path a -/// test can reach is driven with marker values, and the test fails if a -/// marker appears anywhere in what the guest encodes. -/// -/// **Allowed:** -/// -/// - keyset ids and names -/// - counts and lengths -/// - term kinds and index names -/// - field names from a plan or a Go struct: these describe the schema, not -/// the data -/// - ZeroKMS request kinds and HTTP status numbers -/// - workspace ids, workspace CRNs and region names -/// - the path of a profile file, which names the store and not its contents -/// - a description the CipherStash token service sends for a person to read -/// (an OAuth `error_description`) -/// - the message of an error this rule also governs: one from these four -/// crates, or from a library whose messages are fixed text, such as -/// `url::ParseError` -/// -/// **Never allowed:** -/// -/// - plaintext, or any part of it -/// - key material: data keys, index keys, client keys -/// - access tokens and refresh tokens -/// - ciphertext bytes and index term bytes -/// - raw context values -/// -/// **Decided, with the reason:** -/// -/// - **Context descriptors are left out.** A context can be built from a -/// record field (`#[stash(context_field)]`), so its descriptor can hold -/// customer data. An error about a context gives the descriptor's length -/// and its number of parts instead. -/// - **An error from another library gives its type, not its message.** That -/// message is text these crates do not control: an HTTP client's error can -/// carry a URL with its query string, and a JSON parser's can quote the -/// input it refused. Where these crates wrap such an error, their message -/// names what failed, and the wrapped error stays reachable through -/// [`source`](std::error::Error::source) for a caller in the same process, -/// who decides what to log. It never appears in a message or a payload. -/// - **A slot any implementation can fill shows that implementation's -/// message**, and the implementation answers for it under this rule. Such -/// a slot is a `Box` a trait implementor hands back: -/// `stack_encrypt::Error::Other` from an `EncryptFrom` or `DecryptInto` -/// implementation, `TargetError::Other` from an EQL type resolver. Their -/// own report ("unsupported EQL ciphertext producer or version") is the -/// one a caller needs, so it is shown as given. An implementation that -/// fills one names what it refused, never a byte of the value or the -/// ciphertext, and does not pass another library's message through. -/// - **ZeroKMS response bodies are left out**, until someone confirms that a -/// ZeroKMS error body never echoes what the request carried. An error from -/// a ZeroKMS request gives the request kind and the HTTP status instead. -/// -/// A field that falls under none of these needs a decision before it is -/// added, recorded here. +/// Use [`miette::Diagnostic`] for the code and help, and [`payload`](Self::payload) +/// for fields such as a profile path or a JSON error's position. See the +/// [module documentation](crate::diagnostic) for examples and the policy on error contents. pub trait ErrorPayload: miette::Diagnostic { - /// The error's structured fields, keyed by `snake_case` name. Every value - /// obeys the rule above. Empty unless the error has facts worth - /// branching on. + /// Returns structured details with `snake_case` keys. + /// + /// Values must follow this module's policy on error contents. The default + /// implementation returns an empty map. fn payload(&self) -> serde_json::Map { serde_json::Map::new() } } -/// A payload built from `(name, value)` pairs. +/// Builds a payload map from `(name, value)` pairs. /// -/// Shared by the four crates' [`ErrorPayload`] impls so each reads as a list -/// of fields rather than a map built by hand. +/// Use this when implementing [`ErrorPayload::payload`]. Names should use +/// `snake_case`, and values must follow this module's policy on error contents. pub fn payload( fields: [(&str, serde_json::Value); N], ) -> serde_json::Map { @@ -97,9 +116,11 @@ pub fn payload( .collect() } -/// A JSON error as the rule allows it: what kind of error, and where. Never -/// serde_json's own message, which can quote the input it refused: -/// `syntax error at line 1 column 5`. +/// Describes a JSON error by kind, line, and column without quoting input. +/// +/// For example: `syntax error at line 1 column 5`. Use this instead of the +/// parser's own message when the input may contain credentials or other +/// sensitive data. pub fn describe_json_error(error: &serde_json::Error) -> String { let kind = match error.classify() { serde_json::error::Category::Io => "read error", @@ -110,12 +131,11 @@ pub fn describe_json_error(error: &serde_json::Error) -> String { format!("{kind} at line {} column {}", error.line(), error.column()) } -/// Whether `code` has the shape every code from these crates has: the crate's -/// name, `::`, then a `snake_case` name — `stack_encrypt::foreign_keyset`. +/// Checks that a code uses the given crate prefix and a `snake_case` name. /// -/// Each crate's code test runs every code it can produce through this, so a -/// code in the wrong crate's namespace, or spelled in another case, fails -/// there rather than reaching a binding. +/// For example, `is_code_of("stack_profile", "stack_profile::not_found")` +/// returns `true`. This checks the format, not whether the crate defines the +/// code; use the crate's `ERROR_CODES` list to check membership. pub fn is_code_of(crate_name: &str, code: &str) -> bool { let Some(name) = code .strip_prefix(crate_name) From 76355582ff2b7a23372434c5db51af62c251b8c5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 03:39:39 +0000 Subject: [PATCH 09/22] test: every error variant's code is checked without a hand-kept list of codes Each of stack-profile, stack-auth, stack-kms and stack-encrypt carried a public ERROR_CODES list that only its own tests read: a test built every variant, required its code to be listed, and required every listed code to be produced. Adding or renaming a code meant editing the list too, and the list still could not catch a variant missing from the test's rows. The lists are gone. Each crate's test builds one of every variant and checks its code is in the crate's namespace and snake_case, or, for a variant that wraps another crate's error, in that crate's. The rows are written `pattern => value` through a small test macro whose patterns are the arms of a match with no wildcard, so a variant without a row fails to compile, and each row must build the variant its pattern names. That found two StackKmsBuilderError variants, Auth and InvalidConfig, the old test never built. AuthError::ERROR_CODES, the frozen list the TypeScript bindings and the Go auth guest read, is unchanged. A renamed code now shows only as the changed #[diagnostic(code(...))] line in review. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-auth/src/error.rs | 102 +++---- packages/stack-auth/src/lib.rs | 1 - packages/stack-encrypt/CHANGELOG.md | 6 +- packages/stack-encrypt/src/cipher.rs | 6 +- packages/stack-encrypt/src/codes.rs | 366 ++++++++++------------- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/plan/error.rs | 6 +- packages/stack-kms/src/errors.rs | 258 ++++++++-------- packages/stack-kms/src/lib.rs | 1 - packages/stack-profile/src/diagnostic.rs | 4 +- packages/stack-profile/src/error.rs | 66 ++-- packages/stack-profile/src/lib.rs | 2 +- 12 files changed, 385 insertions(+), 435 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 94678966f..fe1df000e 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -26,7 +26,7 @@ use crate::access_key; /// [`error_code`](Self::error_code) is the frozen code the TypeScript /// bindings publish (`INVALID_CRN`), kept unchanged beside the miette /// [`code`](miette::Diagnostic::code) (`stack_auth::invalid_crn`) every error -/// also carries; [`ERROR_CODES`](crate::ERROR_CODES) lists the miette codes. +/// also carries. pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { /// Stable machine-readable identifier surfaced across FFI boundaries /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with @@ -985,44 +985,6 @@ impl ErrorPayload for AuthError { } } -/// Every miette code an error from this crate can carry: [`AuthError`]'s -/// twenty own codes and [`InvalidAccessKey`](crate::InvalidAccessKey)'s -/// four. `DeviceClientError` adds none: each of its variants carries the -/// code of the [`AuthError`] it converts into. A store failure carries a -/// `stack_profile` code ([`StoreError`]), so it is not here. -/// -/// Not [`AuthError::ERROR_CODES`]: that is the frozen list of `INVALID_CRN` -/// style codes the TypeScript bindings publish. A test maps each -/// [`AuthError`] miette code to its frozen code, so the two cannot drift, -/// and another builds every variant and checks its code is here, so renaming -/// a code means editing this list on purpose. -pub const ERROR_CODES: &[&str] = &[ - "stack_auth::request_error", - "stack_auth::access_denied", - "stack_auth::invalid_grant", - "stack_auth::invalid_client", - "stack_auth::invalid_url", - "stack_auth::invalid_region", - "stack_auth::invalid_crn", - "stack_auth::workspace_mismatch", - "stack_auth::invalid_workspace_id", - "stack_auth::missing_workspace_crn", - "stack_auth::not_authenticated", - "stack_auth::expired_token", - "stack_auth::invalid_access_key", - "stack_auth::invalid_token", - "stack_auth::usage_limit_exceeded", - "stack_auth::org_not_provisioned", - "stack_auth::server_error", - "stack_auth::already_consumed", - "stack_auth::internal_error", - "stack_auth::custom", - "stack_auth::access_key_missing_prefix", - "stack_auth::access_key_missing_dot", - "stack_auth::access_key_empty_id", - "stack_auth::access_key_empty_secret", -]; - // --------------------------------------------------------------------------- // Ergonomic `From` impls — keep `?` working where call sites lift a // foreign error straight into `AuthError` (the per-struct wrapping is internal). @@ -1674,41 +1636,59 @@ mod tests { ); } - /// Every code an error from this crate produces is in [`ERROR_CODES`], - /// in this crate's namespace, and every listed code is produced. + /// One row per variant of an enum, written `pattern => value`. The + /// patterns are the arms of a match with no wildcard, so a variant with + /// no row fails to compile, and each value must match its own pattern. + macro_rules! variants { + ($($pattern:pat => $value:expr),+ $(,)?) => {{ + let rows = vec![$({ + let value = $value; + assert!(matches!(value, $pattern), "{value:?} is not {}", stringify!($pattern)); + value + }),+]; + for row in &rows { + match row { + $($pattern => {})+ + } + } + rows + }}; + } + + /// Every variant has a code in this crate's namespace and `snake_case`, + /// save a store failure, whose code is its profile error's. + /// [`every_variant`] has a row for every frozen code, so every + /// [`AuthError`] variant is here; the access-key rows cover + /// [`InvalidAccessKey`](crate::InvalidAccessKey). #[test] - fn every_variant_has_a_listed_code() { + fn every_variant_has_a_code_of_this_crate() { + use crate::InvalidAccessKey; use miette::Diagnostic; use stack_profile::diagnostic::is_code_of; let mut errors: Vec> = every_variant() .into_iter() .map(|(error, _, _)| Box::new(error) as Box) .collect(); - for key in ["CSAK", "nope", "CSAK.secret", "CSAKid."] { - errors.push(Box::new( - key.parse::().unwrap_err(), - )); - } - let mut seen = std::collections::BTreeSet::new(); + errors.extend( + variants![ + InvalidAccessKey::MissingPrefix => InvalidAccessKey::MissingPrefix, + InvalidAccessKey::MissingDot => InvalidAccessKey::MissingDot, + InvalidAccessKey::EmptyKeyId => InvalidAccessKey::EmptyKeyId, + InvalidAccessKey::EmptySecret => InvalidAccessKey::EmptySecret, + ] + .into_iter() + .map(|error| Box::new(error) as Box), + ); for error in &errors { let code = error .code() .unwrap_or_else(|| panic!("{error:?} has no code")) .to_string(); - if code.starts_with("stack_profile::") { - assert!( - stack_profile::ERROR_CODES.contains(&code.as_str()), - "{code}" - ); - continue; - } - assert!(is_code_of("stack_auth", &code), "{code}"); - assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); - seen.insert(code); + assert!( + is_code_of("stack_auth", &code) || is_code_of("stack_profile", &code), + "{code}" + ); } - let listed: std::collections::BTreeSet = - ERROR_CODES.iter().map(|code| code.to_string()).collect(); - assert_eq!(seen, listed, "every listed code is produced"); } /// A store failure's payload is the profile error's, so a binding diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 506454fb0..0f687463e 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -77,7 +77,6 @@ mod oidc_refresher; mod refresher; pub use error::StoreError; -pub use error::ERROR_CODES; pub use error::{ AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 68294d84f..19b10a25d 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -14,9 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `stack_encrypt::foreign_keyset`. Help text explains how to resolve errors callers can fix. `ErrorPayload::payload()` provides details such as both keyset IDs for a wrong-keyset error or the field rejected by a - plan. `ErrorPayload` is re-exported from `stack-profile`, and - `ERROR_CODES` lists the crate's codes. Codes identify errors across - language boundaries; Rust callers can continue matching enum variants. + plan. `ErrorPayload` is re-exported from `stack-profile`. Codes + identify errors across language boundaries; Rust callers can continue + matching enum variants. The payload policy excludes plaintext, key material, tokens, ciphertext, search-index bytes, and raw context values. - **Data-driven input errors identify the field and the reason.** diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index eb61203e0..77c569d4e 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -120,9 +120,9 @@ pub type StackCipherText = CipherText; /// Errors from sealing or opening a [`StackCipherText`]. /// -/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES), -/// or forwards the code of the error it carries ([`Kms`](Self::Kms), -/// [`Term`](Self::Term), [`Plan`](Self::Plan)); its structured fields are its +/// Every variant has a `stack_encrypt::` miette code, or forwards the code +/// of the error it carries ([`Kms`](Self::Kms), [`Term`](Self::Term), +/// [`Plan`](Self::Plan)); its structured fields are its /// [`ErrorPayload`](crate::ErrorPayload). No message carries a context's /// descriptor or another library's text (see the rule on /// [`ErrorPayload`](crate::ErrorPayload)). diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 79e46c04e..5c9a12c55 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -1,202 +1,161 @@ -//! Every miette code an error from this crate can carry. - -/// Every miette code an error from this crate can carry: [`Error`](crate::Error), -/// [`PlanError`](crate::PlanError), [`LabelError`](crate::LabelError), -/// [`LeafBytesError`](crate::LeafBytesError), the term errors in -/// [`sem`](crate::sem), and with the `dynamic` feature the dynamic module's -/// errors. A variant that carries a `stack_kms` or `stack_auth` error carries -/// its code instead. -/// -/// A test builds every variant and checks its code is here, so renaming a -/// code means editing this list on purpose. Codes are for crossing a -/// boundary: Rust code that branches on an error matches the variant. -pub const ERROR_CODES: &[&str] = &[ - // `Error` - "stack_encrypt::aead", - "stack_encrypt::key_count_mismatch", - "stack_encrypt::descriptor_too_long", - "stack_encrypt::config", - "stack_encrypt::other", - "stack_encrypt::unsupported_shape", - "stack_encrypt::context_mismatch", - "stack_encrypt::response_shape", - "stack_encrypt::keyset_mismatch", - "stack_encrypt::foreign_keyset", - "stack_encrypt::no_keyset", - "stack_encrypt::not_opened", - // `LeafBytesError` - "stack_encrypt::leaf_version", - "stack_encrypt::leaf_truncated", - "stack_encrypt::leaf_tag_too_long", - // `sem::TermError` - "stack_encrypt::prf_failed", - "stack_encrypt::ore_failed", - "stack_encrypt::invalid_match_options", - "stack_encrypt::empty_term_text", - // `sem::TermBytesError` - "stack_encrypt::equality_term_length", - "stack_encrypt::match_term_length", - "stack_encrypt::match_position_out_of_range", - "stack_encrypt::cllw_ciphertext_length", - // `LabelError` - "stack_encrypt::label_empty", - "stack_encrypt::label_empty_segment", - "stack_encrypt::label_separator", - "stack_encrypt::label_reserved", - "stack_encrypt::label_reserved_prefix", - // `PlanError` - "stack_encrypt::plan_context_label", - "stack_encrypt::plan_field_label", - "stack_encrypt::plan_identity_without_field", - "stack_encrypt::plan_duplicate_field", - "stack_encrypt::plan_shared_identity", - "stack_encrypt::plan_passthrough_indexed", - "stack_encrypt::plan_duplicate_index", - "stack_encrypt::plan_empty_indexes", - "stack_encrypt::plan_field_not_in_plan", - "stack_encrypt::plan_field_not_in_value", - "stack_encrypt::plan_field_type", - "stack_encrypt::plan_no_such_field", - "stack_encrypt::plan_mixed_ciphers", - "stack_encrypt::plan_index_not_declared", - "stack_encrypt::plan_index_options", - "stack_encrypt::plan_two_context_sources", - "stack_encrypt::plan_no_context", - "stack_encrypt::plan_target_with_verbs", - // `dynamic::Error` - "stack_encrypt::dynamic_context", - "stack_encrypt::dynamic_term", - "stack_encrypt::dynamic_plan", - "stack_encrypt::dynamic_untyped_index", - "stack_encrypt::dynamic_source", - "stack_encrypt::dynamic_record", - "stack_encrypt::dynamic_internal", - // `dynamic::TargetError` - "stack_encrypt::target_none", - "stack_encrypt::target_unknown", - "stack_encrypt::target_unproducible", - "stack_encrypt::target_no_query", - "stack_encrypt::target_extended", - "stack_encrypt::target_kind", - "stack_encrypt::target_column", - "stack_encrypt::target_plaintext", - "stack_encrypt::target_stored", - "stack_encrypt::target_other", -]; +//! Every error in this crate carries a miette code: one in this crate's +//! namespace, or, for a variant that wraps another crate's error, that +//! error's. The tests here build one of every variant to check it, and pin +//! each error's payload fields. #[cfg(test)] mod tests { - use std::collections::BTreeSet; - use miette::Diagnostic; use uuid::Uuid; - use super::ERROR_CODES; use crate::diagnostic::is_code_of; use crate::sem::{MatchOptions, TermBytesError, TermError}; use crate::target::IndexSpec; use crate::{Descriptor, Error, LabelError, LeafBytesError, PlanError}; - /// One of every variant of every error type here. A transparent variant - /// is built once, to show the code it forwards is listed somewhere. + /// One row per variant of an enum, written `pattern => value`. The + /// patterns are the arms of a match with no wildcard, so a variant with + /// no row fails to compile, and each value must match its own pattern. + macro_rules! variants { + ($($pattern:pat => $value:expr),+ $(,)?) => {{ + let rows = vec![$({ + let value = $value; + assert!(matches!(value, $pattern), "{value:?} is not {}", stringify!($pattern)); + value + }),+]; + for row in &rows { + match row { + $($pattern => {})+ + } + } + rows + }}; + } + + fn boxed(rows: Vec) -> impl Iterator> { + rows.into_iter() + .map(|error| Box::new(error) as Box) + } + + /// One of every variant of every error type here. fn every_variant() -> Vec> { - let boxed = || Box::new(std::io::Error::other("cause")); + let cause = || Box::new(std::io::Error::other("cause")); let (a, b) = (Uuid::from_u128(1), Uuid::from_u128(2)); let field = || "age".to_string(); - let errors: Vec> = vec![ - Box::new(Error::Kms(crate::kms::Error::Unexpected("kms".into()))), - Box::new(Error::Aead), - Box::new(Error::KeyCountMismatch { + let mut errors = Vec::new(); + errors.extend(boxed(variants![ + Error::Kms(_) => Error::Kms(crate::kms::Error::Unexpected("kms".into())), + Error::Aead => Error::Aead, + Error::KeyCountMismatch { .. } => Error::KeyCountMismatch { expected: 2, received: 1, - }), - Box::new(Error::DescriptorTooLong { len: 513 }), - Box::new(Error::Config(boxed())), - Box::new(Error::Term(TermError::EmptyTermText)), - Box::new(Error::Other(boxed())), - Box::new(Error::UnsupportedShape), - Box::new(Error::ContextMismatch { + }, + Error::DescriptorTooLong { .. } => Error::DescriptorTooLong { len: 513 }, + Error::Config(_) => Error::Config(cause()), + Error::Term(_) => Error::Term(TermError::EmptyTermText), + Error::Other(_) => Error::Other(cause()), + Error::UnsupportedShape => Error::UnsupportedShape, + Error::ContextMismatch { .. } => Error::ContextMismatch { stored: Descriptor::of("users"), - }), - Box::new(Error::ResponseShape), - Box::new(Error::KeysetMismatch { left: a, right: b }), - Box::new(Error::ForeignKeyset { + }, + Error::ResponseShape => Error::ResponseShape, + Error::KeysetMismatch { .. } => Error::KeysetMismatch { left: a, right: b }, + Error::ForeignKeyset { .. } => Error::ForeignKeyset { expected: a, found: b, - }), - Box::new(Error::NoKeyset), - Box::new(Error::NotOpened), - Box::new(Error::Plan(PlanError::NoContext)), - Box::new(LeafBytesError::UnknownVersion(9)), - Box::new(LeafBytesError::Truncated), - Box::new(LeafBytesError::TagTooLong(70_000)), - Box::new(TermError::Prf(boxed())), - Box::new(TermError::Ore(cllw_ore::Error)), - Box::new(TermError::InvalidOptions("k out of range")), - Box::new(TermError::EmptyTermText), - Box::new(TermError::Bytes(TermBytesError::OddMatchTermsLength(3))), - Box::new(TermBytesError::WrongEqualityTermLength(3)), - Box::new(TermBytesError::OddMatchTermsLength(3)), - Box::new(TermBytesError::MatchPositionOutOfRange { - position: 900, - filter_size: 256, - }), - Box::new(TermBytesError::MalformedCllwCiphertext(3)), - Box::new(LabelError::Empty), - Box::new(LabelError::EmptySegment { index: 0 }), - Box::new(LabelError::Separator { index: 0 }), - Box::new(LabelError::Reserved { + }, + Error::NoKeyset => Error::NoKeyset, + Error::NotOpened => Error::NotOpened, + Error::Plan(_) => Error::Plan(PlanError::NoContext), + ])); + errors.extend(boxed(variants![ + LeafBytesError::UnknownVersion(_) => LeafBytesError::UnknownVersion(9), + LeafBytesError::Truncated => LeafBytesError::Truncated, + LeafBytesError::TagTooLong(_) => LeafBytesError::TagTooLong(70_000), + ])); + errors.extend(boxed(variants![ + TermError::Prf(_) => TermError::Prf(cause()), + TermError::Ore(_) => TermError::Ore(cllw_ore::Error), + TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"), + TermError::EmptyTermText => TermError::EmptyTermText, + TermError::Bytes(_) => TermError::Bytes(TermBytesError::OddMatchTermsLength(3)), + ])); + errors.extend(boxed(variants![ + TermBytesError::WrongEqualityTermLength(_) => { + TermBytesError::WrongEqualityTermLength(3) + }, + TermBytesError::OddMatchTermsLength(_) => TermBytesError::OddMatchTermsLength(3), + TermBytesError::MatchPositionOutOfRange { .. } => { + TermBytesError::MatchPositionOutOfRange { + position: 900, + filter_size: 256, + } + }, + TermBytesError::MalformedCllwCiphertext(_) => { + TermBytesError::MalformedCllwCiphertext(3) + }, + ])); + errors.extend(boxed(variants![ + LabelError::Empty => LabelError::Empty, + LabelError::EmptySegment { .. } => LabelError::EmptySegment { index: 0 }, + LabelError::Separator { .. } => LabelError::Separator { index: 0 }, + LabelError::Reserved { .. } => LabelError::Reserved { index: 0, found: '(', - }), - Box::new(LabelError::ReservedPrefix { index: 0 }), - Box::new(PlanError::ContextLabel(LabelError::Empty)), - Box::new(PlanError::FieldLabel { + }, + LabelError::ReservedPrefix { .. } => LabelError::ReservedPrefix { index: 0 }, + LabelError::NotText => LabelError::NotText, + ])); + errors.extend(boxed(variants![ + PlanError::ContextLabel(_) => PlanError::ContextLabel(LabelError::Empty), + PlanError::FieldLabel { .. } => PlanError::FieldLabel { field: field(), source: LabelError::Empty, - }), - Box::new(PlanError::IdentityWithoutField), - Box::new(PlanError::DuplicateField { field: field() }), - Box::new(PlanError::SharedIdentity { + }, + PlanError::IdentityWithoutField => PlanError::IdentityWithoutField, + PlanError::DuplicateField { .. } => PlanError::DuplicateField { field: field() }, + PlanError::SharedIdentity { .. } => PlanError::SharedIdentity { identity: "age".into(), first: "age".into(), second: "years".into(), - }), - Box::new(PlanError::PassthroughIndexed { field: field() }), - Box::new(PlanError::DuplicateIndex { + }, + PlanError::PassthroughIndexed { .. } => { + PlanError::PassthroughIndexed { field: field() } + }, + PlanError::DuplicateIndex { .. } => PlanError::DuplicateIndex { at: field(), index: "eq", - }), - Box::new(PlanError::EmptyIndexes), - Box::new(PlanError::NotInPlan { field: field() }), - Box::new(PlanError::NotInValue { field: field() }), - Box::new(PlanError::FieldType { + }, + PlanError::EmptyIndexes => PlanError::EmptyIndexes, + PlanError::NotInPlan { .. } => PlanError::NotInPlan { field: field() }, + PlanError::NotInValue { .. } => PlanError::NotInValue { field: field() }, + PlanError::FieldType { .. } => PlanError::FieldType { field: field(), expected: "int64", - }), - Box::new(PlanError::NoSuchField { field: field() }), - Box::new(PlanError::MixedCiphers), - Box::new(PlanError::IndexNotDeclared { + }, + PlanError::NoSuchField { .. } => PlanError::NoSuchField { field: field() }, + PlanError::MixedCiphers => PlanError::MixedCiphers, + PlanError::IndexNotDeclared { .. } => PlanError::IndexNotDeclared { field: field(), index: "ore", - }), - Box::new(PlanError::IndexOptions { + }, + PlanError::IndexOptions { .. } => PlanError::IndexOptions { field: field(), declared: IndexSpec::Match(MatchOptions::default()), asked: IndexSpec::Match(MatchOptions { downcase: false, ..MatchOptions::default() }), - }), - Box::new(PlanError::TwoContextSources { + }, + PlanError::TwoContextSources { .. } => PlanError::TwoContextSources { first: "the plan", second: "the call", - }), - Box::new(PlanError::NoContext), - Box::new(PlanError::TargetWithVerbs { field: field() }), - ]; + }, + PlanError::NoContext => PlanError::NoContext, + PlanError::TargetWithVerbs { .. } => PlanError::TargetWithVerbs { field: field() }, + ])); #[cfg(feature = "dynamic")] - let errors = errors.into_iter().chain(dynamic_variants()).collect(); + errors.extend(dynamic_variants()); errors } @@ -205,83 +164,78 @@ mod tests { use crate::dynamic::{Error, Reason, TargetError, ValueKind}; let name = || "email".to_string(); let target = || "TextEq".to_string(); - vec![ - Box::new(Error::bad_context(Reason::EmptyContext)), - Box::new(Error::Term { + let mut errors = Vec::new(); + errors.extend(boxed(variants![ + Error::Context { .. } => Error::bad_context(Reason::EmptyContext), + Error::Term { .. } => Error::Term { field: Some(name()), kind: IndexSpec::Equality, - }), - Box::new(Error::bad_plan(Reason::NoFields)), - Box::new(Error::UntypedIndex { field: name() }), - Box::new(Error::bad_source(Reason::FieldMissing)), - Box::new(Error::bad_record(Reason::NoCiphertextNode)), - Box::new(Error::Internal), - Box::new(Error::Target(TargetError::NoTargets { name: target() })), - Box::new(Error::Cipher(crate::Error::Aead)), - Box::new(TargetError::NoTargets { name: target() }), - Box::new(TargetError::Unknown { name: target() }), - Box::new(TargetError::Unproducible { + }, + Error::Plan { .. } => Error::bad_plan(Reason::NoFields), + Error::UntypedIndex { .. } => Error::UntypedIndex { field: name() }, + Error::Source { .. } => Error::bad_source(Reason::FieldMissing), + Error::Record { .. } => Error::bad_record(Reason::NoCiphertextNode), + Error::Internal => Error::Internal, + Error::Target(_) => Error::Target(TargetError::NoTargets { name: target() }), + Error::Cipher(_) => Error::Cipher(crate::Error::Aead), + ])); + errors.extend(boxed(variants![ + TargetError::NoTargets { .. } => TargetError::NoTargets { name: target() }, + TargetError::Unknown { .. } => TargetError::Unknown { name: target() }, + TargetError::Unproducible { .. } => TargetError::Unproducible { name: target(), reason: "block ORE".into(), - }), - Box::new(TargetError::NoQuery { name: target() }), - Box::new(TargetError::Extended { + }, + TargetError::NoQuery { .. } => TargetError::NoQuery { name: target() }, + TargetError::Extended { .. } => TargetError::Extended { name: name(), label: "users/email".into(), - }), - Box::new(TargetError::Kind { + }, + TargetError::ContextField { .. } => TargetError::ContextField { + name: name(), + context_field: "tenant".into(), + }, + TargetError::Kind { .. } => TargetError::Kind { name: name(), target: target(), expected: Some(ValueKind::String), declared: ValueKind::UInt64, - }), - Box::new(TargetError::Column { + }, + TargetError::Column { .. } => TargetError::Column { name: name(), label: "app/users/email".into(), reason: "two segments".into(), - }), - Box::new(TargetError::Plaintext { + }, + TargetError::Plaintext { .. } => TargetError::Plaintext { name: name(), target: target(), expected: Some(ValueKind::String), found: None, - }), - Box::new(TargetError::Stored { + }, + TargetError::Stored { .. } => TargetError::Stored { name: name(), target: target(), reason: "not JSON".into(), - }), - Box::new(TargetError::Other(Box::new(std::io::Error::other("boom")))), - ] + }, + TargetError::Other(_) => TargetError::Other(Box::new(std::io::Error::other("boom"))), + ])); + errors } + /// Every variant has a code in this crate's namespace and `snake_case`, + /// save one that carries a stack-kms error, whose code is that error's. #[test] - fn every_variant_has_a_listed_code() { - let mut seen = BTreeSet::new(); + fn every_variant_has_a_code_of_this_crate() { for error in every_variant() { let code = error .code() .unwrap_or_else(|| panic!("{error:?} has no code")) .to_string(); - if code.starts_with("stack_kms::") { - assert!(crate::kms::ERROR_CODES.contains(&code.as_str()), "{code}"); - continue; - } - assert!(is_code_of("stack_encrypt", &code), "{code}"); - assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); - seen.insert(code); + assert!( + is_code_of("stack_encrypt", &code) || is_code_of("stack_kms", &code), + "{code}" + ); } - // The dynamic module's codes need its feature to be built. - let listed: BTreeSet = ERROR_CODES - .iter() - .filter(|code| { - cfg!(feature = "dynamic") - || !(code.starts_with("stack_encrypt::dynamic_") - || code.starts_with("stack_encrypt::target_")) - }) - .map(|code| code.to_string()) - .collect(); - assert_eq!(seen, listed, "every listed code is produced"); } /// A stored context can be customer data: its descriptor stays out of diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 61520fb8b..37b36ba4f 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -271,6 +271,7 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod cipher; +#[cfg(test)] mod codes; pub mod descriptor; #[cfg(feature = "dynamic")] @@ -292,7 +293,6 @@ pub use plan::{all, Plan, PlanError}; // versioned on its own (release-plz.toml), so a caller reaches it through // here and always gets the version this crate was built against, never a // second copy whose types do not fit `StackCipher`'s bounds. -pub use codes::ERROR_CODES; pub use stack_kms as kms; /// The trait every error here implements to hand over its structured /// fields, with the rule for what an error may contain, and the helpers that diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index fe82b4586..d93b196ed 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -13,9 +13,9 @@ use crate::LabelError; /// /// [`Error::Plan`]: crate::Error::Plan /// -/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES), -/// and names the field it is about where there is one, in its message and -/// its [`ErrorPayload`](crate::ErrorPayload). Field names describe the +/// Every variant has a `stack_encrypt::` miette code, and names the field +/// it is about where there is one, in its message and its +/// [`ErrorPayload`](crate::ErrorPayload). Field names describe the /// schema, not the data, so a message may carry them. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index b382def61..1765a5980 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -191,46 +191,8 @@ impl From for LoadKeysetError { } } -/// Every miette code an error from this crate can carry. The variants that -/// carry a `stack_auth` error carry its code instead, so those are in -/// `stack_auth::ERROR_CODES`. A test builds every variant and checks its code -/// is here, so renaming a code means editing this list on purpose. -pub const ERROR_CODES: &[&str] = &[ - "stack_kms::invalid_key_material", - "stack_kms::retrieve_key_failed", - "stack_kms::retrieved_key_count", - "stack_kms::key_not_retrieved", - "stack_kms::generate_key_unauthorized", - "stack_kms::generate_key_forbidden", - "stack_kms::generate_iv", - "stack_kms::generated_key_count", - "stack_kms::generate_key_failed", - "stack_kms::load_keyset_unauthorized", - "stack_kms::load_keyset_forbidden", - "stack_kms::keyset_not_found", - "stack_kms::load_keyset_failed", - "stack_kms::connection_init", - "stack_kms::invalid_endpoint", - "stack_kms::unexpected", - "stack_kms::endpoint_not_url", - "stack_kms::endpoint_no_host", - "stack_kms::endpoint_scheme", - "stack_kms::endpoint_query_or_fragment", - "stack_kms::endpoint_userinfo", - "stack_kms::client_key_not_configured", - "stack_kms::invalid_client_key", - "stack_kms::client_key_load", - "stack_kms::invalid_client_opts", - "stack_kms::base_url_unresolved", - "stack_kms::unexpected_content_type", - "stack_kms::failure_response", - "stack_kms::http_client_init", -]; - #[cfg(test)] mod codes { - use std::collections::BTreeSet; - use stack_auth::diagnostic::is_code_of; use super::*; @@ -250,66 +212,34 @@ mod codes { .into() } - /// One of every variant of every error type here. A transparent - /// variant is built once, to show the code it forwards is listed - /// somewhere. + /// One row per variant of an enum, written `pattern => value`. The + /// patterns are the arms of a match with no wildcard, so a variant with + /// no row fails to compile, and each value must match its own pattern. + macro_rules! variants { + ($($pattern:pat => $value:expr),+ $(,)?) => {{ + let rows = vec![$({ + let value = $value; + assert!(matches!(value, $pattern), "{value:?} is not {}", stringify!($pattern)); + value + }),+]; + for row in &rows { + match row { + $($pattern => {})+ + } + } + rows + }}; + } + + fn boxed(rows: Vec) -> impl Iterator> { + rows.into_iter() + .map(|error| Box::new(error) as Box) + } + + /// One of every variant of every error type here. fn every_variant() -> Vec> { let mut errors: Vec> = vec![ Box::new(material()), - Box::new(RetrieveKeyError::RequestFailed(vitur( - ViturRequestErrorKind::SendRequest, - ))), - Box::new(RetrieveKeyError::InvalidNumberOfKeys { - expected: 2, - received: 1, - }), - Box::new(RetrieveKeyError::FailedRetrieval("no key".into())), - Box::new(RetrieveKeyError::InvalidKeyMaterial(material())), - Box::new(GenerateKeyError::Unauthorized), - Box::new(GenerateKeyError::Forbidden), - Box::new(GenerateKeyError::GenerateIv(RandomError::GenerationFailed)), - Box::new(GenerateKeyError::InvalidNumberOfKeys { - expected: 2, - received: 1, - }), - Box::new(GenerateKeyError::InvalidKeyMaterial(material())), - Box::new(GenerateKeyError::RequestFailed(vitur( - ViturRequestErrorKind::Other, - ))), - Box::new(LoadKeysetError::Unauthorized(vitur( - ViturRequestErrorKind::Unauthorized, - ))), - Box::new(LoadKeysetError::Forbidden(vitur( - ViturRequestErrorKind::Forbidden, - ))), - Box::new(LoadKeysetError::KeysetNotFound(vitur( - ViturRequestErrorKind::NotFound, - ))), - Box::new(LoadKeysetError::InvalidKeyMaterial(material())), - Box::new(LoadKeysetError::RequestFailed(vitur( - ViturRequestErrorKind::Conflict, - ))), - Box::new(Error::GenerateKey(GenerateKeyError::Forbidden)), - Box::new(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( - "no key".into(), - ))), - Box::new(Error::LoadKeyset(LoadKeysetError::KeysetNotFound(vitur( - ViturRequestErrorKind::NotFound, - )))), - Box::new(Error::Auth(stack_auth::AuthError::TokenExpired( - stack_auth::TokenExpired, - ))), - Box::new(Error::ConnectionInit(Box::new(std::io::Error::other("no")))), - Box::new(Error::InvalidEndpoint(InvalidEndpoint::Userinfo)), - Box::new(Error::Unexpected("unexpected".into())), - Box::new(InvalidEndpoint::Parse(url::ParseError::EmptyHost)), - Box::new(InvalidEndpoint::NoHost("localhost:8080".into())), - Box::new(InvalidEndpoint::Scheme("ftp".into())), - Box::new(InvalidEndpoint::QueryOrFragment("https://x/?q".into())), - Box::new(InvalidEndpoint::Userinfo), - Box::new(KeyProviderError::NotConfigured("unset".into())), - Box::new(KeyProviderError::InvalidKey("not hex".into())), - Box::new(KeyProviderError::LoadError("disk".into())), Box::new(BaseUrlUnresolved), Box::new(UnexpectedContentType { received: Some("text/html".into()), @@ -323,6 +253,88 @@ mod codes { headers: Default::default(), }), ]; + errors.extend(boxed(variants![ + RetrieveKeyError::RequestFailed(_) => { + RetrieveKeyError::RequestFailed(vitur(ViturRequestErrorKind::SendRequest)) + }, + RetrieveKeyError::InvalidNumberOfKeys { .. } => RetrieveKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1, + }, + RetrieveKeyError::FailedRetrieval(_) => { + RetrieveKeyError::FailedRetrieval("no key".into()) + }, + RetrieveKeyError::InvalidKeyMaterial(_) => { + RetrieveKeyError::InvalidKeyMaterial(material()) + }, + ])); + errors.extend(boxed(variants![ + GenerateKeyError::Unauthorized => GenerateKeyError::Unauthorized, + GenerateKeyError::Forbidden => GenerateKeyError::Forbidden, + GenerateKeyError::GenerateIv(_) => { + GenerateKeyError::GenerateIv(RandomError::GenerationFailed) + }, + GenerateKeyError::InvalidNumberOfKeys { .. } => GenerateKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1, + }, + GenerateKeyError::InvalidKeyMaterial(_) => { + GenerateKeyError::InvalidKeyMaterial(material()) + }, + GenerateKeyError::RequestFailed(_) => { + GenerateKeyError::RequestFailed(vitur(ViturRequestErrorKind::Other)) + }, + ])); + errors.extend(boxed(variants![ + LoadKeysetError::Unauthorized(_) => { + LoadKeysetError::Unauthorized(vitur(ViturRequestErrorKind::Unauthorized)) + }, + LoadKeysetError::Forbidden(_) => { + LoadKeysetError::Forbidden(vitur(ViturRequestErrorKind::Forbidden)) + }, + LoadKeysetError::KeysetNotFound(_) => { + LoadKeysetError::KeysetNotFound(vitur(ViturRequestErrorKind::NotFound)) + }, + LoadKeysetError::InvalidKeyMaterial(_) => { + LoadKeysetError::InvalidKeyMaterial(material()) + }, + LoadKeysetError::RequestFailed(_) => { + LoadKeysetError::RequestFailed(vitur(ViturRequestErrorKind::Conflict)) + }, + ])); + // A variant that wraps another of this crate's errors, or a + // stack-auth error, forwards that error's code. + errors.extend(boxed(variants![ + Error::GenerateKey(_) => Error::GenerateKey(GenerateKeyError::Forbidden), + Error::RetrieveKey(_) => { + Error::RetrieveKey(RetrieveKeyError::FailedRetrieval("no key".into())) + }, + Error::LoadKeyset(_) => Error::LoadKeyset(LoadKeysetError::KeysetNotFound(vitur( + ViturRequestErrorKind::NotFound, + ))), + Error::Auth(_) => { + Error::Auth(stack_auth::AuthError::TokenExpired(stack_auth::TokenExpired)) + }, + Error::ConnectionInit(_) => { + Error::ConnectionInit(Box::new(std::io::Error::other("no"))) + }, + Error::InvalidEndpoint(_) => Error::InvalidEndpoint(InvalidEndpoint::Userinfo), + Error::Unexpected(_) => Error::Unexpected("unexpected".into()), + ])); + errors.extend(boxed(variants![ + InvalidEndpoint::Parse(_) => InvalidEndpoint::Parse(url::ParseError::EmptyHost), + InvalidEndpoint::NoHost(_) => InvalidEndpoint::NoHost("localhost:8080".into()), + InvalidEndpoint::Scheme(_) => InvalidEndpoint::Scheme("ftp".into()), + InvalidEndpoint::QueryOrFragment(_) => { + InvalidEndpoint::QueryOrFragment("https://x/?q".into()) + }, + InvalidEndpoint::Userinfo => InvalidEndpoint::Userinfo, + ])); + errors.extend(boxed(variants![ + KeyProviderError::NotConfigured(_) => KeyProviderError::NotConfigured("unset".into()), + KeyProviderError::InvalidKey(_) => KeyProviderError::InvalidKey("not hex".into()), + KeyProviderError::LoadError(_) => KeyProviderError::LoadError("disk".into()), + ])); if let Err(error) = crate::ClientOpts::new(()).with_max_keys_per_req(0) { errors.push(Box::new(error)); } @@ -334,43 +346,47 @@ mod codes { .build() .expect_err("not a URL"); errors.push(Box::new(crate::ConnectionInitError::from(reqwest_error))); - errors.push(Box::new(StackKmsBuilderError::InvalidEndpoint { - env_var: "CS_ZEROKMS_HOST", - source: InvalidEndpoint::Userinfo, - })); - errors.push(Box::new(StackKmsBuilderError::ClientInit( - Error::Unexpected("x".into()), - ))); - errors.push(Box::new(StackKmsBuilderError::KeyProvider( - KeyProviderError::NotConfigured("unset".into()), - ))); + errors.extend(boxed(variants![ + StackKmsBuilderError::InvalidEndpoint { .. } => { + StackKmsBuilderError::InvalidEndpoint { + env_var: "CS_ZEROKMS_HOST", + source: InvalidEndpoint::Userinfo, + } + }, + StackKmsBuilderError::ClientInit(_) => { + StackKmsBuilderError::ClientInit(Error::Unexpected("x".into())) + }, + StackKmsBuilderError::Auth(_) => StackKmsBuilderError::Auth( + stack_auth::AuthError::TokenExpired(stack_auth::TokenExpired) + ), + StackKmsBuilderError::InvalidConfig(_) => StackKmsBuilderError::InvalidConfig( + crate::ClientOpts::new(()) + .with_max_keys_per_req(0) + .err() + .expect("zero keys per request is refused"), + ), + StackKmsBuilderError::KeyProvider(_) => StackKmsBuilderError::KeyProvider( + KeyProviderError::NotConfigured("unset".into()), + ), + ])); } errors } + /// Every variant has a code in this crate's namespace and `snake_case`, + /// save one that carries a stack-auth error, whose code is that error's. #[test] - fn every_variant_has_a_listed_code() { - let mut seen = BTreeSet::new(); + fn every_variant_has_a_code_of_this_crate() { for error in every_variant() { let code = error .code() .unwrap_or_else(|| panic!("{error:?} has no code")) .to_string(); - if code.starts_with("stack_auth::") { - assert!(stack_auth::ERROR_CODES.contains(&code.as_str()), "{code}"); - continue; - } - assert!(is_code_of("stack_kms", &code), "{code}"); - assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); - seen.insert(code); + assert!( + is_code_of("stack_kms", &code) || is_code_of("stack_auth", &code), + "{code}" + ); } - // `http_client_init` needs the `http` feature to be built. - let listed: BTreeSet = ERROR_CODES - .iter() - .filter(|code| cfg!(feature = "http") || **code != "stack_kms::http_client_init") - .map(|code| code.to_string()) - .collect(); - assert_eq!(seen, listed, "every listed code is produced"); } /// A ZeroKMS failure gives its request kind, never the response it diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 4cf0af70c..ee484f39e 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -121,7 +121,6 @@ pub use maybe_send::MaybeSend; // Errors pub use errors::{ Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, RetrieveKeyError, - ERROR_CODES, }; /// The trait every error from this crate implements to hand over its /// structured fields, with the rule for what an error may contain, and the diff --git a/packages/stack-profile/src/diagnostic.rs b/packages/stack-profile/src/diagnostic.rs index a06e06df9..22a145791 100644 --- a/packages/stack-profile/src/diagnostic.rs +++ b/packages/stack-profile/src/diagnostic.rs @@ -134,8 +134,8 @@ pub fn describe_json_error(error: &serde_json::Error) -> String { /// Checks that a code uses the given crate prefix and a `snake_case` name. /// /// For example, `is_code_of("stack_profile", "stack_profile::not_found")` -/// returns `true`. This checks the format, not whether the crate defines the -/// code; use the crate's `ERROR_CODES` list to check membership. +/// returns `true`. This checks the format, not whether any error carries the +/// code. Each crate's tests run it on a code from every one of its variants. pub fn is_code_of(crate_name: &str, code: &str) -> bool { let Some(name) = code .strip_prefix(crate_name) diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index 40eba24a2..ded8b514c 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -4,7 +4,8 @@ use crate::diagnostic::{describe_json_error, payload, ErrorPayload}; /// Errors that can occur when reading or writing profile files. /// -/// Every variant has a miette code in [`ERROR_CODES`](crate::ERROR_CODES). +/// Every variant has a miette code, `stack_profile::` and a `snake_case` +/// name. /// [`Io`](Self::Io) and [`Json`](Self::Json) wrap another library's error /// and keep its message out of their own (see [`ErrorPayload`] for the /// rule): the wrapped error is their [`source`](std::error::Error::source). @@ -84,61 +85,62 @@ impl ErrorPayload for ProfileError { } } -/// Every miette code [`ProfileError`] can carry. A test builds every variant -/// and checks its code is here, so renaming a code means editing this list -/// on purpose. -pub const ERROR_CODES: &[&str] = &[ - "stack_profile::io", - "stack_profile::json", - "stack_profile::home_dir_not_found", - "stack_profile::not_found", - "stack_profile::invalid_filename", - "stack_profile::no_current_workspace", - "stack_profile::invalid_workspace_id", - "stack_profile::workspace_not_found", -]; - #[cfg(test)] mod tests { - use std::collections::BTreeSet; - use miette::Diagnostic; use super::*; use crate::diagnostic::is_code_of; + /// One row per variant of an enum, written `pattern => value`. The + /// patterns are the arms of a match with no wildcard, so a variant with + /// no row fails to compile, and each value must match its own pattern. + macro_rules! variants { + ($($pattern:pat => $value:expr),+ $(,)?) => {{ + let rows = vec![$({ + let value = $value; + assert!(matches!(value, $pattern), "{value:?} is not {}", stringify!($pattern)); + value + }),+]; + for row in &rows { + match row { + $($pattern => {})+ + } + } + rows + }}; + } + /// One of every variant, so the code test covers them all. fn every_variant() -> Vec { - vec![ - ProfileError::Io(std::io::Error::other("disk")), - serde_json::from_str::("\"secret\"") + variants![ + ProfileError::Io(_) => ProfileError::Io(std::io::Error::other("disk")), + ProfileError::Json(_) => serde_json::from_str::("\"secret\"") .map_err(ProfileError::Json) .unwrap_err(), - ProfileError::HomeDirNotFound, - ProfileError::NotFound { + ProfileError::HomeDirNotFound => ProfileError::HomeDirNotFound, + ProfileError::NotFound { .. } => ProfileError::NotFound { path: "auth.json".into(), }, - ProfileError::InvalidFilename("../x".into()), - ProfileError::NoCurrentWorkspace, - ProfileError::InvalidWorkspaceId("short".into()), - ProfileError::WorkspaceNotFound("AAAAAAAAAAAAAAAA".into()), + ProfileError::InvalidFilename(_) => ProfileError::InvalidFilename("../x".into()), + ProfileError::NoCurrentWorkspace => ProfileError::NoCurrentWorkspace, + ProfileError::InvalidWorkspaceId(_) => ProfileError::InvalidWorkspaceId("short".into()), + ProfileError::WorkspaceNotFound(_) => { + ProfileError::WorkspaceNotFound("AAAAAAAAAAAAAAAA".into()) + } ] } + /// Every variant has a code, in this crate's namespace and `snake_case`. #[test] - fn every_variant_has_a_listed_code() { - let mut seen = BTreeSet::new(); + fn every_variant_has_a_code_of_this_crate() { for error in every_variant() { let code = error .code() .unwrap_or_else(|| panic!("{error:?} has no code")) .to_string(); assert!(is_code_of("stack_profile", &code), "{code}"); - assert!(ERROR_CODES.contains(&code.as_str()), "{code} is unlisted"); - seen.insert(code); } - let listed: BTreeSet = ERROR_CODES.iter().map(|c| c.to_string()).collect(); - assert_eq!(seen, listed, "every listed code is produced by a variant"); } /// serde_json quotes the value it refused; a profile file can hold a diff --git a/packages/stack-profile/src/lib.rs b/packages/stack-profile/src/lib.rs index e6b80ad40..1455102f4 100644 --- a/packages/stack-profile/src/lib.rs +++ b/packages/stack-profile/src/lib.rs @@ -69,7 +69,7 @@ mod profile_store; pub use device_identity::DeviceIdentity; pub use diagnostic::ErrorPayload; -pub use error::{ProfileError, ERROR_CODES}; +pub use error::ProfileError; pub use profile_store::{FileLockGuard, ProfileStore}; /// A type that can be stored in a profile directory. From 7c2ac1da9add20406d80c63d26e67364717719e9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:37:36 +0000 Subject: [PATCH 10/22] fix(stack-encrypt): a stored value that does not parse is reported by kind and position TargetError::Stored's reason goes into the payload as well as the message, and the Go encrypt guest filled it with serde_json's own message. serde_json quotes the value it refused, and the stored EQL value holds ciphertext and index terms, so both could carry them. The guest's convert, and the test resolver in dynamic/record.rs that showed the same pattern, now write describe_json_error's kind, line and column. The reason field's doc states the rule. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- languages/golang/encrypt/guest/src/targets.rs | 23 ++++++++++++++++++- packages/stack-encrypt/src/dynamic/record.rs | 2 +- packages/stack-encrypt/src/dynamic/target.rs | 6 ++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index 4b344c99c..4afc43340 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -134,10 +134,12 @@ fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError expected: Some(expected), found, }, + // The parser's kind and position, never its message: serde_json + // quotes the input it refused, and the input is stored ciphertext. Eql::Stored { target, source } => TargetError::Stored { name: String::new(), target: target.to_owned(), - reason: source.to_string(), + reason: stack_encrypt::diagnostic::describe_json_error(&source), }, other => TargetError::Other(Box::new(other)), } @@ -207,4 +209,23 @@ mod tests { TargetError::Plaintext { target, expected: Some(vitaminc_aead_value::ValueKind::String), .. } if target == "TextEq" )); } + + /// serde_json quotes the value it refused, and a stored EQL value holds + /// ciphertext and index terms: only its kind and position cross. + #[cfg(feature = "eql")] + #[test] + fn a_stored_value_that_does_not_parse_quotes_none_of_it() { + use eql_bindings::encryption::targets::TargetError as Eql; + let source = serde_json::from_str::(r#""marker-ciphertext""#).unwrap_err(); + let error = convert(Eql::Stored { + target: "TextEq", + source, + }); + let TargetError::Stored { reason, .. } = &error else { + panic!("{error:?}"); + }; + assert_eq!(reason, "unexpected data at line 1 column 19"); + let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error)); + assert!(!shown.contains("marker"), "{shown}"); + } } diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 699f9524c..d07c5d8b1 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -5243,7 +5243,7 @@ mod tests { serde_json::from_slice(stored).map_err(|e| TargetError::Stored { name: String::new(), target: name.to_owned(), - reason: e.to_string(), + reason: crate::diagnostic::describe_json_error(&e), })?; let leaf = stored["c"] .as_str() diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index 57de194a7..858f61f0d 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -272,7 +272,11 @@ pub enum TargetError { name: String, /// The EQL type. target: String, - /// What the parser refused. + /// What the parser refused, by kind and position. Never the + /// parser's own message: serde_json's quotes the input it refused, + /// and the input is the stored value, ciphertext and index terms. + /// [`describe_json_error`](crate::diagnostic::describe_json_error) + /// writes one. reason: String, }, /// The resolver's own failure: a value that did not serialize, an From 3f3de6edaf5afb4483c2d2b76057fa68d915e443 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:37:37 +0000 Subject: [PATCH 11/22] fix(stack-auth): a store error keeps its cause, and a missing transport says so StoreError had no #[source], and thiserror does not take a tuple field as one, so AuthError::Store(..).source() was None. The profile error, and the parser or I/O error under it, could not be reached from an AuthError, though ProfileError drops their text from its message on the promise that source() keeps it. A build without `http` whose strategy has no transport returned a RequestError whose help said to check the network path, though nothing was sent; what to do was only in the source, which a binding does not show. RequestError now implements Diagnostic by hand: that case has its own message, code (stack_auth::no_transport) and help, all fixed text, and keeps the legacy code REQUEST_ERROR. The network help no longer points at a source() that TypeScript cannot read. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-auth/src/error.rs | 73 ++++++++++++++++++++++++---- packages/stack-auth/src/transport.rs | 18 +++++-- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index fe1df000e..a109898ad 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -96,14 +96,13 @@ pub(crate) mod codes { /// its query string (see [`ErrorPayload`] for the rule). The transport's /// error is the [`source`](std::error::Error::source), for a caller in the /// same process to log. -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[error("Request to the auth server failed")] -#[diagnostic( - code(stack_auth::request_error), - help( - "The auth server could not be reached, or its response could not be read. Check the network path to it; the transport's error is this error's source." - ) -)] +/// +/// One case is not a failed request: a build without `http` whose strategy +/// was given no transport sends nothing. That error has its own message, +/// code (`stack_auth::no_transport`) and help, all fixed text of this +/// crate's, and keeps the old code `REQUEST_ERROR`. +#[derive(Debug, thiserror::Error)] +#[error("{}", self.message())] pub struct RequestError(#[source] pub Box); impl AuthErrorKind for RequestError { fn error_code(&self) -> &'static str { @@ -111,6 +110,43 @@ impl AuthErrorKind for RequestError { } } +impl RequestError { + /// True when nothing was sent because the build has no transport: a + /// configuration mistake, not a network failure. + fn is_no_transport(&self) -> bool { + #[cfg(not(feature = "http"))] + return self.0.is::(); + #[cfg(feature = "http")] + false + } + + fn message(&self) -> &'static str { + if self.is_no_transport() { + "No HTTP transport: this build of stack-auth has no `http` feature, so the strategy must be given one with `.transport(..)`" + } else { + "Request to the auth server failed" + } + } +} + +impl miette::Diagnostic for RequestError { + fn code<'a>(&'a self) -> Option> { + Some(Box::new(if self.is_no_transport() { + "stack_auth::no_transport" + } else { + "stack_auth::request_error" + })) + } + + fn help<'a>(&'a self) -> Option> { + Some(Box::new(if self.is_no_transport() { + "Give the strategy a transport with `.transport(..)`, or build stack-auth with its `http` feature." + } else { + "The auth server could not be reached, or its response could not be read. Check the network path to it." + })) + } +} + /// The user denied the authorization request. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Authorization was denied")] @@ -478,10 +514,16 @@ impl AuthErrorKind for CustomError { /// binding sees `stack_profile::not_found` whether a profile failure came /// through the auth path or straight from the store. Its old code, /// `STORE_ERROR`, is unchanged. +/// +/// The profile error is also the [`source`](std::error::Error::source), so +/// the library error under it (the parser's or the file system's) stays +/// reachable from an [`AuthError`]. The message repeats the profile error's +/// because the TypeScript binding shows the message alone; a report that +/// prints the whole chain shows it twice. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Token store error: {0}")] #[diagnostic(transparent)] -pub struct StoreError(pub stack_profile::ProfileError); +pub struct StoreError(#[source] pub stack_profile::ProfileError); impl AuthErrorKind for StoreError { fn error_code(&self) -> &'static str { codes::STORE_ERROR @@ -1704,6 +1746,19 @@ mod tests { assert!(json.get("workspace_id").is_none(), "{json}"); } + /// The profile error drops the parser's and the file system's text from + /// its message and keeps their errors as its source: that holds through + /// the auth path too. + #[test] + fn a_store_failure_keeps_the_library_error_in_its_chain() { + let parser = serde_json::from_str::("\"x\"").unwrap_err(); + let error = AuthError::from(stack_profile::ProfileError::Json(parser)); + let profile = std::error::Error::source(&error).expect("the profile error"); + assert!(profile.is::(), "{profile:?}"); + let parser = profile.source().expect("the parser's error"); + assert!(parser.is::(), "{parser:?}"); + } + #[test] fn profile_error_retains_store_type() { let err = AuthError::from(stack_profile::ProfileError::NotFound { diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index a742710d3..05dc02c43 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -979,11 +979,19 @@ mod tests { panic!("built a strategy with nothing to send through"); }; assert!(matches!(err, AuthError::Request(_)), "{err:?}"); - // The message stays fixed; what to do about it is the source's. - let source = std::error::Error::source(&err) - .map(ToString::to_string) - .unwrap_or_default(); - assert!(source.contains("`.transport(..)`"), "{source:?}"); + assert_eq!(err.error_code(), "REQUEST_ERROR"); + // Nothing was sent, so nothing points at the network: the message, + // code and help all say what to do, for a binding that shows only + // those. + use miette::Diagnostic as _; + assert!(err.to_string().contains("`.transport(..)`"), "{err}"); + assert_eq!( + err.code().map(|code| code.to_string()).as_deref(), + Some("stack_auth::no_transport") + ); + let help = err.help().map(|help| help.to_string()).unwrap_or_default(); + assert!(help.contains("`.transport(..)`"), "{help}"); + assert!(!help.contains("network"), "{help}"); } #[cfg(not(feature = "http"))] From 119d95e9ba7104b09888d93e7263c872032966f5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:37:37 +0000 Subject: [PATCH 12/22] fix(stack-encrypt): payload values are written out, never Debug text PlanError::IndexOptions put `declared` and `asked` in the payload as Rust Debug text, which is no format: a Go caller cannot parse it, and it changes whenever MatchOptions gains a field. Its test built the expected value with the same format!, so it could not notice. They are now the index as a plan writes it: the key, or the object of all four match options. stack-kms's request_kind and stack-profile's io_kind were Debug text of another crate's enum. Both are now written out: request_kind by a match with no wildcard, so a new zerokms-protocol kind fails to compile rather than reaching a caller unseen; io_kind by a table of the kinds a profile store meets, `Other` for the rest. Both keep the spellings callers already compare. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-encrypt/src/codes.rs | 4 +- packages/stack-encrypt/src/plan/error.rs | 30 ++++++++++++++- packages/stack-kms/src/errors.rs | 39 ++++++++++++++++++- packages/stack-profile/src/error.rs | 49 +++++++++++++++++++++++- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 5c9a12c55..054adbc75 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -446,8 +446,8 @@ mod tests { json!({ "field": "age", "index": "match", - "declared": format!("{declared:?}"), - "asked": format!("{asked:?}"), + "declared": { "match": { "tokenizer": { "ngram": 3 }, "downcase": true, "k": 3, "m": 256 } }, + "asked": { "match": { "tokenizer": { "ngram": 3 }, "downcase": false, "k": 3, "m": 256 } }, }), ), ( diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index d93b196ed..d9e858d42 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -259,8 +259,8 @@ impl crate::ErrorPayload for PlanError { } => payload([ ("field", field.as_str().into()), ("index", declared.key().into()), - ("declared", format!("{declared:?}").into()), - ("asked", format!("{asked:?}").into()), + ("declared", index_value(declared)), + ("asked", index_value(asked)), ]), Self::TwoContextSources { first, second } => { payload([("first", (*first).into()), ("second", (*second).into())]) @@ -272,3 +272,29 @@ impl crate::ErrorPayload for PlanError { } } } + +/// An index as a plan writes it: its key, or for a match index the object of +/// all four options (`{"match": {"tokenizer": "standard", "downcase": true, +/// "k": 3, "m": 256}}`, an n-gram tokenizer as `{"ngram": 3}`). A caller in +/// another language can read it, and it does not move when a field is added +/// to the Rust type, as `Debug` text would. +fn index_value(index: &IndexSpec) -> serde_json::Value { + use crate::sem::Tokenizer; + match index { + IndexSpec::Match(options) => { + let tokenizer = match options.tokenizer { + Tokenizer::Standard => serde_json::Value::from("standard"), + Tokenizer::Ngram { length } => serde_json::json!({ "ngram": length }), + }; + serde_json::json!({ + "match": { + "tokenizer": tokenizer, + "downcase": options.downcase, + "k": options.k, + "m": options.m, + } + }) + } + IndexSpec::Equality | IndexSpec::Ore | IndexSpec::Ope => index.key().into(), + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 1765a5980..a6359bb77 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -7,7 +7,25 @@ use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; /// The fields a failed ZeroKMS request contributes: the request kind /// (`NotFound`, `SendRequest`, ...), and nothing from the response body. fn request_payload(error: &ViturRequestError) -> serde_json::Map { - payload([("request_kind", format!("{:?}", error.kind).into())]) + payload([("request_kind", request_kind(&error.kind).into())]) +} + +/// A request kind as the payload spells it. Written out rather than taken +/// from `Debug`, which is no format and belongs to `zerokms-protocol`: the +/// match has no wildcard, so a kind added there fails to compile here +/// instead of reaching a caller as a value nobody wrote down. +fn request_kind(kind: &ViturRequestErrorKind) -> &'static str { + match kind { + ViturRequestErrorKind::PrepareRequest => "PrepareRequest", + ViturRequestErrorKind::SendRequest => "SendRequest", + ViturRequestErrorKind::NotFound => "NotFound", + ViturRequestErrorKind::Conflict => "Conflict", + ViturRequestErrorKind::FailureResponse => "FailureResponse", + ViturRequestErrorKind::ParseResponse => "ParseResponse", + ViturRequestErrorKind::Unauthorized => "Unauthorized", + ViturRequestErrorKind::Forbidden => "Forbidden", + ViturRequestErrorKind::Other => "Other", + } } /// The fields of a key-count mismatch. @@ -404,6 +422,25 @@ mod codes { .is_some_and(|help| help.to_string().contains("client is unknown"))); } + /// The written-out kinds are the spellings callers already compare. + #[test] + fn every_request_kind_keeps_its_spelling() { + use ViturRequestErrorKind as K; + for kind in [ + K::PrepareRequest, + K::SendRequest, + K::NotFound, + K::Conflict, + K::FailureResponse, + K::ParseResponse, + K::Unauthorized, + K::Forbidden, + K::Other, + ] { + assert_eq!(request_kind(&kind), format!("{kind:?}")); + } + } + #[test] fn no_endpoint_message_repeats_the_url() { for error in [ diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index ded8b514c..21b627d72 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -70,7 +70,7 @@ pub enum ProfileError { impl ErrorPayload for ProfileError { fn payload(&self) -> serde_json::Map { match self { - Self::Io(error) => payload([("io_kind", format!("{:?}", error.kind()).into())]), + Self::Io(error) => payload([("io_kind", io_kind(error.kind()).into())]), Self::Json(error) => payload([ ("line", error.line().into()), ("column", error.column().into()), @@ -85,6 +85,37 @@ impl ErrorPayload for ProfileError { } } +/// An I/O error kind as the payload spells it: the kinds a profile store +/// can meet, written out rather than taken from `Debug`, which is no format. +/// `ErrorKind` is non-exhaustive, so any other kind is `Other`. +fn io_kind(kind: std::io::ErrorKind) -> &'static str { + use std::io::ErrorKind as K; + match kind { + K::NotFound => "NotFound", + K::PermissionDenied => "PermissionDenied", + K::AlreadyExists => "AlreadyExists", + K::WouldBlock => "WouldBlock", + K::NotADirectory => "NotADirectory", + K::IsADirectory => "IsADirectory", + K::DirectoryNotEmpty => "DirectoryNotEmpty", + K::ReadOnlyFilesystem => "ReadOnlyFilesystem", + K::StorageFull => "StorageFull", + K::QuotaExceeded => "QuotaExceeded", + K::FileTooLarge => "FileTooLarge", + K::ResourceBusy => "ResourceBusy", + K::InvalidInput => "InvalidInput", + K::InvalidData => "InvalidData", + K::InvalidFilename => "InvalidFilename", + K::TimedOut => "TimedOut", + K::WriteZero => "WriteZero", + K::Interrupted => "Interrupted", + K::Unsupported => "Unsupported", + K::UnexpectedEof => "UnexpectedEof", + K::OutOfMemory => "OutOfMemory", + _ => "Other", + } +} + #[cfg(test)] mod tests { use miette::Diagnostic; @@ -160,6 +191,22 @@ mod tests { assert!(shown.contains("line 1"), "{shown}"); } + /// The table spells each kind as std names it, and a kind it does not + /// list is `Other` rather than a spelling nobody wrote down. + #[test] + fn an_io_kind_is_spelled_as_std_names_it() { + use std::io::ErrorKind as K; + for kind in [ + K::NotFound, + K::PermissionDenied, + K::StorageFull, + K::UnexpectedEof, + ] { + assert_eq!(io_kind(kind), format!("{kind:?}")); + } + assert_eq!(io_kind(K::BrokenPipe), "Other"); + } + #[test] fn an_io_error_names_its_kind_not_its_message() { let error = ProfileError::Io(std::io::Error::new( From c37644307647ebbb5798b82b47eb458594227676 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:38:04 +0000 Subject: [PATCH 13/22] fix(stack-encrypt): LabelError::Reserved names the segment, not the character LabelError's doc called a label schema, and so let Reserved quote the character it refused, in its message and as a `character` payload field. But context_field builds a label from a record's own field, and the policy in stack_profile::diagnostic says a context can hold customer data and is reported by length and parts, never content. Reserved now names the segment by position only, like the other variants. The doc says why, and that `found` is still on the variant for a caller in the same process. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-encrypt/CHANGELOG.md | 4 +++- packages/stack-encrypt/src/codes.rs | 2 +- packages/stack-encrypt/src/descriptor.rs | 30 ++++++++++++++++++------ 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 19b10a25d..348542a21 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -65,7 +65,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 of parts instead of its contents. `sem::TermError::Prf` omits the underlying pseudorandom-function implementation's message, and `sem::TermBytesError::MatchPositionOutOfRange` omits the position read - from search-index bytes. These details remain available on the Rust + from search-index bytes, and `LabelError::Reserved` names the segment + but not the reserved character, since a `context_field` label is record + data. These details remain available on the Rust error values for callers in the same process, including the `stored` field on `ContextMismatch`. - **A data plan field with a term output must declare its `"type"`.** A diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 054adbc75..444948c83 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -389,7 +389,7 @@ mod tests { index: 0, found: '(', }), - json!({ "segment": 0, "character": "(" }), + json!({ "segment": 0 }), ), ( Box::new(PlanError::ContextLabel(LabelError::Separator { index: 2 })), diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 7fd27ca58..adb6b47b8 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -621,8 +621,12 @@ impl std::str::FromStr for Label { /// Why a string is not a [`Label`] segment. `index` is the segment's /// position, counting from zero. /// -/// A label is schema — a plan's context and its fields' names — so a message -/// may quote the character it refused. +/// A label is not always schema: a plan's context and its fields' names +/// are, but [`context_field`](crate::plan::FieldsBuilder::context_field) +/// builds the context from a record's own field, which is customer data. +/// So no message or payload quotes a segment or the character it refused; +/// they name the segment by position. A caller in the same process can +/// still read [`Reserved`](Self::Reserved)'s `found`. #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum LabelError { @@ -646,7 +650,7 @@ pub enum LabelError { Separator { index: usize }, /// The segment contains a control character, an invisible format /// character or a parenthesis, which the descriptor reserves. - #[error("label segment {index} contains {found:?}, which the descriptor reserves")] + #[error("label segment {index} contains a character the descriptor reserves")] #[diagnostic(code(stack_encrypt::label_reserved))] Reserved { index: usize, found: char }, /// The segment begins like another descriptor form: `b64:`, a digit or @@ -669,11 +673,8 @@ impl crate::ErrorPayload for LabelError { Self::Empty | Self::NotText => serde_json::Map::new(), Self::EmptySegment { index } | Self::Separator { index } + | Self::Reserved { index, .. } | Self::ReservedPrefix { index } => payload([("segment", (*index).into())]), - Self::Reserved { index, found } => payload([ - ("segment", (*index).into()), - ("character", found.to_string().into()), - ]), } } } @@ -1114,6 +1115,21 @@ mod label_tests { ); } + /// A context field's label is record data, so a refusal names the + /// segment and never the character it refused. + #[test] + fn a_reserved_character_is_not_quoted() { + let error = Label::new(["tenants", "acme(marker"]).unwrap_err(); + let shown = format!( + "{error} {}", + serde_json::Value::Object(crate::ErrorPayload::payload(&error)) + ); + assert_eq!( + shown, + r#"label segment 1 contains a character the descriptor reserves {"segment":1}"# + ); + } + /// The one fixture both suites read; the Go label test reads the same /// file, so the Rust and Go rules cannot drift apart silently. fn segment_fixture() -> (Vec, Vec) { From 2be5203362135fc8018a6d8ce34c3208e59a2349 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:38:04 +0000 Subject: [PATCH 14/22] fix(stack-encrypt): the environment Config error names CS_ZEROKMS_HOST and carries the builder's fields StackCipher::builder().init() reports every setup failure as Error::Config. Its help listed four variables but not CS_ZEROKMS_HOST, so a bad endpoint was sent to check the others, and its payload was empty though the builder error inside it has fields (the variable at fault). The help now names all five and points at the message, and the payload is the builder error's. The code stays stack_encrypt::config: the box keeps the enum's shape the same with and without `http`, and no binding reaches this path (the Go guest builds its cipher with an explicit key source). Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-encrypt/src/cipher.rs | 13 +++++++++++-- packages/stack-encrypt/src/codes.rs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 77c569d4e..70ebf09d7 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -174,10 +174,13 @@ pub enum Error { /// The box holds whatever built it: this crate fills it with /// `StackKmsBuilderError`, whose message obeys the rule on /// [`ErrorPayload`](crate::ErrorPayload), and so may be shown here. + /// + /// Its payload is the builder error's (the variable at fault, say). Its + /// code stays this one; the builder error's own is on the source. #[error("could not build a ZeroKMS client from the environment: {0}")] #[diagnostic( code(stack_encrypt::config), - help("Check `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY`, or log in with `stash auth login`.") + help("The message says which setting failed. Check `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY` and `CS_ZEROKMS_HOST`, or log in with `stash auth login`.") )] Config(#[source] Box), /// An index term failed to derive. @@ -327,8 +330,14 @@ impl crate::ErrorPayload for Error { ("expected", expected.to_string().into()), ("found", found.to_string().into()), ]), + #[cfg(feature = "http")] + Self::Config(error) => error + .downcast_ref::() + .map(crate::ErrorPayload::payload) + .unwrap_or_default(), + #[cfg(not(feature = "http"))] + Self::Config(_) => serde_json::Map::new(), Self::Aead - | Self::Config(_) | Self::Other(_) | Self::UnsupportedShape | Self::ResponseShape diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 444948c83..c1f993d0f 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -459,6 +459,21 @@ mod tests { ), (Box::new(PlanError::NoContext), json!({})), ]; + // The builder error's fields come through the box. + #[cfg(feature = "http")] + let payloads = { + let mut payloads = payloads; + payloads.push(( + Box::new(Error::from( + crate::kms::StackKmsBuilderError::InvalidEndpoint { + env_var: "CS_ZEROKMS_HOST", + source: crate::kms::InvalidEndpoint::Userinfo, + }, + )), + json!({ "env_var": "CS_ZEROKMS_HOST" }), + )); + payloads + }; #[cfg(feature = "dynamic")] let payloads = payloads.into_iter().chain(dynamic_payloads()).collect(); payloads From 70a7e7243a482297063488c07822dfcbf85d38e5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 06:38:05 +0000 Subject: [PATCH 15/22] test: pin the fields, reasons and help that Go and TypeScript callers read The review found error details a caller reads that no test checked, so a swapped reason or a dropped key would pass: - a JWT whose claims do not decode quotes nothing from the token, the behaviour the @cipherstash/auth changeset promises; - the help on REQUEST_ERROR, INVALID_GRANT, INVALID_WORKSPACE_ID, ALREADY_CONSUMED and STORE_ERROR; - the field and reason of a target field's misfit EQL node, of a query on an unknown or non-target field, and of three refused plans (two fields under one identity, an empty context, a repeated match option); - stack-kms's scheme, status, key-count and env_var fields, and the builder help naming its variable. IndexSpec::from_value's doc said a repeated match option is UnknownOutput; the code says RepeatedKey, which is right, and the doc and the test now say so too. is_code_of is a test helper that four crates' tests need public: it is hidden from the docs so it is not part of their API. Refs #1099 Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf --- packages/stack-auth/src/lib.rs | 50 +++++++++++ packages/stack-encrypt/src/dynamic/record.rs | 91 ++++++++++++++------ packages/stack-encrypt/src/dynamic/term.rs | 15 +++- packages/stack-kms/src/errors.rs | 63 ++++++++++++++ packages/stack-profile/src/diagnostic.rs | 4 + 5 files changed, 194 insertions(+), 29 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 0f687463e..6147a8d01 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -438,6 +438,32 @@ where mod tests { use super::*; + /// A claims segment that does not decode is reported by kind and + /// position, never by the decoder's text: base64's names a byte of the + /// token, serde_json's quotes the claim it refused, and a token is a + /// credential. + #[test] + fn a_jwt_whose_claims_do_not_decode_quotes_nothing_from_it() { + use base64::Engine; + let error = decode_jwt_payload::("h.marker*claims.s").unwrap_err(); + assert!(matches!(error, AuthError::InvalidToken(_)), "{error:?}"); + assert_eq!( + error.to_string(), + "Invalid token: the JWT's claims segment is not base64url" + ); + + let claims = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"exp":"marker-claim"}"#); + // serde_json's own text is `invalid type: string "marker-claim", ...`. + let error = + decode_jwt_payload::>(&format!("h.{claims}.s")) + .unwrap_err(); + assert!(matches!(error, AuthError::InvalidToken(_)), "{error:?}"); + let shown = error.to_string(); + assert!(!shown.contains("marker"), "{shown}"); + assert!(shown.contains("line 1"), "{shown}"); + } + /// The `error_code` strings are a stable contract surfaced across FFI /// (JS `Error.code`, Node-API codes), so pin every variant's code. If a /// new variant is added without a code, `error_code`'s exhaustive `kind()` @@ -674,6 +700,30 @@ mod tests { AuthError::from("".parse::().unwrap_err()), "CSAK.", ), + ( + AuthError::Request(crate::error::RequestError(Box::new(std::io::Error::other( + "refused", + )))), + "network path", + ), + ( + AuthError::InvalidGrant(crate::error::InvalidGrant), + "stash auth login", + ), + ( + AuthError::from("short".parse::().unwrap_err()), + "16 base32 characters", + ), + ( + AuthError::AlreadyConsumed(crate::error::AlreadyConsumed), + "new device-code flow", + ), + ( + AuthError::from(stack_profile::ProfileError::NotFound { + path: "auth.json".into(), + }), + "stash auth login", + ), ]; for (err, substring) in with_help { diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index d07c5d8b1..0093b9f53 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -5668,10 +5668,10 @@ mod tests { "email".to_string(), CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), )); - assert!(matches!( - check_record(CipherText::Map(row), &plan, None), - Err(Error::Record { .. }) - )); + let error = check_record(CipherText::Map(row), &plan, None).expect_err("refused"); + assert!(matches!(error, Error::Record { .. }), "{error:?}"); + assert_eq!(error.field(), Some("email")); + assert_eq!(error.reason(), Some(Reason::NotPassthrough)); let mut row = seal_mixed(&keyset, &plan).await; row.retain(|(k, _)| k != "email"); row.push(( @@ -5919,13 +5919,21 @@ mod tests { ))) as BoxedPassthrough), )]) }; + // Each misfit is refused naming the field and the reason a + // binding reports for that shape. + let refusal = |result: Result<(), Error>| { + let error = result.expect_err("the record is refused"); + assert!(matches!(error, Error::Record { .. }), "{error:?}"); + assert_eq!(error.field(), Some("email"), "{error}"); + error.reason() + }; // A ciphertext leaf where the EQL value should be. let misplaced = CipherText::Map(vec![("c".to_string(), forged(s("x")))]); let record = with_email(seal_mixed(&keyset, &plan).await, misplaced); - assert!(matches!( - check_record(record, &plan, None), - Err(Error::Record { .. }) - )); + assert_eq!( + refusal(check_record(record, &plan, None)), + Some(Reason::NoEqlNode) + ); // The node twice. let mut row = seal_mixed(&keyset, &plan).await; let CipherText::Map(mut outputs) = node(&mut row, "email") else { @@ -5937,20 +5945,20 @@ mod tests { }; outputs.extend(again); row.push(("email".to_string(), CipherText::Map(outputs))); - assert!(matches!( - check_record(CipherText::Map(row), &plan, None), - Err(Error::Record { .. }) - )); + assert_eq!( + refusal(check_record(CipherText::Map(row), &plan, None)), + Some(Reason::RepeatedKey) + ); // A payload that is not bytes. let null = CipherText::Map(vec![( EQL_KEY.to_string(), CipherText::Passthrough(Box::new(FfiValue::Null) as BoxedPassthrough), )]); let record = with_email(seal_mixed(&keyset, &plan).await, null); - assert!(matches!( - check_record(record, &plan, None), - Err(Error::Record { .. }) - )); + assert_eq!( + refusal(check_record(record, &plan, None)), + Some(Reason::NotPassthrough) + ); // Bytes that are not the type: the shape fits, and the resolver // refuses them before any key is retrieved. let record = with_email(seal_mixed(&keyset, &plan).await, bytes_node(b"not json")); @@ -6000,15 +6008,14 @@ mod tests { ); assert_eq!(generates(&cipher), 0, "a query mints nothing"); // Not a target field, no such field, the wrong kind, and the - // bare build: each refused before the resolver runs. - assert!(matches!( - refused(query(&keyset, &plan, "age", s("x"), &FakeEql)), - Error::Plan { .. } - )); - assert!(matches!( - refused(query(&keyset, &plan, "nope", s("x"), &FakeEql)), - Error::Plan { .. } - )); + // bare build: each refused before the resolver runs. The first + // two name the field asked for and why. + for (field, reason) in [("age", Reason::NotATarget), ("nope", Reason::NoSuchField)] { + let error = refused(query(&keyset, &plan, field, s("x"), &FakeEql)); + assert!(matches!(error, Error::Plan { .. }), "{error:?}"); + assert_eq!(error.field(), Some(field), "{error}"); + assert_eq!(error.reason(), Some(reason), "{error}"); + } assert!(matches!( refused(query( &keyset, @@ -6461,6 +6468,40 @@ mod tests { MixedContexts, "Plan", ), + ( + "two fields under one identity", + obj(vec![ + ("mail", typed(label("email"), &["c", "eq"], "string")), + ("mail2", typed(label("email"), &["c", "eq"], "string")), + ]), + Some("mail2"), + SharedIdentity, + "Plan", + ), + ( + "a context that renders empty", + age(vec![("context", s("")), ("outputs", strings(&["c"]))]), + Some("age"), + EmptyContext, + "Context", + ), + ( + "a match option given twice", + age(vec![ + ("context", label("age")), + ( + "outputs", + FfiValue::Array(vec![obj(vec![( + "match", + obj(vec![("k", FfiValue::UInt32(6)), ("k", FfiValue::UInt32(6))]), + )])]), + ), + ("type", s("string")), + ]), + Some("age"), + RepeatedKey, + "Plan", + ), ]; for (what, value, field, reason, variant) in cases { let error = refused_plan(value); diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 2c1c7f7ae..edeebe492 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -70,8 +70,8 @@ impl IndexSpec { /// /// [`Error::Plan`] ([`Reason::UnknownOutput`]) for a value that is /// neither an index key nor a match options object, or whose options are - /// unknown, repeated, mistyped or out of bounds. It names no field: the - /// plan parser names it. + /// unknown, mistyped or out of bounds; [`Reason::RepeatedKey`] for an + /// option given twice. It names no field: the plan parser names it. pub fn from_value(value: &FfiValue) -> Result { let unknown = || Error::bad_plan(Reason::UnknownOutput); match value { @@ -1106,10 +1106,17 @@ mod tests { ), ]; for (label, wire) in refused { + let error = IndexSpec::from_value(&wire).expect_err(label); + let reason = if label == "an option twice" { + Reason::RepeatedKey + } else { + Reason::UnknownOutput + }; assert!( - matches!(IndexSpec::from_value(&wire), Err(Error::Plan { .. })), - "{label} is not an index" + matches!(error, Error::Plan { field: None, .. }), + "{label}: {error:?}" ); + assert_eq!(error.reason(), Some(reason), "{label}"); } } diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index a6359bb77..752735c52 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -422,6 +422,69 @@ mod codes { .is_some_and(|help| help.to_string().contains("client is unknown"))); } + /// Every structured field this crate adds. A binding passes these on as + /// they are, so a renamed key breaks the callers that read it. + #[test] + fn every_payload_carries_its_fields() { + use serde_json::json; + #[cfg_attr(not(feature = "http"), allow(unused_mut))] + let mut rows: Vec<(Box, serde_json::Value)> = vec![ + ( + Box::new(InvalidEndpoint::Scheme("ftp".into())), + json!({ "scheme": "ftp" }), + ), + ( + Box::new(InvalidEndpoint::QueryOrFragment("https://x/?q".into())), + json!({}), + ), + ( + Box::new(FailureResponse { + status: 503, + body: Some("marker-body".into()), + headers: Default::default(), + }), + json!({ "status": 503 }), + ), + ( + Box::new(RetrieveKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1, + }), + json!({ "expected": 2, "received": 1 }), + ), + ]; + #[cfg(feature = "http")] + rows.push(( + Box::new(crate::builder::StackKmsBuilderError::InvalidEndpoint { + env_var: "CS_ZEROKMS_HOST", + source: InvalidEndpoint::Userinfo, + }), + json!({ "env_var": "CS_ZEROKMS_HOST" }), + )); + for (error, expected) in rows { + assert_eq!( + serde_json::Value::Object(error.payload()), + expected, + "{error:?}" + ); + } + } + + /// The builder's help names the variable that is wrong. + #[cfg(feature = "http")] + #[test] + fn an_invalid_endpoint_help_names_its_variable() { + let error = crate::builder::StackKmsBuilderError::InvalidEndpoint { + env_var: "CS_ZEROKMS_HOST", + source: InvalidEndpoint::Userinfo, + }; + let help = error + .help() + .map(|help| help.to_string()) + .unwrap_or_default(); + assert!(help.starts_with("Set CS_ZEROKMS_HOST to "), "{help}"); + } + /// The written-out kinds are the spellings callers already compare. #[test] fn every_request_kind_keeps_its_spelling() { diff --git a/packages/stack-profile/src/diagnostic.rs b/packages/stack-profile/src/diagnostic.rs index 22a145791..f8b17929f 100644 --- a/packages/stack-profile/src/diagnostic.rs +++ b/packages/stack-profile/src/diagnostic.rs @@ -136,6 +136,10 @@ pub fn describe_json_error(error: &serde_json::Error) -> String { /// For example, `is_code_of("stack_profile", "stack_profile::not_found")` /// returns `true`. This checks the format, not whether any error carries the /// code. Each crate's tests run it on a code from every one of its variants. +/// +/// Hidden from the documentation: it exists for those tests, which live in +/// four crates and so need it public, and is not part of the API. +#[doc(hidden)] pub fn is_code_of(crate_name: &str, code: &str) -> bool { let Some(name) = code .strip_prefix(crate_name) From b243d02145b58d9cf4c2d899206bfd47eb548b6a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 12:30:57 -0700 Subject: [PATCH 16/22] test(stack-auth): pin RequestError's message for the mutants gate The --in-diff gate left three survivors in error.rs. RequestError::message had no assertion on its text; a test now pins the message and help of a failed request. RequestError::is_no_transport -> false is the body itself under the gate's --all-features build, so it is excluded with the other no-http entries; the no-http test in transport.rs pins that arm. --- .cargo/mutants.toml | 3 +++ packages/stack-auth/src/error.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 7c477a6cd..1fae9ba39 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -59,6 +59,9 @@ exclude_re = [ # Production http_client variants are cfg-disabled here; the test variant # builds an unconfigured Client, equivalent to Client::default(). 'stack-auth/src/transport\.rs:\d+:\d+: replace http_client -> reqwest::Client with Default::default\(\)$', + # Under `http` the body is `false`; the no-`http` arm is pinned by + # `a_builder_without_a_transport_is_refused_when_there_is_no_bundled_one`. + 'stack-auth/src/error\.rs:\d+:\d+: replace RequestError::is_no_transport -> bool with false$', 'stack-auth/src/auto_strategy\.rs:150:9: replace AutoStrategy::detect_inner -> Result with Ok\(Default::default\(\)\)$', 'stack-auth/src/token_store\.rs:(258|266):9: replace >::(load|save)', # stack-auth — equivalent. diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index a109898ad..4497eb0a9 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -1678,6 +1678,22 @@ mod tests { ); } + /// A failed request says so in fixed text of this crate's, and keeps the + /// transport's own message out: a binding shows only the message, code + /// and help. + #[test] + fn a_failed_request_has_a_fixed_message_and_help() { + use miette::Diagnostic; + let error = RequestError(Box::new(std::io::Error::other("refused"))); + assert_eq!(error.to_string(), "Request to the auth server failed"); + assert_eq!( + error.help().map(|help| help.to_string()).as_deref(), + Some( + "The auth server could not be reached, or its response could not be read. Check the network path to it." + ) + ); + } + /// One row per variant of an enum, written `pattern => value`. The /// patterns are the arms of a match with no wildcard, so a variant with /// no row fails to compile, and each value must match its own pattern. From 66af9bf71d65f2d46decafebc516d263ba168bd9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 8 Oct 2026 14:12:40 +1100 Subject: [PATCH 17/22] fix(stack-encrypt): a one-value plan's errors name the value, never its context DuplicateIndex, IndexNotDeclared and IndexOptions on a one-value plan put the plan's or the call's context in their message and in the field payload key. A context can carry customer data, and a Go caller reads field as a field name. They now name `the value`, as a one-value plan with no context already did. --- packages/stack-encrypt/CHANGELOG.md | 5 ++- packages/stack-encrypt/src/plan/error.rs | 9 ++-- packages/stack-encrypt/src/plan/value.rs | 14 ++++--- packages/stack-encrypt/tests/plan.rs | 4 +- packages/stack-encrypt/tests/plan_grammar.rs | 44 +++++++++++++++++--- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 348542a21..e3396812a 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -69,7 +69,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 but not the reserved character, since a `context_field` label is record data. These details remain available on the Rust error values for callers in the same process, including the `stored` - field on `ContextMismatch`. + field on `ContextMismatch`. A one-value plan's + `PlanError::DuplicateIndex`, `IndexNotDeclared` and `IndexOptions` name + `the value` where they used to quote the plan's or the call's context; + the caller already holds that context. - **A data plan field with a term output must declare its `"type"`.** A plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no `"type"` is refused when it is built (`Error::UntypedIndex`, naming the diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index d9e858d42..43a3da260 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -16,7 +16,8 @@ use crate::LabelError; /// Every variant has a `stack_encrypt::` miette code, and names the field /// it is about where there is one, in its message and its /// [`ErrorPayload`](crate::ErrorPayload). Field names describe the -/// schema, not the data, so a message may carry them. +/// schema, not the data, so a message may carry them. A context is data, +/// so a one-value plan's errors name `the value` in its place. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum PlanError { @@ -90,7 +91,7 @@ pub enum PlanError { #[error("{at:?} names the {index} index twice")] #[diagnostic(code(stack_encrypt::plan_duplicate_index))] DuplicateIndex { - /// The field, or the context of a one-value plan. + /// The field, or `the value` for a one-value plan. at: String, /// The index named twice, as its key (`"eq"`, `"match"`, ...). index: &'static str, @@ -158,7 +159,7 @@ pub enum PlanError { help("Query the field through an index it declares, or declare the index on the field.") )] IndexNotDeclared { - /// The field, or the context of a one-value plan. + /// The field, or `the value` for a one-value plan. field: String, /// The index asked for, as its key. index: &'static str, @@ -175,7 +176,7 @@ pub enum PlanError { help("Query with the options the field declares: terms derived under other options never match.") )] IndexOptions { - /// The field, or the context of a one-value plan. + /// The field, or `the value` for a one-value plan. field: String, /// The index as the field declares it. declared: IndexSpec, diff --git a/packages/stack-encrypt/src/plan/value.rs b/packages/stack-encrypt/src/plan/value.rs index 836786a99..c4dfe4dad 100644 --- a/packages/stack-encrypt/src/plan/value.rs +++ b/packages/stack-encrypt/src/plan/value.rs @@ -19,6 +19,11 @@ use crate::{ }; use stack_kms::MaybeSend; +/// What a one-value plan's errors name where a fields plan's name a field. +/// Never the context: a context can carry customer data (a tenant, a user), +/// so an error reports none of it. +const THE_VALUE: &str = "the value"; + impl Plan<(), ()> { /// Start a one-value plan over plaintext `S`, with no context yet: give /// it one with [`context`](ValueStart::context), or leave it for the @@ -231,7 +236,7 @@ impl> ValuePlanBuilder> { } .into()); } - check_indexes("the value", &T::indexes())?; + check_indexes(THE_VALUE, &T::indexes())?; Ok(ValuePlan { context: None, shape: self.shape, @@ -451,10 +456,7 @@ impl> ValuePlanBuilder { .into()) } }; - let at = context - .as_ref() - .map_or_else(|| String::from("the value"), Label::to_string); - check_indexes(&at, &self.shape.specs())?; + check_indexes(THE_VALUE, &self.shape.specs())?; Ok(context) } } @@ -551,7 +553,7 @@ where call: Option