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/.changeset/auth-error-codes-and-help.md b/.changeset/auth-error-codes-and-help.md new file mode 100644 index 000000000..3fdac5dda --- /dev/null +++ b/.changeset/auth-error-codes-and-help.md @@ -0,0 +1,13 @@ +--- +"@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. +- `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/.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/Cargo.lock b/Cargo.lock index 60687ec98..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", @@ -3360,6 +3361,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..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", @@ -2156,6 +2158,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "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/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/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/eql/Cargo.lock b/packages/eql/Cargo.lock index 18f8ac534..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", @@ -4301,6 +4303,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-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/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_client.rs b/packages/stack-auth/src/device_client.rs index 699fd72a9..7490c1cf6 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 // --------------------------------------------------------------------------- @@ -150,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/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 d6acaf824..facf3b938 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. 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,18 +90,67 @@ 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. -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[error("Request to the auth server failed: {0}")] -pub struct RequestError(pub Box); +/// +/// 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. +/// +/// 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 { codes::REQUEST_ERROR } } +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")] +#[diagnostic(code(stack_auth::access_denied))] pub struct AccessDenied; impl AuthErrorKind for AccessDenied { fn error_code(&self) -> &'static str { @@ -105,6 +161,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 +175,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 +186,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 +197,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 +211,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 +229,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 +264,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 +280,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 +296,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 +312,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 +323,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 +337,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 +359,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 +381,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." ), @@ -320,8 +408,15 @@ 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))] pub struct ServerError(pub String); impl AuthErrorKind for ServerError { fn error_code(&self) -> &'static str { @@ -329,12 +424,48 @@ 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 /// 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 +478,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 +499,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,9 +508,22 @@ 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. +/// +/// 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}")] -pub struct StoreError(pub stack_profile::ProfileError); +#[diagnostic(transparent)] +pub struct StoreError(#[source] pub stack_profile::ProfileError); impl AuthErrorKind for StoreError { fn error_code(&self) -> &'static str { codes::STORE_ERROR @@ -869,6 +1015,18 @@ 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(), + } + } +} + // --------------------------------------------------------------------------- // Ergonomic `From` impls — keep `?` working where call sites lift a // foreign error straight into `AuthError` (the per-struct wrapping is internal). @@ -929,8 +1087,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 +1529,313 @@ 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" + ); + } + + /// 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. + 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`]'s errors, and one of every + /// [`InvalidAccessKey`](crate::InvalidAccessKey) variant. + fn every_diagnostic() -> Vec<(&'static str, Box)> { + use crate::InvalidAccessKey; + use stack_profile::diagnostic::named; + let mut errors: Vec<_> = every_variant() + .into_iter() + .map(|(error, _, _)| named(error)) + .collect(); + errors.extend( + variants![ + InvalidAccessKey::MissingPrefix => InvalidAccessKey::MissingPrefix, + InvalidAccessKey::MissingDot => InvalidAccessKey::MissingDot, + InvalidAccessKey::EmptyKeyId => InvalidAccessKey::EmptyKeyId, + InvalidAccessKey::EmptySecret => InvalidAccessKey::EmptySecret, + ] + .into_iter() + .map(named), + ); + errors + } + + /// 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_code_of_this_crate() { + use stack_profile::diagnostic::is_code_of; + for (_, error) in &every_diagnostic() { + let code = error + .code() + .unwrap_or_else(|| panic!("{error:?} has no code")) + .to_string(); + assert!( + is_code_of("stack_auth", &code) || is_code_of("stack_profile", &code), + "{code}" + ); + } + } + + /// No two variants share a code by mistake: callers branch on it. + /// [`DeviceClientError`](crate::DeviceClientError)'s own variants share + /// on purpose: each carries the code of the [`AuthError`] it converts + /// into. + #[test] + fn no_two_variants_share_a_code_by_mistake() { + #[allow(unused_mut)] + let mut errors = every_diagnostic(); + #[allow(unused_mut)] + let mut intended: Vec<&str> = Vec::new(); + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] + { + use crate::DeviceClientError; + errors.extend( + variants![ + DeviceClientError::Profile(_) => DeviceClientError::Profile( + stack_profile::ProfileError::NotFound { path: "auth.json".into() } + ), + DeviceClientError::Auth(_) => DeviceClientError::Auth(AccessDenied.into()), + DeviceClientError::Request(_) => DeviceClientError::Request(RequestError( + Box::new(std::io::Error::other("refused")) + )), + DeviceClientError::Server { .. } => DeviceClientError::Server { + status: 503, + body: String::new(), + }, + // Not the AuthError row's URL error: rows of unrelated + // variants hold different values, so neither reads as + // a wrapper of the other. + DeviceClientError::InvalidUrl(_) => DeviceClientError::InvalidUrl( + "http://[::1".parse::().unwrap_err() + ), + ] + .into_iter() + .map(stack_profile::diagnostic::named), + ); + // The errors two wrappers each carry, as rows of their own: a + // wrapper counts as the error it forwards. + errors.push(stack_profile::diagnostic::named( + stack_profile::ProfileError::NotFound { + path: "auth.json".into(), + }, + )); + errors.push(stack_profile::diagnostic::named(RequestError(Box::new( + std::io::Error::other("refused"), + )))); + intended.extend(["stack_auth::invalid_url", "stack_auth::server_error"]); + } + let shared = stack_profile::diagnostic::shared_codes( + errors.iter().map(|(name, error)| (*name, error.as_ref())), + ); + let codes: Vec<&str> = shared.keys().map(String::as_str).collect(); + assert_eq!(codes, intended, "{shared:#?}"); + } + + /// 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}"); + } + + /// 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 { @@ -1532,8 +2000,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 +2020,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..6147a8d01 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -83,6 +83,14 @@ pub use error::{ 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 +416,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) ))) }) } @@ -424,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()` @@ -654,12 +694,36 @@ mod tests { ), ( AuthError::NotAuthenticated(crate::error::NotAuthenticated), - "stash login", + "stash auth login", ), ( 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-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 8a9436d1d..05dc02c43 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] @@ -910,8 +946,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:?}"), } } @@ -938,7 +979,19 @@ mod tests { panic!("built a strategy with nothing to send through"); }; assert!(matches!(err, AuthError::Request(_)), "{err:?}"); + 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"))] diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a1f86c002..e3396812a 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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`. 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 @@ -32,6 +47,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **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, 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 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/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 880a27eff..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", @@ -2093,6 +2095,7 @@ version = "0.43.0" dependencies = [ "dirs", "gethostname", + "miette", "serde", "serde_json", "thiserror 1.0.69", diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 6a7024f6e..46103500c 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 `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)). +#[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 is damaged or was 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,33 @@ 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. + /// + /// 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("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. #[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 +205,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 +231,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 +249,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 +260,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 +275,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 +293,60 @@ 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()), + ]), + #[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::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 +985,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 +993,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 +1010,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..d2f752f25 --- /dev/null +++ b/packages/stack-encrypt/src/codes.rs @@ -0,0 +1,624 @@ +//! 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 miette::Diagnostic; + use uuid::Uuid; + + use crate::diagnostic::is_code_of; + use crate::sem::{MatchOptions, TermBytesError, TermError}; + use crate::target::IndexSpec; + use crate::{Descriptor, Error, LabelError, LeafBytesError, PlanError}; + + /// 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 + }}; + } + + /// A test's row: an error, boxed with its type's name. + type Row = (&'static str, Box); + + fn boxed(rows: Vec) -> impl Iterator { + rows.into_iter().map(crate::diagnostic::named) + } + + /// One of every variant of every error type here. + fn every_variant() -> Vec { + 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 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, + }, + 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"), + }, + Error::ResponseShape => Error::ResponseShape, + Error::KeysetMismatch { .. } => Error::KeysetMismatch { left: a, right: b }, + Error::ForeignKeyset { .. } => Error::ForeignKeyset { + expected: a, + found: b, + }, + 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: '(', + }, + 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, + }, + PlanError::IdentityWithoutField => PlanError::IdentityWithoutField, + PlanError::DuplicateField { .. } => PlanError::DuplicateField { field: field() }, + PlanError::SharedIdentity { .. } => PlanError::SharedIdentity { + identity: "age".into(), + first: "age".into(), + second: "years".into(), + }, + PlanError::PassthroughIndexed { .. } => { + PlanError::PassthroughIndexed { field: field() } + }, + PlanError::DuplicateIndex { .. } => PlanError::DuplicateIndex { + at: field(), + index: "eq", + }, + PlanError::EmptyIndexes => PlanError::EmptyIndexes, + PlanError::NotInPlan { .. } => PlanError::NotInPlan { field: field() }, + PlanError::NotInValue { .. } => PlanError::NotInValue { field: field() }, + PlanError::FieldType { .. } => PlanError::FieldType { + field: field(), + expected: "int64", + }, + PlanError::NoSuchField { .. } => PlanError::NoSuchField { field: field() }, + PlanError::MixedCiphers => PlanError::MixedCiphers, + PlanError::IndexNotDeclared { .. } => PlanError::IndexNotDeclared { + field: field(), + index: "ore", + }, + PlanError::IndexOptions { .. } => PlanError::IndexOptions { + field: field(), + declared: IndexSpec::Match(MatchOptions::default()), + asked: IndexSpec::Match(MatchOptions { + downcase: false, + ..MatchOptions::default() + }), + }, + PlanError::TwoContextSources { .. } => PlanError::TwoContextSources { + first: "the plan", + second: "the call", + }, + PlanError::NoContext => PlanError::NoContext, + PlanError::TargetWithVerbs { .. } => PlanError::TargetWithVerbs { field: field() }, + ])); + #[cfg(feature = "dynamic")] + errors.extend(dynamic_variants()); + 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(); + 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, + }, + 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(), + }, + TargetError::NoQuery { .. } => TargetError::NoQuery { name: target() }, + TargetError::Extended { .. } => TargetError::Extended { + name: name(), + label: "users/email".into(), + }, + 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, + }, + TargetError::Column { .. } => TargetError::Column { + name: name(), + label: "app/users/email".into(), + reason: "two segments".into(), + }, + TargetError::Plaintext { .. } => TargetError::Plaintext { + name: name(), + target: target(), + expected: Some(ValueKind::String), + found: None, + }, + TargetError::Stored { .. } => TargetError::Stored { + name: name(), + target: target(), + reason: "not JSON".into(), + }, + 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_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_encrypt", &code) || is_code_of("stack_kms", &code), + "{code}" + ); + } + } + + /// No two variants share a code: callers branch on it. + #[test] + fn no_two_variants_share_a_code_by_mistake() { + let errors = every_variant(); + let shared = crate::diagnostic::shared_codes( + errors.iter().map(|(name, error)| (*name, error.as_ref())), + ); + let codes: Vec<&str> = shared.keys().map(String::as_str).collect(); + assert_eq!(codes, [] as [&str; 0], "{shared:#?}"); + } + + /// 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"); + } + + /// 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 }), + ), + ( + 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": { "match": { "tokenizer": { "ngram": 3 }, "downcase": true, "k": 3, "m": 256 } }, + "asked": { "match": { "tokenizer": { "ngram": 3 }, "downcase": false, "k": 3, "m": 256 } }, + }), + ), + ( + Box::new(PlanError::TwoContextSources { + first: "the plan", + second: "the call", + }), + json!({ "first": "the plan", "second": "the call" }), + ), + (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 + } + + #[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/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 9a98766dc..adb6b47b8 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -620,36 +620,65 @@ 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 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 { /// 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")] + #[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 /// `-`. #[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::Reserved { index, .. } + | Self::ReservedPrefix { index } => payload([("segment", (*index).into())]), + } + } +} + impl std::fmt::Display for Descriptor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) @@ -1086,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) { 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..8466f6bc9 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,269 @@ 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 + } +} + +/// 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 { + Self::WORDS[self as usize].0 + } +} + +impl fmt::Display for Reason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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 d8bb78eb7..0093b9f53 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))?); } - // An unknown key, or one of the four given twice. - _ => return Err(Error::Plan), + // One of the four given twice. + "context" | "target" | "outputs" | "type" => { + return Err(refuse(Reason::RepeatedKey)) + } + _ => 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:?}"); } } @@ -5063,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() @@ -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) @@ -5488,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(( @@ -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] @@ -5727,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 { @@ -5745,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")); @@ -5808,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, @@ -5825,7 +6024,7 @@ mod tests { FfiValue::UInt32(1), &FakeEql )), - Error::Source + Error::Source { .. } )); assert!(matches!( refused(query(&keyset, &plan, "email", s("x"), &NoTargets)), @@ -6056,4 +6255,560 @@ 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", + ), + ( + "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); + 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}"); + } + } + + /// 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() { + 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..858f61f0d 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,20 +266,98 @@ 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, /// 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 - /// 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 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 } + | 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..edeebe492 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, 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 { - 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:?}" ); } @@ -1092,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-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index df300fe4b..37b36ba4f 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -271,6 +271,8 @@ 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")] pub mod dynamic; @@ -292,6 +294,11 @@ pub use plan::{all, Plan, PlanError}; // 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 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..43a3da260 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -12,30 +12,48 @@ 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 `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. 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 { /// 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 +63,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,14 +79,19 @@ 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. + /// The field, or `the value` for a one-value plan. at: String, /// The index named twice, as its key (`"eq"`, `"match"`, ...). index: &'static str, @@ -74,9 +101,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 +116,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 +124,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 +136,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,12 +146,20 @@ 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. + /// The field, or `the value` for a one-value plan. field: String, /// The index asked for, as its key. index: &'static str, @@ -125,8 +171,12 @@ 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. + /// The field, or `the value` for a one-value plan. field: String, /// The index as the field declares it. declared: IndexSpec, @@ -137,6 +187,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 +205,97 @@ 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", index_value(declared)), + ("asked", index_value(asked)), + ]), + Self::TwoContextSources { first, second } => { + payload([("first", (*first).into()), ("second", (*second).into())]) + } + Self::IdentityWithoutField + | Self::EmptyIndexes + | Self::MixedCiphers + | Self::NoContext => serde_json::Map::new(), + } + } +} + +/// 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-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