/// TRACES: FR-RAW-4 | NFR-SEC-1 /// Failures from decoding. /// /// Per FR-RAW-4 a malformed file must not abort a batch, so these are always /// returned rather than panicking — and the decode path is the one place /// untrusted input arrives (NFR-SEC-1). #[derive(Debug, thiserror::Error)] pub enum DecodeError { #[error("read failed: {0}")] Read(String), #[error("unsupported or unrecognised format: {0}")] Unsupported(String), #[error("decode failed: {0}")] Decode(String), #[error("metadata unavailable: {0}")] Metadata(String), #[error("no embedded preview in this file")] NoPreview, #[error("embedded preview is corrupt: {0}")] CorruptPreview(String), } /// Run a decoder call, and return a panic inside it as an error. /// /// TRACES: FR-RAW-4 | NFR-SEC-1 /// rawler `panic!`s on some malformed input rather than returning `Err` — a /// DNG whose IFD claims a >50000 px image, for one, which is in the reference /// library. A panic on a worker thread ends the thread: the face sweep that /// met that file stopped 13 seconds in, three sweeps running, with "17301 /// image(s) to index" as the last word and nothing to say why. FR-RAW-4's /// rule — a malformed file must not abort a batch — is this crate's to keep /// whatever the library beneath it does, so every entry point that calls into /// rawler runs through here, and a file that panics the decoder is one failed /// file like any other. /// /// The crash hook still records the panic, because it runs before unwinding /// reaches this frame; that is right — it is a real defect in a dependency /// and the record is how it gets reported upstream — and a repeat is the same /// file being met again rather than a new fault. pub(crate) fn guarded( what: &'static str, f: impl FnOnce() -> Result, ) -> Result { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { Ok(result) => result, Err(payload) => { let msg = payload .downcast_ref::<&str>() .map(|s| s.to_string()) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "no message".to_string()); Err(DecodeError::Decode(format!( "{what}: the decoder panicked on this file: {msg}" ))) } } } impl DecodeError { /// Whether a fallback path might still produce an image. /// /// A missing preview is not a failure to display the file — it means fall /// through to full decode (FR-CULL-2, M-11). pub fn has_fallback(&self) -> bool { matches!( self, DecodeError::NoPreview | DecodeError::CorruptPreview(_) ) } } #[cfg(test)] mod tests { use super::*; #[test] fn preview_failures_fall_through_rather_than_failing() { assert!(DecodeError::NoPreview.has_fallback()); assert!(DecodeError::CorruptPreview("truncated".into()).has_fallback()); // A genuinely unsupported file has nowhere to fall through to. assert!(!DecodeError::Unsupported("unknown".into()).has_fallback()); } #[test] fn a_panic_in_the_decoder_is_an_error_and_the_thread_survives() { // The property the face sweep relies on: one file that panics rawler // is one failed file, not the end of the pass. The message travels, // because "decode failed" alone sends the reader to the crash log. let err = guarded("decode", || -> Result<(), DecodeError> { panic!("rawler: surely there's no such thing as a {}MP image!", 600) }) .unwrap_err(); let text = err.to_string(); assert!(text.contains("panicked"), "{text}"); assert!(text.contains("600MP"), "{text}"); assert!(!err.has_fallback(), "a panic is not a missing preview"); } #[test] fn a_result_passes_through_untouched() { assert_eq!(guarded("decode", || Ok::<_, DecodeError>(7)).unwrap(), 7); assert!(matches!( guarded("decode", || Err::<(), _>(DecodeError::NoPreview)), Err(DecodeError::NoPreview) )); } }