Skip to main content

stuffr_core/source/
mod.rs

1//! Input abstraction. A container never opens a file — it asks a `Source` what
2//! it can do, and the ladder (see [`crate::ladder`]) supplies the best rung.
3
4pub mod decode_side;
5mod file;
6pub mod limit;
7mod reader;
8mod seek_guard;
9pub mod spill;
10pub mod stream_only;
11
12pub use decode_side::{DecodeSideSource, is_decode_side, mark_decode_side};
13pub use file::FileSource;
14pub use limit::{Counting, CountingWriter, DEFAULT_MAX_RATIO, RATIO_FLOOR, RatioGuard};
15pub use reader::{PeekSource, ReaderSource};
16pub use seek_guard::{GuardedSeek, MAX_SEEK_POSITION};
17pub use spill::{SpillPolicy, SpillSource, SpillWriter};
18pub use stream_only::StreamOnly;
19
20use std::io::{Read, Seek};
21
22/// Blanket-implemented marker so `&mut dyn SeekRead` is usable as a trait object.
23pub trait SeekRead: Read + Seek + Send {}
24impl<T: Read + Seek + Send> SeekRead for T {}
25
26#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
27pub struct SourceCaps {
28    pub seekable: bool,
29    /// Total length if known. `None` for pipes.
30    pub len: Option<u64>,
31}
32
33/// An input stream that reports its own capabilities.
34pub trait Source: Read + Send {
35    fn caps(&self) -> SourceCaps;
36
37    /// Returns a seekable view, or `None` if this source cannot seek.
38    fn as_seek(&mut self) -> Option<&mut dyn SeekRead>;
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44    use std::io::Read;
45
46    #[test]
47    fn reader_source_is_not_seekable_and_has_unknown_length() {
48        let mut s = ReaderSource::new(std::io::Cursor::new(b"hello".to_vec()));
49        // A Cursor *is* seekable, but ReaderSource deliberately erases that:
50        // it models "arrived on a pipe". Use FileSource for real seek.
51        assert!(!s.caps().seekable);
52        assert_eq!(s.caps().len, None);
53        assert!(s.as_seek().is_none());
54
55        let mut buf = Vec::new();
56        s.read_to_end(&mut buf).unwrap();
57        assert_eq!(buf, b"hello");
58    }
59
60    #[test]
61    fn file_source_is_seekable_and_knows_its_length() {
62        let mut f = tempfile::NamedTempFile::new().unwrap();
63        std::io::Write::write_all(&mut f, b"0123456789").unwrap();
64        let mut s = FileSource::open(f.path()).unwrap();
65
66        assert!(s.caps().seekable);
67        assert_eq!(s.caps().len, Some(10));
68
69        let seek = s.as_seek().expect("file must expose seek");
70        seek.seek(std::io::SeekFrom::Start(4)).unwrap();
71        let mut buf = [0u8; 3];
72        seek.read_exact(&mut buf).unwrap();
73        assert_eq!(&buf, b"456");
74    }
75
76    /// Every rung a container can be handed a seek through must refuse a
77    /// position no file can hold, and refuse it the SAME way.
78    ///
79    /// This is the class evidence, not a zip regression: `as_seek()` is the
80    /// only channel a container has for a seek, and the three containers
81    /// that seek on archive-declared offsets — `zip.rs`'s `SeekAdapter`
82    /// (central-directory local-header offsets, 64-bit under zip64),
83    /// `zoo.rs`'s `ZooSeekAdapter` (the absolute directory chain) and
84    /// `arj.rs`'s `ArjGuardedReader` (declared skips) — all reach the
85    /// platform through it and through nothing else. Guarding what it hands
86    /// back therefore covers all three, and any container written next,
87    /// without any of them having to remember to fold anything.
88    ///
89    /// Both spool backings are included deliberately: an unguarded `Cursor`
90    /// ACCEPTS `SeekFrom::Start(u64::MAX)` where a `File` answers `EINVAL`,
91    /// so before this guard the identical archive took two different paths
92    /// out of the reader depending on whether the spool happened to escalate
93    /// to disk.
94    #[test]
95    fn every_seekable_rung_refuses_a_position_no_file_can_hold() {
96        use crate::error::Error;
97        use std::io::SeekFrom;
98
99        fn refusal(src: &mut dyn Source) -> Error {
100            let seek = src.as_seek().expect("this rung must be seekable");
101            let err = seek
102                .seek(SeekFrom::Start(u64::MAX))
103                .expect_err("a position past i64::MAX must be refused");
104            assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "{err}");
105            Error::from_decode_io(err)
106        }
107
108        let mut f = tempfile::NamedTempFile::new().unwrap();
109        std::io::Write::write_all(&mut f, b"0123456789").unwrap();
110        let mut file_rung = FileSource::open(f.path()).unwrap();
111
112        let mut in_memory = SpillSource::materialize(
113            Box::new(ReaderSource::new(std::io::Cursor::new(
114                b"0123456789".to_vec(),
115            ))),
116            &SpillPolicy::Temp {
117                dir: None,
118                mem_cap: 1024,
119                max: 1 << 20,
120            },
121        )
122        .unwrap();
123        assert!(
124            !in_memory.spilled_to_disk(),
125            "premise: this rung is a Cursor"
126        );
127
128        let mut on_disk = SpillSource::materialize(
129            Box::new(ReaderSource::new(std::io::Cursor::new(
130                b"0123456789".to_vec(),
131            ))),
132            &SpillPolicy::Temp {
133                dir: None,
134                mem_cap: 4,
135                max: 1 << 20,
136            },
137        )
138        .unwrap();
139        assert!(on_disk.spilled_to_disk(), "premise: this rung is a File");
140
141        for rung in [
142            &mut file_rung as &mut dyn Source,
143            &mut in_memory,
144            &mut on_disk,
145        ] {
146            let classified = refusal(rung);
147            assert!(
148                matches!(classified, Error::Corrupt(_)),
149                "the input's fault, not stuffr's: {classified:?}"
150            );
151            assert_eq!(
152                classified.exit_code(),
153                5,
154                "never exit 1, and never exit 6 — nothing was allocated from the offset"
155            );
156        }
157    }
158
159    #[test]
160    fn peek_source_replays_the_prefix_it_consumed() {
161        // This is the property the probe depends on: peeking must not consume.
162        let inner: Box<dyn Source> = Box::new(ReaderSource::new(std::io::Cursor::new(
163            b"MAGIC-then-payload".to_vec(),
164        )));
165        let mut p = PeekSource::fill(inner, 5).unwrap();
166        assert_eq!(p.prefix(), b"MAGIC");
167
168        let mut all = Vec::new();
169        p.read_to_end(&mut all).unwrap();
170        assert_eq!(all, b"MAGIC-then-payload");
171    }
172
173    #[test]
174    fn peek_source_tolerates_input_shorter_than_the_request() {
175        let inner: Box<dyn Source> =
176            Box::new(ReaderSource::new(std::io::Cursor::new(b"ab".to_vec())));
177        let mut p = PeekSource::fill(inner, 4096).unwrap();
178        assert_eq!(p.prefix(), b"ab");
179
180        let mut all = Vec::new();
181        p.read_to_end(&mut all).unwrap();
182        assert_eq!(all, b"ab");
183    }
184}