1use crate::error::{Error, Result};
8use crate::fidelity::{Fidelity, FidelityReport, Rung};
9use crate::format::{ContainerCaps, FormatId};
10use crate::source::{Source, SpillPolicy, SpillSource};
11
12#[derive(Clone, PartialEq, Eq, Debug)]
13pub enum StreamPolicy {
14 Exact,
16 Adaptive {
18 allow_forward_only: bool,
19 spill: SpillPolicy,
20 allow_degraded: bool,
21 },
22 ForwardOnly,
24}
25
26impl Default for StreamPolicy {
27 fn default() -> Self {
28 StreamPolicy::Adaptive {
29 allow_forward_only: true,
30 spill: SpillPolicy::default(),
31 allow_degraded: true,
32 }
33 }
34}
35
36#[non_exhaustive]
47pub struct Resolved {
48 pub source: Box<dyn Source>,
49 pub rung: Rung,
50 pub report: FidelityReport,
51}
52
53impl std::fmt::Debug for Resolved {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("Resolved")
58 .field("source", &"<dyn Source>")
59 .field("rung", &self.rung)
60 .field("report", &self.report)
61 .finish()
62 }
63}
64
65static NO_SPILL: SpillPolicy = SpillPolicy::Off;
70
71fn seed_report(rung: Rung, format: FormatId, caps: ContainerCaps) -> FidelityReport {
78 let mut report = FidelityReport::new(rung);
79 if rung.is_authoritative() {
80 return report;
81 }
82 if caps.trailing_index {
83 report.warn(Fidelity::TrailingIndexUnread { format });
84 report.warn(Fidelity::EntryCountUnknown);
85 }
86 report
87}
88
89pub fn resolve(
95 src: Box<dyn Source>,
96 format: FormatId,
97 caps: ContainerCaps,
98 policy: &StreamPolicy,
99) -> Result<Resolved> {
100 if src.caps().seekable {
102 return Ok(Resolved {
103 source: src,
104 rung: Rung::Exact,
105 report: FidelityReport::exact(),
106 });
107 }
108
109 let (allow_forward_only, spill, allow_degraded) = match policy {
110 StreamPolicy::Exact => return Err(Error::NotSeekable { format }),
111 StreamPolicy::ForwardOnly => (true, &NO_SPILL, false),
112 StreamPolicy::Adaptive {
113 allow_forward_only,
114 spill,
115 allow_degraded,
116 } => (*allow_forward_only, spill, *allow_degraded),
117 };
118
119 if caps.forward_parse && allow_forward_only && !caps.needs_seek {
121 return Ok(Resolved {
122 source: src,
123 rung: Rung::ForwardOnly,
124 report: seed_report(Rung::ForwardOnly, format, caps),
125 });
126 }
127
128 if spill.is_enabled() {
130 let spilled = SpillSource::materialize(src, spill)?;
131 return Ok(Resolved {
132 source: Box::new(spilled),
133 rung: Rung::Spilled,
134 report: FidelityReport::new(Rung::Spilled),
135 });
136 }
137
138 if allow_degraded && caps.degraded_parse {
140 return Ok(Resolved {
141 source: src,
142 rung: Rung::Degraded,
143 report: seed_report(Rung::Degraded, format, caps),
144 });
145 }
146
147 Err(Error::NotSeekable { format })
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::source::{FileSource, ReaderSource};
154 use crate::{Fidelity, FormatId, Rung};
155
156 const ZIP: FormatId = FormatId::new("zip");
157 const SQUASHFS: FormatId = FormatId::new("squashfs");
158
159 fn pipe(bytes: &[u8]) -> Box<dyn Source> {
160 Box::new(ReaderSource::new(std::io::Cursor::new(bytes.to_vec())))
161 }
162
163 fn file(bytes: &[u8]) -> Box<dyn Source> {
164 let mut f = tempfile::NamedTempFile::new().unwrap();
165 std::io::Write::write_all(&mut f, bytes).unwrap();
166 let (_, path) = f.keep().unwrap();
167 Box::new(FileSource::open(&path).unwrap())
168 }
169
170 fn zip_caps() -> ContainerCaps {
172 ContainerCaps {
173 read: true,
174 forward_parse: true,
175 trailing_index: true,
176 per_entry_codec: true,
177 ..Default::default()
178 }
179 }
180
181 fn squashfs_caps() -> ContainerCaps {
183 ContainerCaps {
184 read: true,
185 needs_seek: true,
186 ..Default::default()
187 }
188 }
189
190 #[test]
191 fn seekable_input_reaches_exact_and_reports_no_loss() {
192 let r = resolve(file(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
193 assert_eq!(r.rung, Rung::Exact);
194 assert!(r.report.is_lossless());
195 }
196
197 #[test]
198 fn pipe_prefers_forward_only_over_spilling_to_disk() {
199 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
201 assert_eq!(r.rung, Rung::ForwardOnly);
202 assert!(!r.report.is_lossless());
203 }
204
205 #[test]
206 fn forward_only_on_a_trailing_index_format_records_the_index_loss() {
207 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
208 assert!(
209 r.report
210 .warnings
211 .contains(&Fidelity::TrailingIndexUnread { format: ZIP })
212 );
213 assert!(r.report.warnings.contains(&Fidelity::EntryCountUnknown));
214 }
215
216 #[test]
217 fn a_forward_parseable_format_without_a_trailing_index_loses_nothing_specific() {
218 let tar = ContainerCaps {
220 read: true,
221 forward_parse: true,
222 ..Default::default()
223 };
224 let r = resolve(
225 pipe(b"payload"),
226 FormatId::new("tar"),
227 tar,
228 &StreamPolicy::default(),
229 )
230 .unwrap();
231 assert_eq!(r.rung, Rung::ForwardOnly);
232 assert!(r.report.warnings.is_empty());
233 }
234
235 #[test]
236 fn needs_seek_format_on_a_pipe_spills_and_stays_authoritative() {
237 let r = resolve(
238 pipe(b"payload"),
239 SQUASHFS,
240 squashfs_caps(),
241 &StreamPolicy::default(),
242 )
243 .unwrap();
244 assert_eq!(r.rung, Rung::Spilled);
245 assert!(r.rung.is_authoritative());
246 assert!(r.report.is_lossless());
247 assert!(
248 r.source.caps().seekable,
249 "spilling must produce a seekable source"
250 );
251 }
252
253 #[test]
254 fn spilling_preserves_the_bytes() {
255 let mut r = resolve(
256 pipe(b"0123456789"),
257 SQUASHFS,
258 squashfs_caps(),
259 &StreamPolicy::default(),
260 )
261 .unwrap();
262 let mut out = Vec::new();
263 std::io::Read::read_to_end(&mut r.source, &mut out).unwrap();
264 assert_eq!(out, b"0123456789");
265 }
266
267 #[test]
268 fn exact_policy_refuses_a_pipe_and_names_the_format() {
269 let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &StreamPolicy::Exact).unwrap_err();
270 assert!(matches!(err, crate::Error::NotSeekable { format } if format == SQUASHFS));
271 }
272
273 #[test]
274 fn forward_only_policy_never_spills() {
275 let err = resolve(
276 pipe(b"x"),
277 SQUASHFS,
278 squashfs_caps(),
279 &StreamPolicy::ForwardOnly,
280 )
281 .unwrap_err();
282 assert!(matches!(err, crate::Error::NotSeekable { .. }));
283 }
284
285 #[test]
286 fn forward_only_policy_still_works_where_the_format_allows_it() {
287 let r = resolve(pipe(b"x"), ZIP, zip_caps(), &StreamPolicy::ForwardOnly).unwrap();
288 assert_eq!(r.rung, Rung::ForwardOnly);
289 }
290
291 #[test]
292 fn spill_off_falls_through_to_degraded_when_the_format_can_salvage() {
293 let sevenz = ContainerCaps {
294 read: true,
295 degraded_parse: true,
296 trailing_index: true,
297 solid: true,
298 needs_seek: true,
299 ..Default::default()
300 };
301 let policy = StreamPolicy::Adaptive {
302 allow_forward_only: true,
303 spill: SpillPolicy::Off,
304 allow_degraded: true,
305 };
306 let r = resolve(pipe(b"x"), FormatId::new("7z"), sevenz, &policy).unwrap();
307 assert_eq!(r.rung, Rung::Degraded);
308 assert!(!r.rung.is_authoritative());
309 }
310
311 #[test]
312 fn spill_off_and_no_salvage_path_is_an_error_not_a_lie() {
313 let policy = StreamPolicy::Adaptive {
314 allow_forward_only: true,
315 spill: SpillPolicy::Off,
316 allow_degraded: true,
317 };
318 let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &policy).unwrap_err();
319 assert!(matches!(err, crate::Error::NotSeekable { .. }));
320 }
321
322 #[test]
323 fn disallowing_forward_only_forces_a_spill_even_where_forward_would_work() {
324 let policy = StreamPolicy::Adaptive {
326 allow_forward_only: false,
327 spill: SpillPolicy::default(),
328 allow_degraded: true,
329 };
330 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &policy).unwrap();
331 assert_eq!(r.rung, Rung::Spilled);
332 assert!(r.report.is_lossless());
333 }
334
335 #[test]
352 fn spilling_classifies_a_decode_side_failure_as_corruption() {
353 use crate::testing::decode_side_failing_source;
354
355 let err = resolve(
356 decode_side_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
357 SQUASHFS,
358 squashfs_caps(),
359 &StreamPolicy::default(),
360 )
361 .expect_err("a source that fails part-way cannot spool");
362
363 assert!(
364 matches!(err, crate::Error::Corrupt(_)),
365 "a decoder's malformed-input report must not claim stuffr failed: {err:?}"
366 );
367 assert_eq!(err.exit_code(), 5);
368 assert!(
369 err.to_string()
370 .contains(crate::testing::FAILING_SOURCE_MESSAGE),
371 "the decoder's own wording must survive classification: {err}"
372 );
373 }
374
375 #[test]
382 fn spilling_leaves_a_raw_side_failure_as_an_io_error() {
383 use crate::testing::raw_failing_source;
384
385 let err = resolve(
386 raw_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
387 SQUASHFS,
388 squashfs_caps(),
389 &StreamPolicy::default(),
390 )
391 .expect_err("a source that fails part-way cannot spool");
392
393 assert!(
394 matches!(err, crate::Error::Io(_)),
395 "only the marking separates this from the test above: {err:?}"
396 );
397 assert_eq!(err.exit_code(), 1);
398 }
399
400 #[test]
407 fn spilling_classifies_a_decode_side_ratio_refusal_as_a_resource_limit() {
408 use crate::testing::decode_side_failing_source;
409
410 let err = resolve(
411 decode_side_failing_source(b"some good bytes", std::io::ErrorKind::OutOfMemory),
412 SQUASHFS,
413 squashfs_caps(),
414 &StreamPolicy::default(),
415 )
416 .expect_err("a source that fails part-way cannot spool");
417
418 assert!(
419 matches!(err, crate::Error::ResourceLimit(_)),
420 "a refused expansion is a limit, never corruption and never exit 1: {err:?}"
421 );
422 assert_eq!(err.exit_code(), 6);
423 }
424
425 #[test]
426 fn default_policy_enables_every_rung() {
427 match StreamPolicy::default() {
428 StreamPolicy::Adaptive {
429 allow_forward_only,
430 allow_degraded,
431 spill,
432 } => {
433 assert!(allow_forward_only && allow_degraded && spill.is_enabled());
434 }
435 other => panic!("unexpected default: {other:?}"),
436 }
437 }
438}