1use std::io;
4#[cfg(any(feature = "net", test))]
5use std::path::Path;
6use std::path::PathBuf;
7
8use crate::profile::ProfileError;
9
10#[derive(Debug, thiserror::Error)]
12pub enum AuthError {
13 #[error("missing {0}")]
15 MissingField(String),
16 #[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
18 Io {
19 path: Option<PathBuf>,
21 #[source]
23 source: io::Error,
24 },
25 #[error("JSON error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
27 Json {
28 path: Option<PathBuf>,
30 #[source]
32 source: serde_json::Error,
33 },
34 #[error("{0}")]
36 TokenProvider(String),
37 #[error("file lock timeout")]
39 LockTimeout,
40 #[error("refusing to write empty or invalid credentials")]
42 EmptyWriteRefused,
43 #[error("vendor rejected token refresh (HTTP {status}): {summary}")]
45 VendorRejected {
46 status: u16,
48 summary: String,
50 },
51 #[error("credential store is stale ({}): {detail}", path.display())]
53 CredentialStoreStale {
54 path: PathBuf,
56 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 #[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 #[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}