Skip to main content

wiremux_auth/
error.rs

1//! TokenProvider errors. Independent of any host `LlmError`.
2
3use std::io;
4#[cfg(any(feature = "net", test))]
5use std::path::Path;
6use std::path::PathBuf;
7
8use crate::profile::ProfileError;
9
10/// Failure from credential load, refresh, or fail-closed write-back.
11#[derive(Debug, thiserror::Error)]
12pub enum AuthError {
13    /// A required field or credential source is missing.
14    #[error("missing {0}")]
15    MissingField(String),
16    /// Filesystem read or write failed.
17    #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
18    Io {
19        /// Path that failed, when known.
20        path: Option<PathBuf>,
21        /// Underlying I/O error.
22        #[source]
23        source: io::Error,
24    },
25    /// JSON parse or serialize failed.
26    #[error("JSON error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
27    Json {
28        /// Path that failed, when known.
29        path: Option<PathBuf>,
30        /// Underlying JSON error.
31        #[source]
32        source: serde_json::Error,
33    },
34    /// Provider construction or refresh failed.
35    #[error("{0}")]
36    TokenProvider(String),
37    /// Advisory file lock was not acquired in time.
38    #[error("file lock timeout")]
39    LockTimeout,
40    /// Write-back refused an empty access token (or a non-object store).
41    #[error("refusing to write empty or invalid credentials")]
42    EmptyWriteRefused,
43    /// Token endpoint rejected the refresh.
44    #[error("vendor rejected token refresh (HTTP {status}): {summary}")]
45    VendorRejected {
46        /// HTTP status from the token endpoint.
47        status: u16,
48        /// Sanitized `error` / `error_description` only.
49        summary: String,
50    },
51    /// Refresh is cached in memory, but the credential file was not updated.
52    #[error("credential store is stale ({}): {detail}", path.display())]
53    CredentialStoreStale {
54        /// Store path that could not be saved.
55        path: PathBuf,
56        /// Save failure with token material removed.
57        detail: String,
58    },
59}
60
61impl AuthError {
62    pub(crate) fn io(path: Option<PathBuf>, source: io::Error) -> Self {
63        Self::Io {
64            path: path.map(redact_pathbuf),
65            source,
66        }
67    }
68
69    /// Label a JSON error with the store path. Path-less `?` still uses [`Self::Json`].
70    #[cfg(any(feature = "net", test))]
71    pub(crate) fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {
72        Self::Json {
73            path: Some(redact_pathbuf(path.as_ref().to_path_buf())),
74            source,
75        }
76    }
77
78    /// In-memory refresh is newer than the store. `detail` must not contain a token.
79    #[cfg(feature = "net")]
80    pub(crate) fn credential_store_stale(path: PathBuf, detail: impl Into<String>) -> Self {
81        let detail = crate::helpers::redact_secret_looking(&detail.into());
82        Self::CredentialStoreStale {
83            path: redact_pathbuf(path),
84            detail,
85        }
86    }
87}
88
89fn redact_pathbuf(path: PathBuf) -> PathBuf {
90    PathBuf::from(crate::helpers::redact_secret_looking(
91        &path.display().to_string(),
92    ))
93}
94
95impl From<serde_json::Error> for AuthError {
96    fn from(source: serde_json::Error) -> Self {
97        Self::Json { path: None, source }
98    }
99}
100
101impl From<ProfileError> for AuthError {
102    fn from(err: ProfileError) -> Self {
103        match err {
104            ProfileError::MissingField(s) => Self::MissingField(s.to_string()),
105            ProfileError::Io { path, source } => Self::io(Some(path), source),
106            other => Self::TokenProvider(crate::helpers::sanitize_oauth_error_text(
107                &other.to_string(),
108            )),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    const LEAK: &str = "ghp_ENVSUBST_LEAK_TOKEN_51";
118
119    fn leak_path() -> PathBuf {
120        PathBuf::from(format!("/tmp/creds-{LEAK}.json"))
121    }
122
123    #[test]
124    fn profile_parse_error_drops_url_userinfo() {
125        let err = AuthError::from(crate::profile::ProfileError::Parse(
126            "callback https://user:s3cret@evil.test/cb".into(),
127        ));
128        let text = err.to_string();
129        assert!(!text.contains("s3cret"), "{text}");
130        assert!(!text.contains("user:"), "{text}");
131        assert!(text.contains("evil.test"), "{text}");
132    }
133
134    #[test]
135    fn profile_parse_error_is_capped_on_auth_error() {
136        let secret = format!("api_key = \"sk-{}\"", "a".repeat(300));
137        let err = AuthError::from(crate::profile::ProfileError::Parse(secret));
138        let text = err.to_string();
139        assert!(!text.contains("sk-aaaa"), "{text}");
140        assert!(text.chars().count() <= 280, "{text}");
141    }
142
143    #[test]
144    fn auth_error_json_display_redacts_secret_in_path() {
145        let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
146        let err = AuthError::json(leak_path(), source);
147        let display = err.to_string();
148        assert!(
149            !display.contains(LEAK),
150            "JSON Display leaked token: {display}"
151        );
152        assert!(
153            display.contains("[redacted]"),
154            "JSON Display should redact: {display}"
155        );
156    }
157
158    #[test]
159    fn auth_error_io_display_redacts_secret_in_path() {
160        let err = AuthError::io(
161            Some(leak_path()),
162            io::Error::new(io::ErrorKind::NotFound, "missing store"),
163        );
164        let display = err.to_string();
165        assert!(
166            !display.contains(LEAK),
167            "Io Display leaked token: {display}"
168        );
169        assert!(
170            display.contains("[redacted]"),
171            "Io Display should redact: {display}"
172        );
173    }
174}