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 {
83 let mut report = FidelityReport::new(rung);
84 if rung.is_authoritative() {
85 return report;
86 }
87 if caps.trailing_index {
88 report.warn(Fidelity::TrailingIndexUnread { format });
89 report.warn(Fidelity::EntryCountUnknown);
90 }
91 report
92}
93
94pub fn resolve(
100 src: Box<dyn Source>,
101 format: FormatId,
102 caps: ContainerCaps,
103 policy: &StreamPolicy,
104) -> Result<Resolved> {
105 if src.caps().seekable {
107 return Ok(Resolved {
108 source: src,
109 rung: Rung::Exact,
110 report: FidelityReport::exact(),
111 });
112 }
113
114 let (allow_forward_only, spill, allow_degraded) = match policy {
115 StreamPolicy::Exact => return Err(Error::NotSeekable { format }),
116 StreamPolicy::ForwardOnly => (true, &NO_SPILL, false),
117 StreamPolicy::Adaptive {
118 allow_forward_only,
119 spill,
120 allow_degraded,
121 } => (*allow_forward_only, spill, *allow_degraded),
122 };
123
124 if caps.forward_parse && allow_forward_only && !caps.needs_seek {
126 return Ok(Resolved {
127 source: src,
128 rung: Rung::ForwardOnly,
129 report: seed_report(Rung::ForwardOnly, format, caps),
130 });
131 }
132
133 if spill.is_enabled() {
135 let spilled = SpillSource::materialize(src, spill)?;
136 return Ok(Resolved {
137 source: Box::new(spilled),
138 rung: Rung::Spilled,
139 report: FidelityReport::new(Rung::Spilled),
140 });
141 }
142
143 if allow_degraded && caps.degraded_parse {
145 return Ok(Resolved {
146 source: src,
147 rung: Rung::Degraded,
148 report: seed_report(Rung::Degraded, format, caps),
149 });
150 }
151
152 Err(Error::NotSeekable { format })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::source::{FileSource, ReaderSource};
159 use crate::{Fidelity, FormatId, Rung};
160
161 const ZIP: FormatId = FormatId::new("zip");
162 const SQUASHFS: FormatId = FormatId::new("squashfs");
163
164 fn pipe(bytes: &[u8]) -> Box<dyn Source> {
165 Box::new(ReaderSource::new(std::io::Cursor::new(bytes.to_vec())))
166 }
167
168 fn file(bytes: &[u8]) -> Box<dyn Source> {
169 let mut f = tempfile::NamedTempFile::new().unwrap();
170 std::io::Write::write_all(&mut f, bytes).unwrap();
171 let (_, path) = f.keep().unwrap();
172 Box::new(FileSource::open(&path).unwrap())
173 }
174
175 fn zip_caps() -> ContainerCaps {
177 ContainerCaps {
178 read: true,
179 forward_parse: true,
180 trailing_index: true,
181 per_entry_codec: true,
182 ..Default::default()
183 }
184 }
185
186 fn squashfs_caps() -> ContainerCaps {
188 ContainerCaps {
189 read: true,
190 needs_seek: true,
191 ..Default::default()
192 }
193 }
194
195 #[test]
196 fn seekable_input_reaches_exact_and_reports_no_loss() {
197 let r = resolve(file(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
198 assert_eq!(r.rung, Rung::Exact);
199 assert!(r.report.is_lossless());
200 }
201
202 #[test]
203 fn pipe_prefers_forward_only_over_spilling_to_disk() {
204 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
206 assert_eq!(r.rung, Rung::ForwardOnly);
207 assert!(!r.report.is_lossless());
208 }
209
210 #[test]
211 fn forward_only_on_a_trailing_index_format_records_the_index_loss() {
212 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &StreamPolicy::default()).unwrap();
213 assert!(
214 r.report
215 .warnings
216 .contains(&Fidelity::TrailingIndexUnread { format: ZIP })
217 );
218 assert!(r.report.warnings.contains(&Fidelity::EntryCountUnknown));
219 }
220
221 #[test]
222 fn a_forward_parseable_format_without_a_trailing_index_loses_nothing_specific() {
223 let tar = ContainerCaps {
225 read: true,
226 forward_parse: true,
227 ..Default::default()
228 };
229 let r = resolve(
230 pipe(b"payload"),
231 FormatId::new("tar"),
232 tar,
233 &StreamPolicy::default(),
234 )
235 .unwrap();
236 assert_eq!(r.rung, Rung::ForwardOnly);
237 assert!(r.report.warnings.is_empty());
238 }
239
240 #[test]
241 fn needs_seek_format_on_a_pipe_spills_and_stays_authoritative() {
242 let r = resolve(
243 pipe(b"payload"),
244 SQUASHFS,
245 squashfs_caps(),
246 &StreamPolicy::default(),
247 )
248 .unwrap();
249 assert_eq!(r.rung, Rung::Spilled);
250 assert!(r.rung.is_authoritative());
251 assert!(r.report.is_lossless());
252 assert!(
253 r.source.caps().seekable,
254 "spilling must produce a seekable source"
255 );
256 }
257
258 #[test]
259 fn spilling_preserves_the_bytes() {
260 let mut r = resolve(
261 pipe(b"0123456789"),
262 SQUASHFS,
263 squashfs_caps(),
264 &StreamPolicy::default(),
265 )
266 .unwrap();
267 let mut out = Vec::new();
268 std::io::Read::read_to_end(&mut r.source, &mut out).unwrap();
269 assert_eq!(out, b"0123456789");
270 }
271
272 #[test]
273 fn exact_policy_refuses_a_pipe_and_names_the_format() {
274 let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &StreamPolicy::Exact).unwrap_err();
275 assert!(matches!(err, crate::Error::NotSeekable { format } if format == SQUASHFS));
276 }
277
278 #[test]
279 fn forward_only_policy_never_spills() {
280 let err = resolve(
281 pipe(b"x"),
282 SQUASHFS,
283 squashfs_caps(),
284 &StreamPolicy::ForwardOnly,
285 )
286 .unwrap_err();
287 assert!(matches!(err, crate::Error::NotSeekable { .. }));
288 }
289
290 #[test]
291 fn forward_only_policy_still_works_where_the_format_allows_it() {
292 let r = resolve(pipe(b"x"), ZIP, zip_caps(), &StreamPolicy::ForwardOnly).unwrap();
293 assert_eq!(r.rung, Rung::ForwardOnly);
294 }
295
296 #[test]
297 fn spill_off_falls_through_to_degraded_when_the_format_can_salvage() {
298 let sevenz = ContainerCaps {
299 read: true,
300 degraded_parse: true,
301 trailing_index: true,
302 solid: true,
303 needs_seek: true,
304 ..Default::default()
305 };
306 let policy = StreamPolicy::Adaptive {
307 allow_forward_only: true,
308 spill: SpillPolicy::Off,
309 allow_degraded: true,
310 };
311 let r = resolve(pipe(b"x"), FormatId::new("7z"), sevenz, &policy).unwrap();
312 assert_eq!(r.rung, Rung::Degraded);
313 assert!(!r.rung.is_authoritative());
314 }
315
316 #[test]
317 fn spill_off_and_no_salvage_path_is_an_error_not_a_lie() {
318 let policy = StreamPolicy::Adaptive {
319 allow_forward_only: true,
320 spill: SpillPolicy::Off,
321 allow_degraded: true,
322 };
323 let err = resolve(pipe(b"x"), SQUASHFS, squashfs_caps(), &policy).unwrap_err();
324 assert!(matches!(err, crate::Error::NotSeekable { .. }));
325 }
326
327 #[test]
328 fn disallowing_forward_only_forces_a_spill_even_where_forward_would_work() {
329 let policy = StreamPolicy::Adaptive {
331 allow_forward_only: false,
332 spill: SpillPolicy::default(),
333 allow_degraded: true,
334 };
335 let r = resolve(pipe(b"payload"), ZIP, zip_caps(), &policy).unwrap();
336 assert_eq!(r.rung, Rung::Spilled);
337 assert!(r.report.is_lossless());
338 }
339
340 #[test]
357 fn spilling_classifies_a_decode_side_failure_as_corruption() {
358 use crate::testing::decode_side_failing_source;
359
360 let err = resolve(
361 decode_side_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
362 SQUASHFS,
363 squashfs_caps(),
364 &StreamPolicy::default(),
365 )
366 .expect_err("a source that fails part-way cannot spool");
367
368 assert!(
369 matches!(err, crate::Error::Corrupt(_)),
370 "a decoder's malformed-input report must not claim stuffr failed: {err:?}"
371 );
372 assert_eq!(err.exit_code(), 5);
373 assert!(
374 err.to_string()
375 .contains(crate::testing::FAILING_SOURCE_MESSAGE),
376 "the decoder's own wording must survive classification: {err}"
377 );
378 }
379
380 #[test]
387 fn spilling_leaves_a_raw_side_failure_as_an_io_error() {
388 use crate::testing::raw_failing_source;
389
390 let err = resolve(
391 raw_failing_source(b"some good bytes", std::io::ErrorKind::InvalidData),
392 SQUASHFS,
393 squashfs_caps(),
394 &StreamPolicy::default(),
395 )
396 .expect_err("a source that fails part-way cannot spool");
397
398 assert!(
399 matches!(err, crate::Error::Io(_)),
400 "only the marking separates this from the test above: {err:?}"
401 );
402 assert_eq!(err.exit_code(), 1);
403 }
404
405 #[test]
412 fn spilling_classifies_a_decode_side_ratio_refusal_as_a_resource_limit() {
413 use crate::testing::decode_side_failing_source;
414
415 let err = resolve(
416 decode_side_failing_source(b"some good bytes", std::io::ErrorKind::OutOfMemory),
417 SQUASHFS,
418 squashfs_caps(),
419 &StreamPolicy::default(),
420 )
421 .expect_err("a source that fails part-way cannot spool");
422
423 assert!(
424 matches!(err, crate::Error::ResourceLimit(_)),
425 "a refused expansion is a limit, never corruption and never exit 1: {err:?}"
426 );
427 assert_eq!(err.exit_code(), 6);
428 }
429
430 #[test]
431 fn default_policy_enables_every_rung() {
432 match StreamPolicy::default() {
433 StreamPolicy::Adaptive {
434 allow_forward_only,
435 allow_degraded,
436 spill,
437 } => {
438 assert!(allow_forward_only && allow_degraded && spill.is_enabled());
439 }
440 other => panic!("unexpected default: {other:?}"),
441 }
442 }
443}