Skip to content

Commit b9b082d

Browse files
committed
docs(stack-profile): explain error diagnostics
Show developers how to inspect codes, help, and payload fields, with an example and guidance for safely reporting underlying errors. Refs #1099
1 parent ac5db8b commit b9b082d

1 file changed

Lines changed: 109 additions & 89 deletions

File tree

‎packages/stack-profile/src/diagnostic.rs‎

Lines changed: 109 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,112 @@
1-
//! The structured fields an error carries, and what an error may contain.
1+
//! Inspect error codes, recovery hints, and structured details.
2+
//!
3+
//! [`ProfileError`](crate::ProfileError) provides a readable message through
4+
//! `Display`, a code and optional help through [`miette::Diagnostic`], and
5+
//! structured fields through [`ErrorPayload::payload`]. Use these fields when
6+
//! you need details such as a missing profile's path or a JSON error's line
7+
//! and column. You do not need to parse the message.
8+
//!
9+
//! # Read an error's details
10+
//!
11+
//! Import both traits to access their methods:
12+
//!
13+
//! ```
14+
//! use miette::Diagnostic;
15+
//! use stack_profile::{ErrorPayload, ProfileError};
16+
//!
17+
//! let error = ProfileError::NotFound {
18+
//! path: "auth.json".into(),
19+
//! };
20+
//!
21+
//! assert_eq!(error.code().unwrap().to_string(), "stack_profile::not_found");
22+
//! assert_eq!(error.payload()["path"], "auth.json");
23+
//! assert!(error.help().is_some());
24+
//! ```
25+
//!
26+
//! In Rust, match error variants to decide how to handle a failure. Codes
27+
//! identify errors when reporting them to another language or service; fields
28+
//! provide the details needed to explain or handle that failure. Payload keys
29+
//! use `snake_case`, and errors without additional details return an empty map.
30+
//!
31+
//! `stack-auth`, `stack-kms`, and `stack-encrypt` use the same trait and
32+
//! re-export it, so you can import it from the crate you already use.
33+
//!
34+
//! # Handle underlying errors with care
35+
//!
36+
//! These crates avoid including sensitive input in diagnostic messages, help,
37+
//! and payloads. Wrapped library errors remain available through
38+
//! [`std::error::Error::source`] for local troubleshooting. Their messages may
39+
//! contain input, credentials, or URLs: inspect them before including them in
40+
//! logs or reports. Do not assume that formatting an entire error chain is
41+
//! safe because its top-level message omits sensitive values.
42+
//!
43+
//! For example, [`describe_json_error`] reports the error kind, line, and
44+
//! column instead of the JSON parser's message, which can quote profile
45+
//! contents containing a token.
46+
//!
47+
//! # Implement an error payload
48+
//!
49+
//! Implement [`ErrorPayload`] for an error that already implements
50+
//! [`miette::Diagnostic`]. Override `payload()` to return its structured
51+
//! details, using the [`payload`] helper to build a map from name/value pairs.
52+
//! Keep the default empty map when there are no useful details.
53+
//!
54+
//! The following policy applies to messages, help, payload fields, and any
55+
//! causes reported through a language binding.
56+
//!
57+
//! ## Allowed details
58+
//!
59+
//! - Keyset IDs and names; counts and lengths; index names and kinds.
60+
//! - Schema field names from a plan or Go struct, rather than field values.
61+
//! - Request kinds and HTTP status codes for ZeroKMS, CipherStash's
62+
//! key-management service.
63+
//! - Workspace IDs, workspace resource names (CRNs), and regions.
64+
//! - Profile file paths, which identify a store rather than its contents.
65+
//! - The CipherStash token service's OAuth `error_description`, intended for
66+
//! people to read.
67+
//! - Messages from errors governed by this policy, or fixed library messages
68+
//! such as those from `url::ParseError`.
69+
//!
70+
//! ## Details to exclude
71+
//!
72+
//! Never include plaintext, key material (data, index, or client keys), access
73+
//! or refresh tokens, ciphertext bytes, search-index bytes, or raw context
74+
//! values. Contexts can contain customer data taken from record fields; report
75+
//! their length and number of parts instead of their contents.
76+
//!
77+
//! Do not forward arbitrary library error messages. Report the operation or
78+
//! error kind instead, and retain the original error through `source()`.
79+
//! Likewise, omit ZeroKMS response bodies because they may echo request data;
80+
//! report the request kind and HTTP status instead.
81+
//!
82+
//! Custom implementations that supply boxed errors, such as
83+
//! `stack_encrypt::Error::Other` or `dynamic::TargetError::Other`, are
84+
//! responsible for their messages: these messages are displayed as supplied.
85+
//! Describe what failed without quoting the value or ciphertext, and do not
86+
//! pass through another library's message.
87+
//!
88+
//! If a new field is not covered by this policy, decide whether it is safe
89+
//! and document that decision here before adding it.
290
3-
/// The structured fields an error carries beside its message, its miette
4-
/// [`code`](miette::Diagnostic::code) and its help.
91+
/// Structured error details that callers can read without parsing a message.
592
///
6-
/// Every error in `stack-profile`, `stack-auth`, `stack-kms` and
7-
/// `stack-encrypt` implements it. Those are the crates whose errors reach a
8-
/// caller through a language binding. The trait is defined here because this
9-
/// crate is the one all four depend on, and each of the others re-exports
10-
/// it, so a caller names it from the crate it uses.
11-
///
12-
/// [`payload`](Self::payload) gives the error's facts as data: which keyset a
13-
/// value was sealed under, which plan field was refused, how long a context
14-
/// was. A binding hands them to its caller beside the code, so the caller can
15-
/// branch on a field instead of parsing a message. Codes are for crossing a
16-
/// boundary: Rust code that needs to branch on an error matches the variant.
17-
///
18-
/// # What an error may contain
19-
///
20-
/// The rule covers an error's message (its `Display`), its help and every
21-
/// field of its payload, and every error under it that a binding reports as
22-
/// a cause. The Go guests enforce it with a leak test: every error path a
23-
/// test can reach is driven with marker values, and the test fails if a
24-
/// marker appears anywhere in what the guest encodes.
25-
///
26-
/// **Allowed:**
27-
///
28-
/// - keyset ids and names
29-
/// - counts and lengths
30-
/// - term kinds and index names
31-
/// - field names from a plan or a Go struct: these describe the schema, not
32-
/// the data
33-
/// - ZeroKMS request kinds and HTTP status numbers
34-
/// - workspace ids, workspace CRNs and region names
35-
/// - the path of a profile file, which names the store and not its contents
36-
/// - a description the CipherStash token service sends for a person to read
37-
/// (an OAuth `error_description`)
38-
/// - the message of an error this rule also governs: one from these four
39-
/// crates, or from a library whose messages are fixed text, such as
40-
/// `url::ParseError`
41-
///
42-
/// **Never allowed:**
43-
///
44-
/// - plaintext, or any part of it
45-
/// - key material: data keys, index keys, client keys
46-
/// - access tokens and refresh tokens
47-
/// - ciphertext bytes and index term bytes
48-
/// - raw context values
49-
///
50-
/// **Decided, with the reason:**
51-
///
52-
/// - **Context descriptors are left out.** A context can be built from a
53-
/// record field (`#[stash(context_field)]`), so its descriptor can hold
54-
/// customer data. An error about a context gives the descriptor's length
55-
/// and its number of parts instead.
56-
/// - **An error from another library gives its type, not its message.** That
57-
/// message is text these crates do not control: an HTTP client's error can
58-
/// carry a URL with its query string, and a JSON parser's can quote the
59-
/// input it refused. Where these crates wrap such an error, their message
60-
/// names what failed, and the wrapped error stays reachable through
61-
/// [`source`](std::error::Error::source) for a caller in the same process,
62-
/// who decides what to log. It never appears in a message or a payload.
63-
/// - **A slot any implementation can fill shows that implementation's
64-
/// message**, and the implementation answers for it under this rule. Such
65-
/// a slot is a `Box<dyn Error>` a trait implementor hands back:
66-
/// `stack_encrypt::Error::Other` from an `EncryptFrom` or `DecryptInto`
67-
/// implementation, `TargetError::Other` from an EQL type resolver. Their
68-
/// own report ("unsupported EQL ciphertext producer or version") is the
69-
/// one a caller needs, so it is shown as given. An implementation that
70-
/// fills one names what it refused, never a byte of the value or the
71-
/// ciphertext, and does not pass another library's message through.
72-
/// - **ZeroKMS response bodies are left out**, until someone confirms that a
73-
/// ZeroKMS error body never echoes what the request carried. An error from
74-
/// a ZeroKMS request gives the request kind and the HTTP status instead.
75-
///
76-
/// A field that falls under none of these needs a decision before it is
77-
/// added, recorded here.
93+
/// Use [`miette::Diagnostic`] for the code and help, and [`payload`](Self::payload)
94+
/// for fields such as a profile path or a JSON error's position. See the
95+
/// [module documentation](crate::diagnostic) for examples and the policy on error contents.
7896
pub trait ErrorPayload: miette::Diagnostic {
79-
/// The error's structured fields, keyed by `snake_case` name. Every value
80-
/// obeys the rule above. Empty unless the error has facts worth
81-
/// branching on.
97+
/// Returns structured details with `snake_case` keys.
98+
///
99+
/// Values must follow this module's policy on error contents. The default
100+
/// implementation returns an empty map.
82101
fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
83102
serde_json::Map::new()
84103
}
85104
}
86105

