forensic_vfs/error.rs
1//! The one error type for the whole VFS stack.
2//!
3//! Panic-free (Paranoid Gatekeeper): every failure is a typed value carrying
4//! enough context to diagnose it. Two rules from the fleet discipline are baked
5//! into the shape here:
6//!
7//! - **Show the unrecognized value.** [`VfsError::Unrecognized`] and
8//! [`VfsError::Decode`] carry the actual offending bytes ([`SmallHex`]) plus
9//! the offset and layer — an "unknown magic" report is useless without them.
10//! - **Bootstrap fails loud; only a per-node miss degrades.** A base/decode
11//! failure is [`VfsError::Bootstrap`]/[`VfsError::Decode`] (never an empty
12//! result), distinct from a genuinely-unrecognized node.
13
14use core::fmt;
15
16/// A short, inline snapshot of offending bytes for diagnostics — never a heap
17/// allocation on the hot error path, and capped so a hostile stream cannot make
18/// an error message unbounded.
19#[derive(Clone, Copy, PartialEq, Eq)]
20pub struct SmallHex {
21 buf: [u8; Self::CAP],
22 len: u8,
23}
24
25impl SmallHex {
26 /// Maximum captured bytes; enough to identify a magic/signature.
27 pub const CAP: usize = 16;
28
29 /// Capture up to [`SmallHex::CAP`] bytes from `bytes` (a longer slice is
30 /// truncated — the prefix is what identifies a signature). Never panics.
31 #[must_use]
32 pub fn new(bytes: &[u8]) -> Self {
33 let mut buf = [0u8; Self::CAP];
34 let n = bytes.len().min(Self::CAP);
35 // `n <= CAP` and `n <= bytes.len()`, so both slices are in bounds.
36 buf[..n].copy_from_slice(&bytes[..n]);
37 Self { buf, len: n as u8 }
38 }
39
40 /// The captured bytes.
41 #[must_use]
42 pub fn as_bytes(&self) -> &[u8] {
43 // `len <= CAP` by construction, so the range is always valid.
44 self.buf.get(..self.len as usize).unwrap_or(&[])
45 }
46
47 /// True when nothing was captured.
48 #[must_use]
49 pub fn is_empty(&self) -> bool {
50 self.len == 0
51 }
52}
53
54impl fmt::Debug for SmallHex {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(f, "SmallHex(")?;
57 for b in self.as_bytes() {
58 write!(f, "{b:02x}")?;
59 }
60 write!(f, ")")
61 }
62}
63
64impl fmt::Display for SmallHex {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 let mut first = true;
67 for b in self.as_bytes() {
68 if !first {
69 write!(f, " ")?;
70 }
71 write!(f, "{b:02x}")?;
72 first = false;
73 }
74 Ok(())
75 }
76}
77
78/// Every fallible operation in the VFS returns this. `#[non_exhaustive]` so a new
79/// variant is an additive, non-breaking change.
80#[non_exhaustive]
81#[derive(Debug, thiserror::Error)]
82pub enum VfsError {
83 /// An OS/backing read failed. `op` names the operation for triage.
84 #[error("I/O during {op}: {source}")]
85 Io {
86 op: &'static str,
87 #[source]
88 source: std::io::Error,
89 },
90
91 /// A prober said `Yes`/`Maybe` for this layer, then `open` failed to validate
92 /// it. LOUD — never a silent downgrade to raw. Carries the offending bytes.
93 #[error("decode failed in {layer} at offset {offset}: {detail} (bytes: {bytes})")]
94 Decode {
95 layer: &'static str,
96 offset: u64,
97 detail: String,
98 bytes: SmallHex,
99 },
100
101 /// No prober recognized this source. The node is a typed `Unknown` leaf; the
102 /// sniffed bytes are attached so an analyst can identify it by hand.
103 #[error("unrecognized data at {at} offset {offset} (bytes: {bytes})")]
104 Unrecognized {
105 at: &'static str,
106 offset: u64,
107 bytes: SmallHex,
108 },
109
110 /// More than one prober returned `Yes` and `auto_pick` was off. The analyst
111 /// disambiguates rather than the engine guessing.
112 #[error("ambiguous detection: {candidates:?}")]
113 Ambiguous { candidates: Vec<&'static str> },
114
115 /// A prerequisite every downstream step depends on failed. ALWAYS loud; never
116 /// absorbed into an empty result.
117 #[error("bootstrap failed at {stage}: {detail}")]
118 Bootstrap { stage: &'static str, detail: String },
119
120 /// A required decoder is not compiled in (should not happen — batteries
121 /// included). Loud, names the capability.
122 #[error("unsupported {layer}: {scheme}")]
123 Unsupported { layer: &'static str, scheme: String },
124
125 /// A depth/source/byte cap tripped — a container/zip bomb, not a stack
126 /// overflow or OOM.
127 #[error("budget exceeded: {cap} (limit {limit})")]
128 Budget { cap: &'static str, limit: u64 },
129
130 /// A encryption layer needs credentials that were not supplied.
131 #[error("credentials required for {scheme} ({target})")]
132 NeedCredentials {
133 scheme: &'static str,
134 target: String,
135 },
136
137 /// An offset/length read past the addressable bound. Returned instead of a
138 /// panic by the bounded readers.
139 #[error("{what}: offset {offset}+{len} past bound {bound}")]
140 OutOfRange {
141 what: &'static str,
142 offset: u64,
143 len: u64,
144 bound: u64,
145 },
146}
147
148/// Convenience alias.
149pub type VfsResult<T> = Result<T, VfsError>;
150
151/// A `map_err` closure that wraps a [`std::io::Error`] as [`VfsError::Io`] with a
152/// static operation label — one place to build the variant, so every I/O call
153/// site is a one-liner.
154pub(crate) fn io_err(op: &'static str) -> impl Fn(std::io::Error) -> VfsError {
155 move |source| VfsError::Io { op, source }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::io_err;
161
162 #[test]
163 fn io_err_wraps_with_the_op_label() {
164 let e = io_err("read")(std::io::Error::other("boom"));
165 assert!(format!("{e}").contains("read"));
166 }
167}