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