Skip to main content

bougie_errors/
lib.rs

1//! Domain error types and the §8 exit-code map.
2//!
3//! Variants carry enough context for the user to diagnose without
4//! reading source: which URL was being fetched, which sha was
5//! expected vs received, which trust root was loaded. The runtime
6//! wires `color_eyre` so the chain renders top-to-bottom.
7
8use thiserror::Error;
9
10/// Format a reqwest (or any `std::error::Error`) chain into a single string
11/// that includes the root cause, e.g.:
12///   "error sending request for url (…): dns error: failed to lookup address …"
13///
14/// Plain `e.to_string()` only shows the outermost message. This walks
15/// `.source()` so callers see *why* the request failed.
16pub 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}