Skip to main content

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}