rawler panics on some input rather than returning Err — a DNG whose IFD claims a >50000 px image, which the reference library has: a 521 MB stitched panorama, IMG_4181-Pano.dng. On a worker thread a panic is the end of the thread, so the face sweep that met it stopped thirteen seconds in, three sweeps running on the tablet and three on the desktop, with "17301 image(s) to index" as the last word. FR-RAW-4 says a malformed file must not abort a batch, and that is this crate's promise whatever the library beneath it does: every entry point that calls into rawler now runs under catch_unwind, and a file that panics the decoder is one failed file with the panic's message in the error. Verified on the panorama itself: metadata reads, decode returns the error, the thread survives. The crash hook still records the panic, which is right — it is a defect in a dependency and the record is how it gets reported.
113 lines
4.1 KiB
Rust
113 lines
4.1 KiB
Rust
/// 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<T>(
|
|
what: &'static str,
|
|
f: impl FnOnce() -> Result<T, DecodeError>,
|
|
) -> Result<T, DecodeError> {
|
|
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::<String>().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)
|
|
));
|
|
}
|
|
}
|