1use thiserror::Error;
9
10pub fn error_chain(err: &dyn std::error::Error) -> String {
17 use std::fmt::Write;
18 let mut buf = err.to_string();
19 let mut cur = err.source();
20 while let Some(src) = cur {
21 let _ = write!(buf, ": {src}");
22 cur = src.source();
23 }
24 buf
25}
26
27#[derive(Debug, Error)]
28pub enum BougieError {
29 #[error("network failure while {operation}\n detail: {detail}")]
30 Network { operation: String, detail: String },
31
32 #[error(
33 "could not verify index signature\n \
34 index: {url}\n \
35 trust root: sha256:{trust_root_fingerprint}\n \
36 reason: {reason}\n \
37 hint: {hint}"
38 )]
39 IndexSignature {
40 url: String,
41 trust_root_fingerprint: String,
42 reason: String,
43 hint: String,
44 },
45
46 #[error(
47 "manifest sha256 mismatch\n \
48 url: {url}\n \
49 expected: sha256:{expected}\n \
50 actual: sha256:{actual}\n \
51 hint: server-side desync; refetching may not help — surface to the index publisher"
52 )]
53 ManifestHashMismatch { url: String, expected: String, actual: String },
54
55 #[error(
56 "blob sha256 mismatch\n \
57 url: {url}\n \
58 expected: sha256:{expected}\n \
59 actual: sha256:{actual}\n \
60 hint: download was retried once and still mismatched; check network for tampering or a stale CDN edge"
61 )]
62 BlobHashMismatch { url: String, expected: String, actual: String },
63
64 #[error("resolution failed for {kind}: {detail}")]
65 Resolution { kind: String, detail: String },
66
67 #[error(
68 "unsupported host target: {triple}\n \
69 hint: {hint}"
70 )]
71 UnknownTarget { triple: String, hint: String },
72
73 #[error(
74 "yanked artifact selected: {tag}\n \
75 reason: {reason}\n \
76 hint: pin a non-yanked version, or pass --allow-yanked for forensic use"
77 )]
78 YankedSelected { tag: String, reason: String },
79
80 #[error(
81 "concurrent operation conflict\n \
82 lock: {path}\n \
83 held by: pid {pid}\n \
84 hint: wait for the other bougie process to finish, or pass --lock-timeout=N for a longer wait"
85 )]
86 LockHeld { path: String, pid: u32 },
87
88 #[error("filesystem error while {operation}: {detail}")]
89 Filesystem { operation: String, detail: String },
90
91 #[error("self-update failed: {detail}")]
92 SelfUpdate { detail: String },
93}
94
95impl BougieError {
96 pub fn exit_code(&self) -> u8 {
97 match self {
98 Self::Network { .. } => 10,
99 Self::IndexSignature { .. } => 11,
100 Self::ManifestHashMismatch { .. } => 12,
101 Self::BlobHashMismatch { .. } => 13,
102 Self::Resolution { .. } => 20,
103 Self::UnknownTarget { .. } => 21,
104 Self::YankedSelected { .. } => 22,
105 Self::LockHeld { .. } => 40,
106 Self::Filesystem { .. } => 50,
107 Self::SelfUpdate { .. } => 60,
108 }
109 }
110}
111
112pub fn exit_code_for(err: &eyre::Report) -> u8 {
113 err.downcast_ref::<BougieError>()
114 .map_or(1, BougieError::exit_code)
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn each_variant_has_distinct_code() {
123 let codes = [
124 BougieError::Network { operation: String::new(), detail: String::new() }.exit_code(),
125 BougieError::IndexSignature {
126 url: String::new(),
127 trust_root_fingerprint: String::new(),
128 reason: String::new(),
129 hint: String::new(),
130 }
131 .exit_code(),
132 BougieError::ManifestHashMismatch {
133 url: String::new(),
134 expected: String::new(),
135 actual: String::new(),
136 }
137 .exit_code(),
138 BougieError::BlobHashMismatch {
139 url: String::new(),
140 expected: String::new(),
141 actual: String::new(),
142 }
143 .exit_code(),
144 BougieError::Resolution { kind: String::new(), detail: String::new() }.exit_code(),
145 BougieError::UnknownTarget { triple: String::new(), hint: String::new() }
146 .exit_code(),
147 BougieError::YankedSelected { tag: String::new(), reason: String::new() }
148 .exit_code(),
149 BougieError::LockHeld { path: String::new(), pid: 0 }.exit_code(),
150 BougieError::Filesystem { operation: String::new(), detail: String::new() }
151 .exit_code(),
152 BougieError::SelfUpdate { detail: String::new() }.exit_code(),
153 ];
154 let mut sorted = codes;
155 sorted.sort_unstable();
156 for w in sorted.windows(2) {
157 assert_ne!(w[0], w[1], "duplicate exit code {}", w[0]);
158 }
159 }
160
161 #[test]
162 fn exit_code_for_wrapped_bougie_error() {
163 let report = eyre::Report::new(BougieError::BlobHashMismatch {
164 url: "u".into(),
165 expected: "e".into(),
166 actual: "a".into(),
167 });
168 assert_eq!(exit_code_for(&report), 13);
169 }
170
171 #[test]
172 fn exit_code_for_unknown_error_defaults_to_one() {
173 let report = eyre::eyre!("something else");
174 assert_eq!(exit_code_for(&report), 1);
175 }
176
177 #[derive(Debug, Error)]
178 #[error("connection failed")]
179 struct Outer {
180 #[source]
181 cause: Inner,
182 }
183
184 #[derive(Debug, Error)]
185 #[error("dns lookup failed")]
186 struct Inner;
187
188 #[test]
189 fn error_chain_walks_sources() {
190 let e = Outer { cause: Inner };
191 let chain = error_chain(&e);
192 assert_eq!(chain, "connection failed: dns lookup failed");
193 }
194
195 #[test]
196 fn error_chain_single_error() {
197 let e = std::io::Error::new(std::io::ErrorKind::NotFound, "file gone");
198 assert_eq!(error_chain(&e), "file gone");
199 }
200
201 #[test]
202 fn signature_error_message_includes_hint() {
203 let e = BougieError::IndexSignature {
204 url: "https://example/index.json".into(),
205 trust_root_fingerprint: "abc".into(),
206 reason: "bad sig".into(),
207 hint: "rotate the key".into(),
208 };
209 let s = e.to_string();
210 assert!(s.contains("https://example/index.json"));
211 assert!(s.contains("sha256:abc"));
212 assert!(s.contains("bad sig"));
213 assert!(s.contains("rotate the key"));
214 }
215}