Skip to main content

bitwarden_access_connector/
error.rs

1//! Retry decisions, safe report details and startup errors.
2
3use thiserror::Error;
4
5/// Whether a failed operation is worth retrying.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub(crate) enum Retry {
8    /// Likely temporary; retry after a backoff.
9    Transient,
10    /// Retrying would not help.
11    Permanent,
12}
13
14/// A bounded, zero-knowledge detail for a failure report. It is built only from vetted scalars
15/// (status codes, exit codes, env var and error kind names), never an arbitrary `String`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub(crate) struct SafeDetail(String);
18
19impl SafeDetail {
20    /// Maximum byte length of a detail string (server contract).
21    pub(crate) const MAX_LEN: usize = 500;
22
23    /// Truncates `s` to at most [`Self::MAX_LEN`] bytes, on a char boundary.
24    fn truncate(s: String) -> String {
25        if s.len() <= Self::MAX_LEN {
26            s
27        } else {
28            let mut end = Self::MAX_LEN;
29            while !s.is_char_boundary(end) {
30                end -= 1;
31            }
32            // `end` is a char boundary, so `get` returns Some.
33            s.get(..end).unwrap_or_default().to_owned()
34        }
35    }
36
37    #[cfg(test)]
38    pub(crate) fn from_status(status: u16) -> Self {
39        Self(Self::truncate(format!("HTTP {status}")))
40    }
41
42    pub(crate) fn from_exit_code(code: i32) -> Self {
43        Self(Self::truncate(format!("exit code {code}")))
44    }
45
46    /// The signal number of a script ended by a signal.
47    pub(crate) fn from_signal(signal: i32) -> Self {
48        Self(Self::truncate(format!("signal {signal}")))
49    }
50
51    /// Variable names are safe to report; values never are.
52    pub(crate) fn from_missing_vars(names: &[String]) -> Self {
53        let joined = names.join(", ");
54        Self(Self::truncate(format!("missing vars: {joined}")))
55    }
56
57    /// `kind` is a `'static` constant such as `"GraphRequest"`, never user-supplied input.
58    pub(crate) fn from_kind(kind: &'static str) -> Self {
59        Self(Self::truncate(format!("error kind: {kind}")))
60    }
61
62    /// Only the code: `diagnosticMessage` and `matchedDN` can echo account names and DNs.
63    pub(crate) fn from_ldap_result_code(rc: u32) -> Self {
64        Self(Self::truncate(format!("LDAP result code {rc}")))
65    }
66
67    pub(crate) fn timed_out(secs: u64) -> Self {
68        Self(Self::truncate(format!("timed out after {secs}s")))
69    }
70
71    /// `reason` is a `'static` constant naming the failed check, never a value from the claim or
72    /// blobs.
73    pub(crate) fn from_seal_rejection(reason: &'static str) -> Self {
74        Self(Self::truncate(format!("sealed data rejected: {reason}")))
75    }
76
77    /// Never includes Graph's `error.message`, which can echo user-supplied content.
78    pub(crate) fn from_http_status_and_graph_code(status: u16, graph_code: Option<&str>) -> Self {
79        let s = match graph_code {
80            Some(code) => format!("HTTP {status} ({code})"),
81            None => format!("HTTP {status}"),
82        };
83        Self(Self::truncate(s))
84    }
85
86    pub(crate) fn as_str(&self) -> &str {
87        &self.0
88    }
89}
90
91// Safe to log, since the content is vetted at construction.
92impl std::fmt::Display for SafeDetail {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(&self.0)
95    }
96}
97
98/// Startup errors, printed to stderr with a non-zero exit code. No `#[bitwarden_error]`, since the
99/// connector has no language bindings.
100#[derive(Debug, Error)]
101pub enum AccessConnectorError {
102    /// The configuration supplied is invalid, such as a missing URL or an out-of-range interval.
103    #[error("invalid configuration: {0}")]
104    InvalidConfig(String),
105
106    /// The access connector token string could not be parsed.
107    #[error("invalid access connector token: {0}")]
108    InvalidToken(String),
109
110    /// The identity server could not be reached during startup authentication.
111    #[error("identity server unreachable: {0}")]
112    IdentityUnreachable(String),
113
114    /// The access connector credential was rejected by the identity server.
115    #[error("credential refused by identity server: {0}")]
116    CredentialRefused(String),
117
118    /// An I/O error occurred.
119    #[error("I/O error: {0}")]
120    Io(#[from] std::io::Error),
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn safe_detail_from_status() {
129        let d = SafeDetail::from_status(429);
130        assert_eq!(d.as_str(), "HTTP 429");
131    }
132
133    #[test]
134    fn safe_detail_from_exit_code() {
135        let d = SafeDetail::from_exit_code(2);
136        assert_eq!(d.as_str(), "exit code 2");
137    }
138
139    #[test]
140    fn safe_detail_from_signal() {
141        let d = SafeDetail::from_signal(9);
142        assert_eq!(d.as_str(), "signal 9");
143    }
144
145    #[test]
146    fn safe_detail_from_missing_vars() {
147        let vars = vec!["FOO_CLIENT_ID".to_owned(), "FOO_CLIENT_SECRET".to_owned()];
148        let d = SafeDetail::from_missing_vars(&vars);
149        assert_eq!(d.as_str(), "missing vars: FOO_CLIENT_ID, FOO_CLIENT_SECRET");
150    }
151
152    #[test]
153    fn safe_detail_from_kind() {
154        let d = SafeDetail::from_kind("GraphRequest");
155        assert_eq!(d.as_str(), "error kind: GraphRequest");
156    }
157
158    #[test]
159    fn safe_detail_from_seal_rejection() {
160        let d = SafeDetail::from_seal_rejection("target_method_not_automatic");
161        assert_eq!(
162            d.as_str(),
163            "sealed data rejected: target_method_not_automatic"
164        );
165    }
166
167    #[test]
168    fn safe_detail_from_ldap_result_code() {
169        let d = SafeDetail::from_ldap_result_code(49);
170        assert_eq!(d.as_str(), "LDAP result code 49");
171    }
172
173    #[test]
174    fn safe_detail_timed_out() {
175        let d = SafeDetail::timed_out(60);
176        assert_eq!(d.as_str(), "timed out after 60s");
177    }
178
179    #[test]
180    fn safe_detail_truncated_at_500_chars() {
181        // ASCII-only, so char length equals byte length.
182        let long = "x".repeat(600);
183        let d = SafeDetail::from_kind(Box::leak(long.into_boxed_str()));
184        assert_eq!(
185            d.as_str().len(),
186            SafeDetail::MAX_LEN,
187            "detail must be capped at MAX_LEN"
188        );
189    }
190
191    #[test]
192    fn safe_detail_exactly_500_chars_not_truncated() {
193        let exactly = "a".repeat(500);
194        // Every constructor adds a prefix, so call truncate directly.
195        let d = SafeDetail(SafeDetail::truncate(exactly.clone()));
196        assert_eq!(d.as_str().len(), 500);
197    }
198
199    #[test]
200    fn safe_detail_truncation_respects_char_boundary() {
201        // The 500-byte mark falls inside a 2-byte é.
202        let base = "a".repeat(499);
203        let long = base + &"é".repeat(10);
204        let d = SafeDetail(SafeDetail::truncate(long));
205        assert!(
206            d.as_str().len() <= SafeDetail::MAX_LEN,
207            "truncated string must not exceed MAX_LEN bytes"
208        );
209        assert!(std::str::from_utf8(d.as_str().as_bytes()).is_ok());
210    }
211}