use std::io;
#[cfg(any(feature = "net", test))]
use std::path::Path;
use std::path::PathBuf;
use crate::profile::ProfileError;
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("missing {0}")]
MissingField(String),
#[error("I/O error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
Io {
path: Option<PathBuf>,
#[source]
source: io::Error,
},
#[error("JSON error{}: {source}", path.as_ref().map(|p| format!(" ({})", p.display())).unwrap_or_default())]
Json {
path: Option<PathBuf>,
#[source]
source: serde_json::Error,
},
#[error("{0}")]
TokenProvider(String),
#[error("file lock timeout")]
LockTimeout,
#[error("refusing to write empty or invalid credentials")]
EmptyWriteRefused,
#[error("vendor rejected token refresh (HTTP {status}): {summary}")]
VendorRejected {
status: u16,
summary: String,
},
#[error("credential store is stale ({}): {detail}", path.display())]
CredentialStoreStale {
path: PathBuf,
detail: String,
},
}
impl AuthError {
pub(crate) fn io(path: Option<PathBuf>, source: io::Error) -> Self {
Self::Io {
path: path.map(redact_pathbuf),
source,
}
}
#[cfg(any(feature = "net", test))]
pub(crate) fn json(path: impl AsRef<Path>, source: serde_json::Error) -> Self {
Self::Json {
path: Some(redact_pathbuf(path.as_ref().to_path_buf())),
source,
}
}
#[cfg(feature = "net")]
pub(crate) fn credential_store_stale(path: PathBuf, detail: impl Into<String>) -> Self {
let detail = crate::helpers::redact_secret_looking(&detail.into());
Self::CredentialStoreStale {
path: redact_pathbuf(path),
detail,
}
}
}
fn redact_pathbuf(path: PathBuf) -> PathBuf {
PathBuf::from(crate::helpers::redact_secret_looking(
&path.display().to_string(),
))
}
impl From<serde_json::Error> for AuthError {
fn from(source: serde_json::Error) -> Self {
Self::Json { path: None, source }
}
}
impl From<ProfileError> for AuthError {
fn from(err: ProfileError) -> Self {
match err {
ProfileError::MissingField(s) => Self::MissingField(s.to_string()),
ProfileError::Io { path, source } => Self::io(Some(path), source),
other => Self::TokenProvider(crate::helpers::sanitize_oauth_error_text(
&other.to_string(),
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const LEAK: &str = "ghp_ENVSUBST_LEAK_TOKEN_51";
fn leak_path() -> PathBuf {
PathBuf::from(format!("/tmp/creds-{LEAK}.json"))
}
#[test]
fn profile_parse_error_drops_url_userinfo() {
let err = AuthError::from(crate::profile::ProfileError::Parse(
"callback https://user:s3cret@evil.test/cb".into(),
));
let text = err.to_string();
assert!(!text.contains("s3cret"), "{text}");
assert!(!text.contains("user:"), "{text}");
assert!(text.contains("evil.test"), "{text}");
}
#[test]
fn profile_parse_error_is_capped_on_auth_error() {
let secret = format!("api_key = \"sk-{}\"", "a".repeat(300));
let err = AuthError::from(crate::profile::ProfileError::Parse(secret));
let text = err.to_string();
assert!(!text.contains("sk-aaaa"), "{text}");
assert!(text.chars().count() <= 280, "{text}");
}
#[test]
fn auth_error_json_display_redacts_secret_in_path() {
let source = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let err = AuthError::json(leak_path(), source);
let display = err.to_string();
assert!(
!display.contains(LEAK),
"JSON Display leaked token: {display}"
);
assert!(
display.contains("[redacted]"),
"JSON Display should redact: {display}"
);
}
#[test]
fn auth_error_io_display_redacts_secret_in_path() {
let err = AuthError::io(
Some(leak_path()),
io::Error::new(io::ErrorKind::NotFound, "missing store"),
);
let display = err.to_string();
assert!(
!display.contains(LEAK),
"Io Display leaked token: {display}"
);
assert!(
display.contains("[redacted]"),
"Io Display should redact: {display}"
);
}
}