1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
//! Shared error type. Vendors and renderers convert their failures into
//! `AppError` so the widget shell can decide whether to retry, fall back to
//! cache, show ⚠, or show "Loading…".
use std::io;
use std::path::PathBuf;
pub type Result<T> = std::result::Result<T, AppError>;
pub const AUTH_FAILURE_MESSAGE: &str =
"authentication rejected — credentials may be missing, expired, or invalid";
#[derive(Debug, thiserror::Error)]
pub enum AppError {
/// Local I/O failed (cache write, credentials read, theme file, etc.).
///
/// **The path is sanitized here rather than at each print site.** A path in
/// this variant is not always a literal this program chose — it can carry a
/// component from an account label, a vendor response, or an archive
/// member — and `Display for Path` escapes nothing. Doing it at the
/// `Display` covers every site that formats an `AppError`, including the
/// ones written after this comment.
#[error(
"io error at {}: {source}",
crate::display::sanitize_untrusted_path(path)
)]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
/// Generic I/O without a meaningful path (e.g. stdout writes).
#[error(transparent)]
IoBare(#[from] io::Error),
/// A vendor's credentials file is missing, unreadable, or malformed.
/// Distinct from `Io` because the widget treats it as "user must re-auth"
/// rather than a transient failure.
#[error("credentials error: {0}")]
Credentials(String),
/// HTTP request failed at the transport layer (DNS, TLS, timeout, connect).
/// Maps to claudebar's "HTTP 000" — show `Loading…`, don't write
/// `.last_error`, retry next tick.
#[error("network transport error: {0}")]
Transport(String),
/// HTTP request reached the server but returned a non-2xx status.
/// Carries the code + best-effort body so the widget can populate
/// `.last_error` for the tooltip.
#[error("HTTP {status}: {body}")]
Http { status: u16, body: String },
/// API returned 2xx but the body did not match our expected schema.
/// Treated like an HTTP error for tooltip purposes, but logged separately
/// because it signals undocumented-endpoint drift.
#[error("schema mismatch: {0}")]
Schema(String),
/// JSON serialization/deserialization failure (config files, response bodies).
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// TOML config parse failure.
#[error("toml error: {0}")]
Toml(#[from] toml::de::Error),
/// Catch-all for unexpected conditions (cache lock contention, etc.).
#[error("{0}")]
Other(String),
}
impl AppError {
/// Convenience for non-pathful I/O.
pub fn io_at(path: impl Into<PathBuf>, source: io::Error) -> Self {
AppError::Io {
path: path.into(),
source,
}
}
/// True for transient network errors that the widget should hide behind a
/// "Loading…" rather than a "⚠".
pub fn is_transient(&self) -> bool {
matches!(self, AppError::Transport(_))
}
/// Render an error for a local UI or report without exposing an upstream
/// authentication response body. Other errors retain their diagnostic text.
pub fn user_message(&self) -> String {
match self {
AppError::Http { status, .. } if matches!(status, 401 | 403) => {
format!("HTTP {status}: {AUTH_FAILURE_MESSAGE}")
}
other => other.to_string(),
}
}
}
/// Map a reqwest error into the right variant. Connection-class failures
/// become `Transport` (transient); the rest become generic `Http`/`Other`.
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() || err.is_connect() || err.is_request() {
return AppError::Transport(err.to_string());
}
if let Some(status) = err.status() {
return AppError::Http {
status: status.as_u16(),
body: err.to_string(),
};
}
AppError::Other(err.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Asserted at the `Display`, not at a print site, because that is the
/// whole point: a path in this variant can come from an account label, a
/// vendor response, or an archive member, and there are a dozen places
/// that format one.
#[test]
fn an_io_path_carrying_a_terminal_escape_renders_without_it() {
let rendered = AppError::Io {
path: PathBuf::from("/tmp/\x1b[2Kspoofed\nRESTORED: 0"),
source: io::Error::other("disk full"),
}
.to_string();
assert!(!rendered.contains('\u{1b}'), "{rendered:?}");
assert!(
!rendered.contains('\n'),
"an embedded newline forges a line: {rendered:?}"
);
assert!(rendered.contains("disk full"), "{rendered}");
}
#[test]
fn user_message_does_not_expose_authentication_response_bodies() {
for status in [401, 403] {
let error = AppError::Http {
status,
body: "PANCEA user@example.test <credential>&token".into(),
};
let rendered = error.user_message();
assert!(rendered.contains(AUTH_FAILURE_MESSAGE));
assert!(!rendered.contains("PANCEA"));
assert!(!rendered.contains("user@example.test"));
assert!(!rendered.contains("&token"));
}
}
#[test]
fn user_message_preserves_non_authentication_diagnostics() {
let error = AppError::Http {
status: 500,
body: "provider unavailable".into(),
};
assert!(error.user_message().contains("provider unavailable"));
}
}