1use std::io;
6use std::path::PathBuf;
7
8pub type Result<T> = std::result::Result<T, AppError>;
9
10pub const AUTH_FAILURE_MESSAGE: &str =
11 "authentication rejected — credentials may be missing, expired, or invalid";
12
13#[derive(Debug, thiserror::Error)]
14pub enum AppError {
15 #[error("io error at {path}: {source}")]
17 Io {
18 path: PathBuf,
19 #[source]
20 source: io::Error,
21 },
22
23 #[error(transparent)]
25 IoBare(#[from] io::Error),
26
27 #[error("credentials error: {0}")]
31 Credentials(String),
32
33 #[error("network transport error: {0}")]
37 Transport(String),
38
39 #[error("HTTP {status}: {body}")]
43 Http { status: u16, body: String },
44
45 #[error("schema mismatch: {0}")]
49 Schema(String),
50
51 #[error("json error: {0}")]
53 Json(#[from] serde_json::Error),
54
55 #[error("toml error: {0}")]
57 Toml(#[from] toml::de::Error),
58
59 #[error("{0}")]
61 Other(String),
62}
63
64impl AppError {
65 pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
67 AppError::Io {
68 path: path.into(),
69 source,
70 }
71 }
72
73 pub fn is_transient(&self) -> bool {
76 matches!(self, AppError::Transport(_))
77 }
78
79 pub fn user_message(&self) -> String {
82 match self {
83 AppError::Http { status, .. } if matches!(status, 401 | 403) => {
84 format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
85 }
86 other => other.to_string(),
87 }
88 }
89}
90
91impl From<reqwest::Error> for AppError {
94 fn from(err: reqwest::Error) -> Self {
95 if err.is_timeout() || err.is_connect() || err.is_request() {
96 return AppError::Transport(err.to_string());
97 }
98 if let Some(status) = err.status() {
99 return AppError::Http {
100 status: status.as_u16(),
101 body: err.to_string(),
102 };
103 }
104 AppError::Other(err.to_string())
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn user_message_does_not_expose_authentication_response_bodies() {
114 for status in [401, 403] {
115 let error = AppError::Http {
116 status,
117 body: "PANCEA user@example.test <credential>&token".into(),
118 };
119 let rendered = error.user_message();
120 assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
121 assert!(!rendered.contains("PANCEA"));
122 assert!(!rendered.contains("user@example.test"));
123 assert!(!rendered.contains("&token"));
124 }
125 }
126
127 #[test]
128 fn user_message_preserves_non_authentication_diagnostics() {
129 let error = AppError::Http {
130 status: 500,
131 body: "provider unavailable".into(),
132 };
133 assert!(error.user_message().contains("provider unavailable"));
134 }
135}