Skip to main content

stuffr_core/source/
seek_guard.rs

1//! One guard between every seekable [`Source`](super::Source) and the
2//! platform's own `seek`.
3//!
4//! # The class this closes
5//!
6//! A container reading an indexed archive seeks to positions the ARCHIVE
7//! declares: zip's central directory carries a local-header offset per
8//! record, zoo's directory chain is a list of absolute file offsets, ARJ
9//! skips by a declared length. Those numbers are attacker-controlled, and
10//! `std`'s unix `Seek for File` hands `SeekFrom::Start(u64)` to `lseek(2)`
11//! after casting it to `off_t`, an `i64`. Any value above [`i64::MAX`]
12//! therefore arrives at the kernel NEGATIVE, and the kernel answers
13//! `EINVAL` — a genuine `io::Error::Os { code: 22, kind: InvalidInput }`,
14//! not a synthesised one.
15//!
16//! [`crate::Error::from_decode_io`] folds `InvalidData` and `OutOfMemory`
17//! and nothing else, so that errno reached `Error::Io` and
18//! `Error::exit_code`'s `_ => 1` wildcard: **exit 1, "stuffr failed", for a
19//! file that is merely lying about where its entries are.** Measured on the
20//! `container` fuzz target's own reproducer, whose zip64 extended-information
21//! extra field declares a relative local-header offset of
22//! `0xBB47_210D_4F0C_59BF`; `stuffr list`, `test` and `unpack` all exited 1
23//! with `i/o error: Invalid argument (os error 22)`.
24//!
25//! # Why the guard is here and not in the container
26//!
27//! Three containers own a seek adapter (`zip.rs`'s `SeekAdapter`, `zoo.rs`'s
28//! `ZooSeekAdapter`, `arj.rs`'s `ArjGuardedReader`) and each would have
29//! needed the identical fold. `Error::from_decode_io`'s own doc comment
30//! states the objection to that shape: "putting the decision in N places
31//! means the natural code — a bare `?` on an `io::Error` — silently bypasses
32//! it, and a convention whose failure mode is invisible is not a
33//! convention." A seekable [`Source`](super::Source) hands its seek out
34//! through one method, `as_seek`, so wrapping what that method returns makes
35//! the guard unbypassable for every container, present and future.
36//!
37//! # Why an `InvalidInput` from `seek` is never a medium failure
38//!
39//! `lseek(2)` reports `EINVAL` for exactly one thing: the requested position
40//! is not a valid one (a bad `whence`, or a result that is negative or
41//! unrepresentable). A failing disk is `EIO`, a revoked file is `EBADF`, a
42//! pipe is `ESPIPE`, a permission problem never reaches `seek` at all —
43//! every one of those is a different kind and passes through this wrapper
44//! untouched. `io::Cursor` agrees: its only `seek` error is
45//! `InvalidInput`/"invalid seek to a negative or overflowing position".
46//!
47//! **`read` is deliberately not touched.** Phase 3a's Task 4b fixed a
48//! near-identical misclassification at ONE call site precisely so that a raw
49//! source's i/o failure — a real disk error, a permission failure, a broken
50//! pipe — would not be relabelled as corruption, and pinned that with two
51//! tests. Reads here are a verbatim delegation, so both still hold.
52
53use std::io::{self, ErrorKind, Read, Seek, SeekFrom};
54
55/// The largest absolute position any platform in scope can address: a file
56/// offset is an `i64` on every unix and on Windows alike, so a `u64` above
57/// this names a location no file can ever hold.
58pub const MAX_SEEK_POSITION: u64 = i64::MAX as u64;
59
60/// Wraps a seekable reader so a rejected seek POSITION is reported as
61/// malformed input rather than as an i/o failure of the medium.
62///
63/// See the module doc for the whole argument. Reads delegate verbatim.
64#[derive(Debug)]
65pub struct GuardedSeek<T> {
66    inner: T,
67}
68
69impl<T> GuardedSeek<T> {
70    pub fn new(inner: T) -> Self {
71        Self { inner }
72    }
73}
74
75impl<T: Read> Read for GuardedSeek<T> {
76    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
77        self.inner.read(buf)
78    }
79}
80
81impl<T: Seek> Seek for GuardedSeek<T> {
82    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
83        // Refused BEFORE the syscall, not folded after it, for one reason
84        // that is not tidiness: `io::Cursor` accepts
85        // `SeekFrom::Start(u64::MAX)` and answers `Ok`, while a `File`
86        // answers `EINVAL`. Without this arm the same archive read from a
87        // spool that stayed in memory and from one that escalated to a temp
88        // file would take two different paths out of the reader — the
89        // in-memory one reaching EOF and reporting truncation, the on-disk
90        // one reporting an OS failure. One refusal, one message, both rungs.
91        if let SeekFrom::Start(target) = pos
92            && target > MAX_SEEK_POSITION
93        {
94            return Err(io::Error::new(
95                ErrorKind::InvalidData,
96                format!(
97                    "a seek to absolute position {target} is past {MAX_SEEK_POSITION}, the \
98                     largest position any file can hold; the input declares a location that \
99                     cannot exist"
100                ),
101            ));
102        }
103        self.inner.seek(pos).map_err(|e| {
104            if e.kind() == ErrorKind::InvalidInput {
105                io::Error::new(
106                    ErrorKind::InvalidData,
107                    format!(
108                        "the platform refused a seek to {pos:?} as an invalid position ({e}); \
109                         the input declares a location that cannot exist"
110                    ),
111                )
112            } else {
113                e
114            }
115        })
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::error::Error;
123    use std::io::Write;
124
125    fn temp_file(bytes: &[u8]) -> std::fs::File {
126        let mut f = tempfile::tempfile().unwrap();
127        f.write_all(bytes).unwrap();
128        f.seek(SeekFrom::Start(0)).unwrap();
129        f
130    }
131
132    /// The defect itself, at the layer that produced it: a `u64` above
133    /// `i64::MAX` handed to a real file's `seek`.
134    #[test]
135    fn a_start_past_the_addressable_range_is_malformed_input_not_an_os_failure() {
136        let mut g = GuardedSeek::new(temp_file(b"0123456789"));
137        let err = g.seek(SeekFrom::Start(MAX_SEEK_POSITION + 1)).unwrap_err();
138        assert_eq!(err.kind(), ErrorKind::InvalidData, "{err}");
139        assert!(
140            err.get_ref().is_some(),
141            "the refusal must carry its own message, not a bare errno: {err:?}"
142        );
143
144        // The whole point: this classifies, and it classifies as the file's
145        // own inconsistency. `Error::exit_code`'s rule — exit 5 is "stuffr
146        // read the bytes and they contradict each other", exit 6 is "stuffr
147        // declined to ask the allocator" — puts this on 5: nothing is
148        // allocated from the offset, and no larger machine makes a position
149        // past `i64::MAX` reachable.
150        let classified = Error::from_decode_io(err);
151        assert!(matches!(classified, Error::Corrupt(_)), "{classified:?}");
152        assert_eq!(classified.exit_code(), 5);
153    }
154
155    /// The in-memory rung must answer the same thing the on-disk one does.
156    /// A bare `Cursor` answers `Ok(u64::MAX)` here, which is what made the
157    /// spool's two backings disagree about the same archive.
158    #[test]
159    fn the_in_memory_backing_refuses_what_a_file_refuses() {
160        let mut bare = io::Cursor::new(b"0123456789".to_vec());
161        assert!(
162            bare.seek(SeekFrom::Start(u64::MAX)).is_ok(),
163            "premise: an unguarded Cursor accepts a position no file can hold"
164        );
165
166        let mut g = GuardedSeek::new(io::Cursor::new(b"0123456789".to_vec()));
167        let err = g.seek(SeekFrom::Start(u64::MAX)).unwrap_err();
168        assert_eq!(err.kind(), ErrorKind::InvalidData, "{err}");
169    }
170
171    /// The other shape of an invalid position — a relative seek landing
172    /// before byte zero — which the platform, not this guard's own arm,
173    /// rejects.
174    #[test]
175    fn a_negative_resulting_position_is_folded_too() {
176        let mut g = GuardedSeek::new(temp_file(b"0123456789"));
177        let err = g.seek(SeekFrom::End(-1000)).unwrap_err();
178        assert_eq!(err.kind(), ErrorKind::InvalidData, "{err}");
179        assert_eq!(Error::from_decode_io(err).exit_code(), 5);
180    }
181
182    #[test]
183    fn an_ordinary_seek_and_read_are_untouched() {
184        let mut g = GuardedSeek::new(temp_file(b"0123456789"));
185        assert_eq!(g.seek(SeekFrom::Start(4)).unwrap(), 4);
186        let mut buf = [0u8; 3];
187        g.read_exact(&mut buf).unwrap();
188        assert_eq!(&buf, b"456");
189        assert_eq!(g.seek(SeekFrom::End(0)).unwrap(), 10);
190        assert_eq!(g.seek(SeekFrom::Current(-2)).unwrap(), 8);
191    }
192
193    /// The scope guarantee Task 4b's two pinned tests exist to protect,
194    /// asserted here at the new boundary rather than left to be inferred: a
195    /// genuine i/o failure of the MEDIUM must keep its kind. A read is
196    /// delegated verbatim, and a non-`InvalidInput` seek failure passes
197    /// through.
198    #[test]
199    fn a_medium_failure_keeps_its_kind_in_both_directions() {
200        struct Failing;
201        impl Read for Failing {
202            fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
203                Err(io::Error::new(ErrorKind::PermissionDenied, "disk said no"))
204            }
205        }
206        impl Seek for Failing {
207            fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
208                Err(io::Error::from_raw_os_error(5)) // EIO
209            }
210        }
211
212        let mut g = GuardedSeek::new(Failing);
213        let read_err = g.read(&mut [0u8; 4]).unwrap_err();
214        assert_eq!(read_err.kind(), ErrorKind::PermissionDenied, "{read_err}");
215        assert!(matches!(Error::from_decode_io(read_err), Error::Io(_)));
216
217        let seek_err = g.seek(SeekFrom::Start(0)).unwrap_err();
218        assert_eq!(seek_err.raw_os_error(), Some(5), "{seek_err:?}");
219        assert!(matches!(Error::from_decode_io(seek_err), Error::Io(_)));
220    }
221}