Skip to main content

cheers_core/
error.rs

1//! Workspace-wide typed errors.
2//!
3//! Per-module errors ([`CodecError`], [`StoreError`]) stay local to the modules
4//! that raise them — they describe failures a single subsystem can own. [`Error`]
5//! is the umbrella type for functions that touch more than one subsystem (e.g. a
6//! sign-in handler that loads a user *and* mints a token); it carries the
7//! original typed cause via `#[from]` so callers can downcast when they need to.
8//!
9//! All the error *types* live here in `cheers-core`, even when the machinery that
10//! raises them lives in a higher crate: [`RefreshError`] is produced by
11//! `cheers-server`'s refresh rotator, but the type is keyless, so keeping it in
12//! the shared contract crate lets the [`Error`] umbrella stay whole (both the
13//! `cheers-verify` `EdgeVerifier` and the `cheers-server` `SessionAuthority`
14//! return this one `Error`).
15//!
16//! ```
17//! use cheers_core::{CodecError, Error};
18//!
19//! fn outer() -> Result<(), Error> {
20//!     // CodecError -> Error via the From impl.
21//!     Err(CodecError::Malformed)?
22//! }
23//!
24//! assert!(matches!(outer(), Err(Error::Codec(CodecError::Malformed))));
25//! ```
26
27use crate::codec::CodecError;
28use crate::lease::LeaseError;
29use crate::store::StoreError;
30
31/// Errors raised by refresh-token rotation (`cheers-server`'s `RefreshRotator`).
32///
33/// Keyless, so it lives in the shared contract crate. `#[non_exhaustive]` so
34/// future variants don't break callers.
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum RefreshError {
38    /// The presented token isn't in the store.
39    #[error("unknown refresh token")]
40    Unknown,
41    /// `expires_at <= now` for this token.
42    #[error("refresh token expired")]
43    Expired,
44    /// The presented token has already been rotated. The chain is now revoked as
45    /// a side effect — every record sharing the chain id has `revoked = true`
46    /// after this error returns.
47    #[error("replay detected; chain revoked")]
48    Replay,
49    /// The chain was previously revoked (logout, device revoke, prior replay).
50    #[error("chain revoked")]
51    ChainRevoked,
52    /// The token is live, but the caller's binding resolver knows no
53    /// `DeviceBinding` for its `(user, device)` — so there is no honest
54    /// binding to mint the successor access token with. Raised *before* the
55    /// token is consumed: the chain is left exactly as it was (R730).
56    #[error("no device binding recorded for this session")]
57    Unbound,
58    /// Underlying `RefreshStore` failure.
59    #[error(transparent)]
60    Store(#[from] StoreError),
61}
62
63/// Top-level cheers error. `#[non_exhaustive]` so new variants are non-breaking.
64#[derive(Debug, thiserror::Error)]
65#[non_exhaustive]
66pub enum Error {
67    /// Failure inside the [`Codec`](crate::codec::Codec) layer.
68    #[error(transparent)]
69    Codec(#[from] CodecError),
70
71    /// An artifact was minted with a lease that breaks its invariant.
72    #[error(transparent)]
73    Lease(#[from] LeaseError),
74
75    /// Failure inside a [`UserStore`]/[`CredentialStore`](crate::store::CredentialStore)/`RefreshStore`
76    /// impl.
77    ///
78    /// [`UserStore`]: crate::store
79    #[error(transparent)]
80    Store(#[from] StoreError),
81
82    /// Failure inside refresh-token rotation — surfaced by `cheers-server`'s
83    /// `SessionAuthority::rotate`.
84    #[error(transparent)]
85    Refresh(#[from] RefreshError),
86
87    /// A token verified cryptographically but its `jti` is in the revocation
88    /// set — surfaced by `cheers-verify`'s `EdgeVerifier`.
89    #[error("session revoked")]
90    Revoked,
91
92    /// A token verified cryptographically but is not bound to the peer public
93    /// key the caller presented — either it carries no
94    /// [`peer_key`](crate::Claims::peer_key) at all, or it names a different
95    /// one. Surfaced by `cheers-verify`'s `EdgeVerifier::verify_bound_at`
96    /// (R515): the token holder is not the connecting peer.
97    #[error("token is not bound to the presented peer key")]
98    PeerKeyMismatch,
99
100    /// A peer-key-bound `DeviceBinding::LanPair` session was requested from a
101    /// `SessionAuthority` that holds no standing binder (R732-F5). A LAN node
102    /// with only a 15-minute access token is the failure the standing binding
103    /// exists to remove, so the mint refuses rather than degrade to it.
104    #[error("a LanPair session needs a standing binder (SessionAuthority::with_standing_binder)")]
105    NoStandingBinder,
106
107    /// A membership snapshot mint (R732-F4) saw the ownership version move
108    /// under its closure walk on every attempt: writes to the store are
109    /// arriving faster than one walk. Retry later.
110    #[error("ownership kept changing while minting a snapshot of {kind}/{id} ({attempts} attempts)")]
111    SnapshotContended { kind: String, id: String, attempts: usize },
112
113    /// Caller passed invalid input that no specific subsystem owns
114    /// (e.g. an empty subject string, a timestamp outside i64 range).
115    #[error("invalid input: {0}")]
116    InvalidInput(String),
117}
118
119/// Crate-local `Result` alias. Re-exported at the crate root.
120pub type Result<T> = std::result::Result<T, Error>;
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn codec_error_converts_via_from() {
128        let e: Error = CodecError::Malformed.into();
129        assert!(matches!(e, Error::Codec(CodecError::Malformed)));
130    }
131
132    #[test]
133    fn store_error_converts_via_from() {
134        let e: Error = StoreError::NotFound.into();
135        assert!(matches!(e, Error::Store(StoreError::NotFound)));
136    }
137
138    #[test]
139    fn refresh_error_converts_via_from() {
140        let e: Error = RefreshError::Replay.into();
141        assert!(matches!(e, Error::Refresh(RefreshError::Replay)));
142        // RefreshError itself absorbs a StoreError.
143        let e: Error = RefreshError::from(StoreError::Conflict).into();
144        assert!(matches!(e, Error::Refresh(RefreshError::Store(StoreError::Conflict))));
145    }
146
147    #[test]
148    fn question_mark_propagates() {
149        fn inner() -> Result<()> {
150            Err(StoreError::Conflict)?
151        }
152        assert!(matches!(inner(), Err(Error::Store(StoreError::Conflict))));
153    }
154
155    #[test]
156    fn invalid_input_displays_message() {
157        let e = Error::InvalidInput("subject empty".into());
158        assert_eq!(format!("{e}"), "invalid input: subject empty");
159    }
160
161    #[test]
162    fn error_is_send_sync_static() {
163        fn _check<T: Send + Sync + 'static>() {}
164        _check::<Error>();
165    }
166}