R5 says in its own note that zoom_resolution.rs establishes it as a pixel equality; that file was tagged FR-DSP-5 alone. FR-DEV-19's three sub-clauses carry eighty-three tags between them while the parent had none; MaskLayer, which is the thing they edit, now carries it. And NFR-R3 — a crash in decode does not take down the application, the image is marked failed — is exactly what the decoder's panic guard and the face sweep's unreadable mark do, tagged FR-RAW-4 and NFR-SEC-1 and not the clause that asked for them.
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 | NFR-R3
|
|
/// 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)
|
|
));
|
|
}
|
|
}
|