87-
/// A payload built from `(name, value)` pairs.
106+
/// Builds a payload map from `(name, value)` pairs.
88107
///
89-
/// Shared by the four crates' [`ErrorPayload`] impls so each reads as a list
90-
/// of fields rather than a map built by hand.
108+
/// Use this when implementing [`ErrorPayload::payload`]. Names should use
109+
/// `snake_case`, and values must follow this module's policy on error contents.
91110
pub fn payload<const N: usize>(
92111
fields: [(&str, serde_json::Value); N],
93112
) -> serde_json::Map<String, serde_json::Value> {
@@ -97,9 +116,11 @@ pub fn payload<const N: usize>(
97116
.collect()
98117
}
99118

100-
/// A JSON error as the rule allows it: what kind of error, and where. Never
101-
/// serde_json's own message, which can quote the input it refused:
102-
/// `syntax error at line 1 column 5`.
119+
/// Describes a JSON error by kind, line, and column without quoting input.
120+
///
121+
/// For example: `syntax error at line 1 column 5`. Use this instead of the
122+
/// parser's own message when the input may contain credentials or other
123+
/// sensitive data.
103124
pub fn describe_json_error(error: &serde_json::Error) -> String {
104125
let kind = match error.classify() {
105126
serde_json::error::Category::Io => "read error",
@@ -110,12 +131,11 @@ pub fn describe_json_error(error: &serde_json::Error) -> String {
110131
format!("{kind} at line {} column {}", error.line(), error.column())
111132
}
112133

113-
/// Whether `code` has the shape every code from these crates has: the crate's
114-
/// name, `::`, then a `snake_case` name — `stack_encrypt::foreign_keyset`.
134+
/// Checks that a code uses the given crate prefix and a `snake_case` name.
115135
///
116-
/// Each crate's code test runs every code it can produce through this, so a
117-
/// code in the wrong crate's namespace, or spelled in another case, fails
118-
/// there rather than reaching a binding.
136+
/// For example, `is_code_of("stack_profile", "stack_profile::not_found")`
137+
/// returns `true`. This checks the format, not whether the crate defines the
138+
/// code; use the crate's `ERROR_CODES` list to check membership.
119139
pub fn is_code_of(crate_name: &str, code: &str) -> bool {
120140
let Some(name) = code
121141
.strip_prefix(crate_name)

0 commit comments

Comments
 (0)