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}