//! TRACES: FR-NC-3 | FR-CULL-2 //! Finding an embedded preview's byte range from a file header alone. //! //! # Why this exists //! //! Remote browsing must not transfer whole RAW files (FR-NC-3). The obvious //! shortcut — fetch a fixed prefix and hope the preview is inside it — does //! not work: an embedded JPEG typically starts a few hundred KB in and runs //! for one to three MB, so a truncated fetch yields a JPEG whose scanlines //! stop partway down. Decoders render what they have rather than erroring, so //! the failure looks like a corrupt image, not a short read. //! //! What FR-NC-3 actually specifies is two-stage: read the header, *parse the //! container* to locate the preview, then fetch exactly those bytes. //! //! # Scope //! //! This reads TIFF-structured containers — CR2, NEF, ARW, DNG, and ORF all //! carry their previews in IFD entries. CR3 is ISO-BMFF and is not handled //! here; it falls back to the caller's whole-file path, which is correct if //! slower. A locator that returned a wrong range would be far worse than one //! that declines. //! //! Every offset read from the file is treated as hostile (NFR-SEC-1): bounds //! are checked against the real file length, never trusted. use std::ops::Range; /// Where a preview lives inside its container. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PreviewLocation { /// Byte range of the JPEG, ready to hand to a `Range:` request. pub range: Range, /// Pixel dimensions where the container declared them. Used to pick the /// largest preview that is still smaller than a full decode. pub width: Option, pub height: Option, } impl PreviewLocation { pub fn len(&self) -> u64 { self.range.end - self.range.start } pub fn is_empty(&self) -> bool { self.range.end <= self.range.start } } /// How many bytes of header a caller should fetch before calling this. /// /// Large enough to cover the IFD chain in the formats measured, small enough /// that a miss costs little. Metadata alone needs less, but IFD1/IFD2 entries /// for the preview sit further in on some bodies. pub const HEADER_BYTES: u64 = 256 * 1024; /// Locate the largest embedded preview at or below `max_edge`, if any. /// /// `header` is the first [`HEADER_BYTES`] of the file; `file_len` is the whole /// file's length, needed to reject offsets that point past the end. /// /// Returns `None` when the container is not TIFF-structured, declares no /// preview, or declares one whose range is not credible. pub fn locate_preview(header: &[u8], file_len: u64) -> Option { let tiff = TiffReader::new(header)?; let mut best: Option = None; for ifd_offset in tiff.ifd_offsets() { let Some(entries) = tiff.read_ifd(ifd_offset) else { continue; }; if let Some(loc) = preview_from_entries(&tiff, &entries, file_len) { // Prefer the largest, since a bigger preview downscales better — // but anything is better than nothing. let better = match (&best, &loc) { (None, _) => true, (Some(b), l) => l.len() > b.len(), }; if better { best = Some(loc); } } } best } /// TIFF tags that carry a preview's location. mod tag { /// Legacy thumbnail offset/length (IFD1 in most makes). pub const JPEG_INTERCHANGE_FORMAT: u16 = 0x0201; pub const JPEG_INTERCHANGE_FORMAT_LENGTH: u16 = 0x0202; /// Strip-based storage, which is how DNG and some NEF previews are held. pub const STRIP_OFFSETS: u16 = 0x0111; pub const STRIP_BYTE_COUNTS: u16 = 0x0117; pub const IMAGE_WIDTH: u16 = 0x0100; pub const IMAGE_LENGTH: u16 = 0x0101; /// 1 = full-resolution sensor data, 0 = a reduced-resolution preview. pub const NEW_SUBFILE_TYPE: u16 = 0x00FE; pub const COMPRESSION: u16 = 0x0103; pub const SUB_IFDS: u16 = 0x014A; /// DNG `OpcodeList1` — the opcodes a reader must apply to the raw mosaic /// *before* anything else touches it, which is exactly where a bad pixel /// has to be dealt with. Lists 2 and 3 run after demosaic and after the /// colour transform, so neither can carry these. pub const OPCODE_LIST_1: u16 = 51008; } /// JPEG compression, as opposed to raw sensor data. const COMPRESSION_JPEG: u32 = 6; const COMPRESSION_OLD_JPEG: u32 = 7; fn preview_from_entries( tiff: &TiffReader, entries: &[Entry], file_len: u64, ) -> Option { let get = |t: u16| entries.iter().find(|e| e.tag == t); // Reject the full-resolution image: it is sensor data, not a preview, and // "locating" it would transfer the whole file — the exact cost this avoids. if let Some(e) = get(tag::NEW_SUBFILE_TYPE) { if tiff.scalar(e)? == 0 && get(tag::JPEG_INTERCHANGE_FORMAT).is_none() { // Subfile type 0 means full resolution. Only continue if it is a // JPEG interchange entry, which a main image never is. return None; } } // Strip-based entries must be JPEG-compressed; an uncompressed strip is // raw sensor data that no JPEG decoder will read. let (offset, length) = if let (Some(o), Some(l)) = ( get(tag::JPEG_INTERCHANGE_FORMAT), get(tag::JPEG_INTERCHANGE_FORMAT_LENGTH), ) { (tiff.scalar(o)? as u64, tiff.scalar(l)? as u64) } else if let (Some(o), Some(l), Some(c)) = ( get(tag::STRIP_OFFSETS), get(tag::STRIP_BYTE_COUNTS), get(tag::COMPRESSION), ) { let compression = tiff.scalar(c)?; if compression != COMPRESSION_JPEG && compression != COMPRESSION_OLD_JPEG { return None; } // A multi-strip image is tiled sensor data, not a single JPEG. if o.count != 1 || l.count != 1 { return None; } (tiff.scalar(o)? as u64, tiff.scalar(l)? as u64) } else { return None; }; // Everything below is validation against a hostile file (NFR-SEC-1). if length == 0 { return None; } let end = offset.checked_add(length)?; if end > file_len { return None; } // A "preview" the size of the whole file is the full image mislabelled. if length > file_len / 2 { return None; } Some(PreviewLocation { range: offset..end, width: get(tag::IMAGE_WIDTH).and_then(|e| tiff.scalar(e)), height: get(tag::IMAGE_LENGTH).and_then(|e| tiff.scalar(e)), }) } /// One IFD entry. #[derive(Debug, Clone, Copy)] struct Entry { tag: u16, kind: u16, count: u32, /// The raw 4-byte value field — either the value itself or an offset to it. value: u32, } /// A minimal TIFF structure reader. /// /// Deliberately not a general TIFF parser: it reads the IFD chain and entry /// values and nothing else, because that is all locating a preview needs. struct TiffReader<'a> { data: &'a [u8], little_endian: bool, first_ifd: u32, } impl<'a> TiffReader<'a> { fn new(data: &'a [u8]) -> Option { if data.len() < 8 { return None; } let little_endian = match &data[0..2] { b"II" => true, b"MM" => false, _ => return None, }; let magic = read_u16(data, 2, little_endian)?; // 42 is TIFF; 0x4F52 and 0x5352 are ORF's variants, which are // otherwise TIFF-shaped. if magic != 42 && magic != 0x4F52 && magic != 0x5352 { return None; } let first_ifd = read_u32(data, 4, little_endian)?; Some(Self { data, little_endian, first_ifd, }) } /// Every IFD worth searching: the chain from the header, plus any SubIFDs. /// /// Bounded, because a malformed file can point an IFD at itself and a /// naive walk would never terminate. fn ifd_offsets(&self) -> Vec { const MAX_IFDS: usize = 16; let mut out = Vec::new(); let mut seen = std::collections::HashSet::new(); let mut next = self.first_ifd; while next != 0 && out.len() < MAX_IFDS && seen.insert(next) { out.push(next); // SubIFDs hold the preview in DNG and several NEF variants. if let Some(entries) = self.read_ifd(next) { if let Some(sub) = entries.iter().find(|e| e.tag == tag::SUB_IFDS) { for offset in self.offsets(sub) { if out.len() < MAX_IFDS && seen.insert(offset) { out.push(offset); } } } } match self.next_ifd_offset(next) { Some(n) => next = n, None => break, } } out } fn read_ifd(&self, offset: u32) -> Option> { let base = offset as usize; let count = read_u16(self.data, base, self.little_endian)? as usize; // A plausible IFD has tens of entries, not thousands. A huge count is // a corrupt or hostile file, and allocating for it is the bug. if count > 512 { return None; } let mut entries = Vec::with_capacity(count); for i in 0..count { let e = base + 2 + i * 12; entries.push(Entry { tag: read_u16(self.data, e, self.little_endian)?, kind: read_u16(self.data, e + 2, self.little_endian)?, count: read_u32(self.data, e + 4, self.little_endian)?, value: read_u32(self.data, e + 8, self.little_endian)?, }); } Some(entries) } fn next_ifd_offset(&self, ifd: u32) -> Option { let base = ifd as usize; let count = read_u16(self.data, base, self.little_endian)? as usize; read_u32(self.data, base + 2 + count * 12, self.little_endian) } /// An entry's value as a single number. /// /// Handles the inline case only for the scalar types a preview entry uses; /// anything larger than four bytes is stored out of line and read through /// its offset. fn scalar(&self, e: &Entry) -> Option { match e.kind { // BYTE, inline when count is 1. The GPS directory's altitude // reference is one of these, and it is the difference between a // hilltop and a position 400 m under the Dead Sea. 1 if e.count == 1 => Some(if self.little_endian { e.value & 0xFF } else { // The value field is left-justified whatever the width, so a // big-endian byte sits in the *top* octet. e.value >> 24 }), // SHORT, inline when count is 1. 3 if e.count == 1 => Some(if self.little_endian { e.value & 0xFFFF } else { // Big-endian packs a short into the high half of the field. e.value >> 16 }), // LONG, always inline at count 1. 4 if e.count == 1 => Some(e.value), // A count above one points elsewhere; take the first element. 3 => read_u16(self.data, e.value as usize, self.little_endian).map(u32::from), 4 => read_u32(self.data, e.value as usize, self.little_endian), _ => None, } } /// TRACES: FR-EXP-8 /// One RATIONAL from an entry, as a number. /// /// A rational is eight bytes, so it never fits the four-byte value field /// and is always read through the offset — which is why `index` is /// meaningful: the GPS directory stores latitude as three of them in a /// row. /// /// A zero denominator yields `None` rather than an infinity. Cameras do /// write `0/0` into slots they had nothing for, and a shutter speed of /// `inf` propagated into an exported file is worse than a missing one. fn rational(&self, e: &Entry, index: u32) -> Option { // 5 is RATIONAL (two unsigned longs); 10 is SRATIONAL (two signed). if (e.kind != 5 && e.kind != 10) || index >= e.count { return None; } let at = (e.value as usize).checked_add(index as usize * 8)?; let n = read_u32(self.data, at, self.little_endian)?; let d = read_u32(self.data, at + 4, self.little_endian)?; if d == 0 { return None; } Some(if e.kind == 10 { n as i32 as f64 / d as i32 as f64 } else { n as f64 / d as f64 }) } /// An ASCII entry's string value. /// /// EXIF strings are NUL-terminated and often padded, and camera vendors /// pad with spaces too — both are trimmed, since a model name with a /// trailing NUL compares unequal to the same name without one. fn ascii(&self, e: &Entry) -> Option { // Type 2 is ASCII. Up to four bytes live inline; longer strings are // stored at the offset in the value field. if e.kind != 2 || e.count == 0 { return None; } let len = e.count as usize; let bytes = if len <= 4 { let raw = if self.little_endian { e.value.to_le_bytes() } else { e.value.to_be_bytes() }; raw[..len.min(4)].to_vec() } else { self.data .get(e.value as usize..e.value as usize + len)? .to_vec() }; let s = String::from_utf8_lossy(&bytes); let s = s.trim_end_matches('\0').trim(); if s.is_empty() { None } else { Some(s.to_string()) } } /// An entry's values as a list of offsets (for SubIFDs). /// An entry's bytes, wherever they live. /// /// Values of four bytes or fewer sit in the entry itself; anything longer /// is an offset. An opcode list is always longer, but the inline case is /// handled rather than assumed away — a file claiming a three-byte opcode /// list is malformed, and reading it from the wrong place would be reading /// somebody else's bytes. fn value_bytes(&self, e: &Entry) -> Option<&'a [u8]> { // UNDEFINED and BYTE are one byte per element; nothing else is a blob. if e.kind != 1 && e.kind != 7 { return None; } let len = e.count as usize; if len <= 4 { // The value field, in file order. It is stored as a u32 that was // read with the file's endianness, so it has to be put back the // same way to recover the original byte order. return None; } let start = e.value as usize; self.data.get(start..start.checked_add(len)?) } fn offsets(&self, e: &Entry) -> Vec { if e.kind != 4 { return Vec::new(); } if e.count == 1 { return vec![e.value]; } // Bounded: a SubIFD list is a handful of entries, never thousands. (0..e.count.min(8)) .filter_map(|i| { read_u32( self.data, e.value as usize + (i as usize) * 4, self.little_endian, ) }) .collect() } } fn read_u16(data: &[u8], at: usize, le: bool) -> Option { let b = data.get(at..at + 2)?; Some(if le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) }) } fn read_u32(data: &[u8], at: usize, le: bool) -> Option { let b = data.get(at..at + 4)?; Some(if le { u32::from_le_bytes([b[0], b[1], b[2], b[3]]) } else { u32::from_be_bytes([b[0], b[1], b[2], b[3]]) }) } /// Read EXIF from a JPEG's APP1 segment. /// /// A JPEG's EXIF block is a complete TIFF structure embedded in an `APP1` /// marker, so the reader above does the work — only finding the block differs. /// /// This exists because rawler decodes no JPEG at all, and a photo library is /// full of them: camera JPEGs, and in this project's reference library nearly /// six thousand scanned frames. Without it every one is undated and missing /// from the timeline. pub fn jpeg_metadata(bytes: &[u8]) -> Result { let tiff_start = find_exif_tiff(bytes) .ok_or_else(|| crate::DecodeError::Metadata("no EXIF segment".into()))?; tiff_metadata(&bytes[tiff_start..]) } /// Read EXIF from a bare TIFF structure. /// /// Serves two callers: a JPEG's APP1 payload, and a TIFF-derived RAW whose /// primary decoder returned no date. The second case is real — rawler reports /// no `DateTimeOriginal` for some DNGs whose tag sits plainly at byte 826 — /// and without this fallback those images are silently undated. pub fn tiff_metadata(tiff_data: &[u8]) -> Result { let reader = TiffReader::new(tiff_data) .ok_or_else(|| crate::DecodeError::Metadata("malformed EXIF header".into()))?; let mut md = crate::Metadata::default(); // Fallback dates accumulate across *all* IFDs before being resolved. They // must not be settled per-IFD: one reference scanner writes `DateTime` in // the main IFD and `DateTimeDigitized` in the Exif sub-IFD, 102 seconds // apart, so resolving after the first would take the worse of the two. let mut fb = FallbackDates::default(); for ifd in reader.ifd_offsets() { let Some(entries) = reader.read_ifd(ifd) else { continue; }; read_exif_entries(&reader, &entries, &mut md, &mut fb); // The interesting tags live in the Exif sub-IFD, which the main IFD // points at rather than containing. if let Some(e) = entries.iter().find(|e| e.tag == EXIF_IFD_POINTER) { if let Some(sub) = reader.scalar(e).and_then(|o| reader.read_ifd(o)) { read_exif_entries(&reader, &sub, &mut md, &mut fb); } } // TRACES: FR-EXP-8 // The GPS directory is a third IFD, pointed at from the main one and // read with its own tag table. First reading wins, as with // orientation: a thumbnail IFD that repeats the pointer describes the // same photograph, and the main image's is the one to trust. if md.location.is_none() { if let Some(e) = entries.iter().find(|e| e.tag == gps_tag::POINTER) { if let Some(sub) = reader.scalar(e).and_then(|o| reader.read_ifd(o)) { md.location = read_gps_entries(&reader, &sub); } } } } // Ranked: when the shutter fired, else when the image was digitised, else // when the file was last written. `DateTime` moves on every re-save, so it // is the last resort rather than the first match. if md.captured_at.is_none() { md.captured_at = fb.digitized.or(fb.modified); } Ok(md) } /// Offset of the TIFF header inside a JPEG's `APP1` EXIF segment. /// /// Walks the marker chain rather than scanning for the `Exif\0\0` magic: /// scanning could match those bytes inside compressed image data and point the /// TIFF reader at noise. fn find_exif_tiff(bytes: &[u8]) -> Option { if !bytes.starts_with(&[0xFF, 0xD8]) { return None; } let mut i = 2; // Bounded by the header slice callers pass; a malformed length field // cannot walk past the end because every read is checked. while i + 4 <= bytes.len() { if bytes[i] != 0xFF { return None; } let marker = bytes[i + 1]; // Start of scan: image data follows, and no more headers. if marker == 0xDA { return None; } let len = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize; if len < 2 { return None; } // APP1 carrying the "Exif\0\0" identifier. if marker == 0xE1 { let seg = bytes.get(i + 4..i + 2 + len)?; if seg.starts_with(b"Exif\0\0") { return Some(i + 4 + 6); } } i += 2 + len; } None } /// Exif sub-IFD pointer, where the capture tags actually live. const EXIF_IFD_POINTER: u16 = 0x8769; mod exif_tag { pub const MAKE: u16 = 0x010F; pub const MODEL: u16 = 0x0110; /// How the stored pixels sit relative to how the image should be seen. /// /// Lives in the main IFD rather than the Exif sub-IFD, which is why it is /// found at all: the sub-IFD is where the *capture* tags are. pub const ORIENTATION: u16 = 0x0112; /// When the shutter fired. Absent on scanner output. pub const DATE_TIME_ORIGINAL: u16 = 0x9003; /// When the file was written. A camera sets both; a **scanner sets only /// this one**, so without it every scanned frame is undated — 5,712 of /// them in this project's reference library. pub const DATE_TIME: u16 = 0x0132; /// Digitisation time. Another fallback some devices fill instead. pub const DATE_TIME_DIGITIZED: u16 = 0x9004; pub const OFFSET_TIME_ORIGINAL: u16 = 0x9011; pub const ISO: u16 = 0x8827; pub const LENS_MODEL: u16 = 0xA434; pub const PIXEL_X: u16 = 0xA002; pub const PIXEL_Y: u16 = 0xA003; /// TRACES: FR-EXP-8 /// Who made the photograph, and under what terms. Both live in the main /// IFD beside `Make`, not in the Exif sub-IFD. pub const ARTIST: u16 = 0x013B; pub const COPYRIGHT: u16 = 0x8298; /// Exposure, as RATIONALs. Read here as well as from rawler because the /// JPEG path has no rawler behind it, and a camera JPEG that lost its /// shutter speed on export lost it for good. pub const EXPOSURE_TIME: u16 = 0x829A; pub const FNUMBER: u16 = 0x829D; pub const FOCAL_LENGTH: u16 = 0x920A; } /// TRACES: FR-EXP-8 /// The GPS directory pointer, and the tags inside it. /// /// A separate module from [`exif_tag`] because the numbers collide: 0x0001 is /// `GPSLatitudeRef` here and `InteropIndex` there, and a GPS tag read against /// a main-IFD table is how a file comes to claim an exposure time of "N". mod gps_tag { /// The main IFD entry pointing at the GPS directory. pub const POINTER: u16 = 0x8825; pub const LATITUDE_REF: u16 = 0x0001; pub const LATITUDE: u16 = 0x0002; pub const LONGITUDE_REF: u16 = 0x0003; pub const LONGITUDE: u16 = 0x0004; /// 0 above sea level, 1 below. The altitude itself is unsigned. pub const ALTITUDE_REF: u16 = 0x0005; pub const ALTITUDE: u16 = 0x0006; } /// Dates that stand in for a missing `DateTimeOriginal`. /// /// Collected across every IFD and ranked once at the end, because the two can /// live in different IFDs and disagree. #[derive(Default)] struct FallbackDates { /// When the image was digitised. A scanner's real capture time. digitized: Option, /// When the file was last written. Moves on re-save, so lowest rank. modified: Option, } fn read_exif_entries( r: &TiffReader, entries: &[Entry], md: &mut crate::Metadata, fb: &mut FallbackDates, ) { for e in entries { match e.tag { exif_tag::MAKE => md.make = r.ascii(e), exif_tag::MODEL => md.model = r.ascii(e), exif_tag::LENS_MODEL => md.lens = r.ascii(e), exif_tag::ISO => md.iso = r.scalar(e), exif_tag::ARTIST => md.artist = r.ascii(e), exif_tag::COPYRIGHT => md.copyright = r.ascii(e), exif_tag::EXPOSURE_TIME => md.shutter = r.rational(e, 0).map(|v| v as f32), exif_tag::FNUMBER => md.aperture = r.rational(e, 0).map(|v| v as f32), exif_tag::FOCAL_LENGTH => md.focal_length = r.rational(e, 0).map(|v| v as f32), exif_tag::PIXEL_X => md.width = r.scalar(e), exif_tag::PIXEL_Y => md.height = r.scalar(e), // First IFD wins, unlike the fields above, which take the last // reading. This loop visits every IFD in the file, and a TIFF's // second one describes the *embedded thumbnail* — which some // bodies write already upright, tagged `1`. Letting that overwrite // the main image's tag would lay every portrait frame on its side. exif_tag::ORIENTATION => { if let Some(v) = r.scalar(e) { md.orientation .get_or_insert_with(|| dr_types::Orientation::from_exif(v as u16)); } } exif_tag::DATE_TIME_ORIGINAL => { if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) { md.captured_at = Some(t); } } exif_tag::DATE_TIME_DIGITIZED => { if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) { fb.digitized.get_or_insert(t); } } exif_tag::DATE_TIME => { if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) { fb.modified.get_or_insert(t); } } exif_tag::OFFSET_TIME_ORIGINAL => { md.captured_offset = r.ascii(e).as_deref().and_then(crate::parse_exif_offset) } _ => {} } } } /// TRACES: FR-EXP-8 /// A GPS directory's entries as a position. /// /// Both coordinates or nothing: a latitude without a longitude is not half a /// position, it is no position, and half of one written into an export would /// be a coordinate on the Greenwich meridian. fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option { let find = |tag: u16| entries.iter().find(|e| e.tag == tag); // Degrees, minutes and seconds, each its own rational — and each of the // three optional in practice, since a body that fixed only to the minute // still writes the entry. let degrees = |tag: u16, ref_tag: u16| -> Option { let e = find(tag)?; let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0 + r.rational(e, 2).unwrap_or(0.0) / 3600.0; // The magnitude is unsigned; the hemisphere is a letter beside it. let south_or_west = find(ref_tag) .and_then(|e| r.ascii(e)) .map(|s| { let s = s.trim().to_ascii_uppercase(); s == "S" || s == "W" }) .unwrap_or(false); Some(if south_or_west { -d } else { d }) }; let latitude = degrees(gps_tag::LATITUDE, gps_tag::LATITUDE_REF)?; let longitude = degrees(gps_tag::LONGITUDE, gps_tag::LONGITUDE_REF)?; let altitude = find(gps_tag::ALTITUDE) .and_then(|e| r.rational(e, 0)) .map(|a| { let below = find(gps_tag::ALTITUDE_REF).and_then(|e| r.scalar(e)) == Some(1); if below { -a } else { a } }); dr_types::Location::new(latitude, longitude, altitude) } /// Whether a byte slice is a complete JPEG. /// /// A truncated JPEG decodes to a partial image rather than an error — the /// exact failure that made range-fetched thumbnails render as the top tenth of /// the frame. Checking for the end-of-image marker catches it before the /// result reaches a cache or a screen. pub fn is_complete_jpeg(bytes: &[u8]) -> bool { bytes.len() > 4 && bytes.starts_with(&[0xFF, 0xD8]) // Trailing padding after EOI is legal and does occur, so scan the tail // rather than testing only the final two bytes. && bytes .rchunks(64) .next() .map(|tail| tail.windows(2).any(|w| w == [0xFF, 0xD9])) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; /// Build a little-endian TIFF header with one IFD. fn tiff(entries: &[(u16, u16, u32, u32)], next_ifd: u32) -> Vec { let mut v = Vec::new(); v.extend_from_slice(b"II"); v.extend_from_slice(&42u16.to_le_bytes()); v.extend_from_slice(&8u32.to_le_bytes()); // first IFD at offset 8 v.extend_from_slice(&(entries.len() as u16).to_le_bytes()); for (tag, kind, count, value) in entries { v.extend_from_slice(&tag.to_le_bytes()); v.extend_from_slice(&kind.to_le_bytes()); v.extend_from_slice(&count.to_le_bytes()); v.extend_from_slice(&value.to_le_bytes()); } v.extend_from_slice(&next_ifd.to_le_bytes()); v.resize(v.len().max(1024), 0); v } /// Build a little-endian TIFF with two chained IFDs. /// /// The second one stands in for a TIFF's thumbnail IFD, which is where the /// orientation test's whole point lives. fn tiff_two_ifds(first: &[(u16, u16, u32, u32)], second: &[(u16, u16, u32, u32)]) -> Vec { // IFD0 occupies 2 + 12n + 4 bytes from offset 8. let second_at = 8 + 2 + 12 * first.len() as u32 + 4; let mut v = tiff(first, second_at); v.truncate(second_at as usize); v.extend_from_slice(&(second.len() as u16).to_le_bytes()); for (tag, kind, count, value) in second { v.extend_from_slice(&tag.to_le_bytes()); v.extend_from_slice(&kind.to_le_bytes()); v.extend_from_slice(&count.to_le_bytes()); v.extend_from_slice(&value.to_le_bytes()); } v.extend_from_slice(&0u32.to_le_bytes()); v.resize(v.len().max(1024), 0); v } #[test] fn the_grid_reads_a_jpegs_orientation_without_decoding_it() { // The exact call the thumbnail worker makes, on the exact bytes it // has: a header, no pixels. Going through `metadata` instead would // build a rawler decoder per grid cell. let jpeg = jpeg_with_exif(&[(exif_tag::ORIENTATION, 3, 1, 6)], &[]); assert_eq!( crate::orientation(&jpeg), Some(dr_types::Orientation::from_exif(6)) ); // And a file that says nothing declines rather than guessing. let plain = jpeg_with_exif(&[(exif_tag::ISO, 3, 1, 400)], &[]); assert_eq!(crate::orientation(&plain), None); } #[test] fn orientation_is_read_from_the_main_ifd() { // 6 is "rotate 90° clockwise to display" — a phone or a body held on // its side, which is the case this whole path exists for. let h = tiff(&[(exif_tag::ORIENTATION, 3, 1, 6)], 0); let md = tiff_metadata(&h).expect("metadata"); assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(6))); } #[test] fn a_file_with_no_orientation_tag_reports_none_rather_than_upright() { // "Nothing was said" and "the camera was level" are different claims. // They are displayed alike, but only one of them can later be // distinguished from a deliberate `1`. let h = tiff(&[(tag::IMAGE_WIDTH, 4, 1, 1620)], 0); let md = tiff_metadata(&h).expect("metadata"); assert_eq!(md.orientation, None); } #[test] fn the_thumbnail_ifd_does_not_overwrite_the_main_images_orientation() { // The regression this guards: some bodies write their embedded // thumbnail already upright and tag that IFD `1`. Reading every IFD // last-wins — which is right for make, model and the dates — would // take the thumbnail's `1` and lay every portrait frame on its side. let h = tiff_two_ifds( &[(exif_tag::ORIENTATION, 3, 1, 8)], &[(exif_tag::ORIENTATION, 3, 1, 1)], ); let md = tiff_metadata(&h).expect("metadata"); assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(8))); } #[test] fn finds_a_jpeg_interchange_preview() { let h = tiff( &[ (tag::JPEG_INTERCHANGE_FORMAT, 4, 1, 100_000), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 1_500_000), (tag::IMAGE_WIDTH, 4, 1, 1620), (tag::IMAGE_LENGTH, 4, 1, 1080), ], 0, ); let loc = locate_preview(&h, 25_000_000).expect("a preview"); assert_eq!(loc.range, 100_000..1_600_000); assert_eq!(loc.width, Some(1620)); assert_eq!(loc.height, Some(1080)); } #[test] fn a_range_past_the_end_of_file_is_rejected() { // The check that stops a corrupt offset becoming a wild range request. let h = tiff( &[ (tag::JPEG_INTERCHANGE_FORMAT, 4, 1, 20_000_000), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 10_000_000), ], 0, ); assert!(locate_preview(&h, 25_000_000).is_none()); } #[test] fn a_preview_larger_than_half_the_file_is_rejected() { // That is the full image mislabelled; "locating" it would transfer the // whole file, which is what this exists to avoid. let h = tiff( &[ (tag::JPEG_INTERCHANGE_FORMAT, 4, 1, 100), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 9_000_000), ], 0, ); assert!(locate_preview(&h, 10_000_000).is_none()); } #[test] fn a_zero_length_preview_is_rejected() { let h = tiff( &[ (tag::JPEG_INTERCHANGE_FORMAT, 4, 1, 100), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 0), ], 0, ); assert!(locate_preview(&h, 1_000_000).is_none()); } #[test] fn uncompressed_strips_are_not_mistaken_for_a_preview() { // Raw sensor data lives in strips too; feeding it to a JPEG decoder // would produce noise. let h = tiff( &[ (tag::STRIP_OFFSETS, 4, 1, 50_000), (tag::STRIP_BYTE_COUNTS, 4, 1, 800_000), (tag::COMPRESSION, 3, 1, 1), // uncompressed ], 0, ); assert!(locate_preview(&h, 25_000_000).is_none()); } #[test] fn jpeg_compressed_strips_are_accepted() { let h = tiff( &[ (tag::STRIP_OFFSETS, 4, 1, 50_000), (tag::STRIP_BYTE_COUNTS, 4, 1, 800_000), (tag::COMPRESSION, 3, 1, COMPRESSION_JPEG), ], 0, ); let loc = locate_preview(&h, 25_000_000).expect("a preview"); assert_eq!(loc.range, 50_000..850_000); } #[test] fn multi_strip_images_are_rejected() { // Several strips means tiled sensor data, not one contiguous JPEG. let h = tiff( &[ (tag::STRIP_OFFSETS, 4, 8, 50_000), (tag::STRIP_BYTE_COUNTS, 4, 8, 800_000), (tag::COMPRESSION, 3, 1, COMPRESSION_JPEG), ], 0, ); assert!(locate_preview(&h, 25_000_000).is_none()); } #[test] fn big_endian_files_parse() { // Nikon and Olympus ship big-endian containers. let mut v = Vec::new(); v.extend_from_slice(b"MM"); v.extend_from_slice(&42u16.to_be_bytes()); v.extend_from_slice(&8u32.to_be_bytes()); v.extend_from_slice(&2u16.to_be_bytes()); for (tag, kind, count, value) in [ (tag::JPEG_INTERCHANGE_FORMAT, 4u16, 1u32, 4096u32), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 900_000), ] { v.extend_from_slice(&tag.to_be_bytes()); v.extend_from_slice(&kind.to_be_bytes()); v.extend_from_slice(&count.to_be_bytes()); v.extend_from_slice(&value.to_be_bytes()); } v.extend_from_slice(&0u32.to_be_bytes()); v.resize(1024, 0); let loc = locate_preview(&v, 25_000_000).expect("a preview"); assert_eq!(loc.range, 4096..904_096); } #[test] fn a_big_endian_short_reads_from_the_high_half() { // The classic TIFF trap: a SHORT is left-justified in the 4-byte value // field on big-endian, so reading it as a LONG yields a huge number. let mut v = Vec::new(); v.extend_from_slice(b"MM"); v.extend_from_slice(&42u16.to_be_bytes()); v.extend_from_slice(&8u32.to_be_bytes()); v.extend_from_slice(&4u16.to_be_bytes()); for (tag, kind, count, value) in [ (tag::STRIP_OFFSETS, 4u16, 1u32, 1000u32), (tag::STRIP_BYTE_COUNTS, 4, 1, 500_000), (tag::COMPRESSION, 3, 1, (COMPRESSION_JPEG) << 16), (tag::IMAGE_WIDTH, 3, 1, 1620u32 << 16), ] { v.extend_from_slice(&tag.to_be_bytes()); v.extend_from_slice(&kind.to_be_bytes()); v.extend_from_slice(&count.to_be_bytes()); v.extend_from_slice(&value.to_be_bytes()); } v.extend_from_slice(&0u32.to_be_bytes()); v.resize(1024, 0); let loc = locate_preview(&v, 25_000_000).expect("a preview"); assert_eq!(loc.width, Some(1620), "short read from the wrong half"); } #[test] fn the_largest_preview_wins_across_ifds() { // Cameras carry both a tiny thumbnail and a screen-sized preview; the // larger downscales better. let mut v = tiff( &[ (tag::JPEG_INTERCHANGE_FORMAT, 4, 1, 1000), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 8_000), ], 200, ); // A second IFD at offset 200 with a much larger preview. let second = 200usize; v[second..second + 2].copy_from_slice(&2u16.to_le_bytes()); for (i, (tag, kind, count, value)) in [ (tag::JPEG_INTERCHANGE_FORMAT, 4u16, 1u32, 20_000u32), (tag::JPEG_INTERCHANGE_FORMAT_LENGTH, 4, 1, 1_200_000), ] .iter() .enumerate() { let e = second + 2 + i * 12; v[e..e + 2].copy_from_slice(&tag.to_le_bytes()); v[e + 2..e + 4].copy_from_slice(&kind.to_le_bytes()); v[e + 4..e + 8].copy_from_slice(&count.to_le_bytes()); v[e + 8..e + 12].copy_from_slice(&value.to_le_bytes()); } let loc = locate_preview(&v, 25_000_000).expect("a preview"); assert_eq!(loc.len(), 1_200_000, "should pick the larger"); } #[test] fn a_self_referential_ifd_chain_terminates() { // A malformed file pointing an IFD at itself must not hang the app. let h = tiff(&[(tag::IMAGE_WIDTH, 4, 1, 100)], 8); let _ = locate_preview(&h, 1_000_000); } #[test] fn an_absurd_entry_count_is_rejected_not_allocated() { let mut v = Vec::new(); v.extend_from_slice(b"II"); v.extend_from_slice(&42u16.to_le_bytes()); v.extend_from_slice(&8u32.to_le_bytes()); v.extend_from_slice(&60000u16.to_le_bytes()); // claims 60k entries v.resize(1024, 0); assert!(locate_preview(&v, 1_000_000).is_none()); } #[test] fn non_tiff_input_declines_cleanly() { assert!(locate_preview(b"not a tiff at all", 1000).is_none()); assert!(locate_preview(&[], 1000).is_none()); // CR3 is ISO-BMFF, not TIFF — declining is correct. assert!(locate_preview(b"\0\0\0\x18ftypcrx ", 1000).is_none()); } /// Build a JPEG carrying an APP1 EXIF block with the given IFD entries. fn jpeg_with_exif(entries: &[(u16, u16, u32, u32)], extra: &[u8]) -> Vec { let mut tiff = Vec::new(); tiff.extend_from_slice(b"II"); tiff.extend_from_slice(&42u16.to_le_bytes()); tiff.extend_from_slice(&8u32.to_le_bytes()); tiff.extend_from_slice(&(entries.len() as u16).to_le_bytes()); for (tag, kind, count, value) in entries { tiff.extend_from_slice(&tag.to_le_bytes()); tiff.extend_from_slice(&kind.to_le_bytes()); tiff.extend_from_slice(&count.to_le_bytes()); tiff.extend_from_slice(&value.to_le_bytes()); } tiff.extend_from_slice(&0u32.to_le_bytes()); tiff.extend_from_slice(extra); let payload_len = (tiff.len() + 6 + 2) as u16; let mut out = vec![0xFF, 0xD8, 0xFF, 0xE1]; out.extend_from_slice(&payload_len.to_be_bytes()); out.extend_from_slice(b"Exif\0\0"); out.extend_from_slice(&tiff); out } #[test] fn jpeg_exif_yields_a_capture_time() { // rawler decodes no JPEG at all, so without this path every JPEG in a // library is undated — 5,712 scanned frames in the reference library. let date = b"2013:06:28 23:32:54\0"; let mut extra = Vec::new(); let date_offset = 8 + 2 + 12 + 4; extra.extend_from_slice(date); let jpeg = jpeg_with_exif( &[( exif_tag::DATE_TIME_ORIGINAL, 2, date.len() as u32, date_offset as u32, )], &extra, ); let md = jpeg_metadata(&jpeg).expect("EXIF"); assert_eq!(md.captured_at, Some(1_372_462_374)); } #[test] fn a_jpeg_without_exif_reports_no_segment() { assert!(jpeg_metadata(&[0xFF, 0xD8, 0xFF, 0xDA, 0, 2]).is_err()); assert!(jpeg_metadata(b"not a jpeg").is_err()); } #[test] fn ascii_values_lose_their_nul_padding() { // A model name with a trailing NUL compares unequal to the same name // without one, which would split one camera into two in any grouping. let model = b"CanoScan 9000F Mark II\0"; let mut extra = Vec::new(); let off = 8 + 2 + 12 + 4; extra.extend_from_slice(model); let jpeg = jpeg_with_exif( &[(exif_tag::MODEL, 2, model.len() as u32, off as u32)], &extra, ); let md = jpeg_metadata(&jpeg).expect("EXIF"); assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II")); } /// A JPEG whose EXIF carries a GPS directory, built by hand. /// /// The offsets are computed rather than written out because the whole /// point of the exercise is that they are consistent: a GPS directory is /// three levels of indirection — the main IFD points at it, and each /// coordinate points at three rationals somewhere else again. /// /// `lat`/`lon` are `(degrees, minutes, hundredths-of-a-second)` and the /// refs are the hemisphere letters, exactly as a camera writes them. fn jpeg_with_gps( lat: (u32, u32, u32), lat_ref: u8, lon: (u32, u32, u32), lon_ref: u8, altitude: Option<(u32, u8)>, ) -> Vec { // One entry in IFD0 (the GPS pointer), so the blob after it starts at // the header (8) + count (2) + one entry (12) + the next-IFD link (4). const GPS_IFD: u32 = 8 + 2 + 12 + 4; let entries: u32 = if altitude.is_some() { 6 } else { 5 }; // Where the rationals live: after the GPS directory itself. let heap = GPS_IFD + 2 + entries * 12 + 4; let mut gps: Vec<(u16, u16, u32, u32)> = vec![ (gps_tag::LATITUDE_REF, 2, 2, u32::from(lat_ref)), (gps_tag::LATITUDE, 5, 3, heap), (gps_tag::LONGITUDE_REF, 2, 2, u32::from(lon_ref)), (gps_tag::LONGITUDE, 5, 3, heap + 24), ]; if let Some((_, reference)) = altitude { gps.push((gps_tag::ALTITUDE_REF, 1, 1, u32::from(reference))); gps.push((gps_tag::ALTITUDE, 5, 1, heap + 48)); } let mut extra = Vec::new(); extra.extend_from_slice(&(gps.len() as u16).to_le_bytes()); for (tag, kind, count, value) in &gps { extra.extend_from_slice(&tag.to_le_bytes()); extra.extend_from_slice(&kind.to_le_bytes()); extra.extend_from_slice(&count.to_le_bytes()); extra.extend_from_slice(&value.to_le_bytes()); } extra.extend_from_slice(&0u32.to_le_bytes()); let mut rational = |n: u32, d: u32| { extra.extend_from_slice(&n.to_le_bytes()); extra.extend_from_slice(&d.to_le_bytes()); }; for (n, d) in [(lat.0, 1), (lat.1, 1), (lat.2, 100)] { rational(n, d); } for (n, d) in [(lon.0, 1), (lon.1, 1), (lon.2, 100)] { rational(n, d); } if let Some((metres, _)) = altitude { rational(metres, 1); } jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra) } #[test] fn a_gps_directory_becomes_signed_degrees() { // TRACES: FR-EXP-8 // 48° 51' 29.52" N, 2° 17' 40.2" E — the Eiffel Tower. Reading this // correctly is what makes stripping it meaningful: a parser that // silently failed would make the export path look private when it was // only ignorant. let jpeg = jpeg_with_gps((48, 51, 2952), b'N', (2, 17, 4020), b'E', Some((35, 0))); let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); assert!((loc.latitude - 48.858200).abs() < 1e-5, "{loc:?}"); assert!((loc.longitude - 2.294500).abs() < 1e-5, "{loc:?}"); assert_eq!(loc.altitude, Some(35.0)); } #[test] fn the_hemisphere_letters_are_applied_not_ignored() { // The failure this catches puts Sydney in the North Atlantic: the // magnitudes are identical and only the letters differ. let jpeg = jpeg_with_gps((33, 51, 3500), b'S', (151, 12, 3600), b'E', None); let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); assert!(loc.latitude < 0.0, "southern latitude must be negative"); assert!(loc.longitude > 0.0, "eastern longitude must be positive"); assert!(loc.altitude.is_none()); } #[test] fn a_below_sea_level_altitude_keeps_its_sign() { // Reference 1 means below sea level; the altitude itself is unsigned, // so dropping the reference turns the Dead Sea into a hilltop. let jpeg = jpeg_with_gps((31, 33, 0), b'N', (35, 28, 0), b'E', Some((430, 1))); let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); assert_eq!(loc.altitude, Some(-430.0)); } #[test] fn a_latitude_with_no_longitude_is_not_half_a_position() { // Half a coordinate written into a file would be a pin on the // Greenwich meridian, which is worse than no pin. const GPS_IFD: u32 = 8 + 2 + 12 + 4; let heap = GPS_IFD + 2 + 12 + 4; let mut extra = Vec::new(); extra.extend_from_slice(&1u16.to_le_bytes()); { let (tag, kind, count, value) = (gps_tag::LATITUDE, 5u16, 3u32, heap); extra.extend_from_slice(&tag.to_le_bytes()); extra.extend_from_slice(&kind.to_le_bytes()); extra.extend_from_slice(&count.to_le_bytes()); extra.extend_from_slice(&value.to_le_bytes()); } extra.extend_from_slice(&0u32.to_le_bytes()); for (n, d) in [(48u32, 1u32), (51, 1), (2952, 100)] { extra.extend_from_slice(&n.to_le_bytes()); extra.extend_from_slice(&d.to_le_bytes()); } let jpeg = jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra); assert!(jpeg_metadata(&jpeg).expect("EXIF").location.is_none()); } #[test] fn the_byline_and_the_rights_statement_are_read() { // TRACES: FR-EXP-8 // Both live in the main IFD, and both are the half of FR-EXP-8 that // must *survive* an export rather than be removed by it. let artist = b"Duncan Tourolle\0"; let copyright = b"(c) 2026 Duncan Tourolle. All rights reserved.\0"; let base = 8 + 2 + 2 * 12 + 4; let mut extra = Vec::new(); extra.extend_from_slice(artist); extra.extend_from_slice(copyright); let jpeg = jpeg_with_exif( &[ (exif_tag::ARTIST, 2, artist.len() as u32, base), ( exif_tag::COPYRIGHT, 2, copyright.len() as u32, base + artist.len() as u32, ), ], &extra, ); let md = jpeg_metadata(&jpeg).expect("EXIF"); assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle")); assert_eq!( md.copyright.as_deref(), Some("(c) 2026 Duncan Tourolle. All rights reserved.") ); } #[test] fn exposure_rationals_are_read_from_a_jpeg() { // rawler fills these for a RAW; a camera JPEG has nothing behind it // but this reader, and an export that lost the shutter speed lost it // for good. let base = 8 + 2 + 3 * 12 + 4; let mut extra = Vec::new(); for (n, d) in [(1u32, 250u32), (28, 10), (850, 10)] { extra.extend_from_slice(&n.to_le_bytes()); extra.extend_from_slice(&d.to_le_bytes()); } let jpeg = jpeg_with_exif( &[ (exif_tag::EXPOSURE_TIME, 5, 1, base), (exif_tag::FNUMBER, 5, 1, base + 8), (exif_tag::FOCAL_LENGTH, 5, 1, base + 16), ], &extra, ); let md = jpeg_metadata(&jpeg).expect("EXIF"); assert_eq!(md.shutter, Some(1.0 / 250.0)); assert_eq!(md.aperture, Some(2.8)); assert_eq!(md.focal_length, Some(85.0)); } #[test] fn a_zero_denominator_is_no_reading_rather_than_an_infinity() { // Bodies do write `0/0` into a slot they had nothing for, and `inf` // seconds carried into an exported file is worse than a gap. let base = 8 + 2 + 12 + 4; let mut extra = Vec::new(); extra.extend_from_slice(&0u32.to_le_bytes()); extra.extend_from_slice(&0u32.to_le_bytes()); let jpeg = jpeg_with_exif(&[(exif_tag::EXPOSURE_TIME, 5, 1, base)], &extra); assert_eq!(jpeg_metadata(&jpeg).expect("EXIF").shutter, None); } #[test] fn a_marker_walk_does_not_run_off_a_truncated_file() { // Untrusted input (NFR-SEC-1): a length field claiming more than the // file holds must not read past the end. let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE1]; jpeg.extend_from_slice(&60000u16.to_be_bytes()); jpeg.extend_from_slice(b"Exif\0\0"); assert!(jpeg_metadata(&jpeg).is_err()); } #[test] fn truncated_jpegs_are_detected() { // The bug this whole module exists to fix: a short read decodes to a // partial image rather than failing, so it must be caught by // inspection. let mut complete = vec![0xFF, 0xD8]; complete.extend_from_slice(&[0x00; 200]); complete.extend_from_slice(&[0xFF, 0xD9]); assert!(is_complete_jpeg(&complete)); let truncated = &complete[..complete.len() - 2]; assert!(!is_complete_jpeg(truncated)); } #[test] fn non_jpeg_bytes_are_not_complete_jpegs() { assert!(!is_complete_jpeg(&[])); assert!(!is_complete_jpeg(&[0xFF, 0xD9])); assert!(!is_complete_jpeg(b"PNG\r\n")); } } // --------------------------------------------------------------------------- // Bad pixels, as the file itself reports them // --------------------------------------------------------------------------- /// A photosite the camera says is defective. /// /// Sensor coordinates, before any crop to the active area — which is what the /// DNG specification defines them against, and what the mosaic is indexed by /// at the point the correction has to run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BadPixel { pub x: u32, pub y: u32, } /// A defective *column* or *row*, which cameras report far more often than /// they report scattered points: a failed readout line takes out a whole file /// of photosites at once. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BadLine { Column(u32), Row(u32), } /// What a file says is wrong with its own sensor. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Defects { pub pixels: Vec, pub lines: Vec, } impl Defects { pub fn is_empty(&self) -> bool { self.pixels.is_empty() && self.lines.is_empty() } } /// DNG opcode ids. Only the two that describe defects are read; the rest of /// `OpcodeList1` is warp and vignette correction that belongs to other stages. const OP_FIX_BAD_PIXELS_CONSTANT: u32 = 4; const OP_FIX_BAD_PIXELS_LIST: u32 = 5; /// TRACES: FR-RAW-3 /// Read the defect map the file carries, if it carries one. /// /// # Why this is parsed here and not taken from the decoder /// /// `rawler` knows these tags exist — it copies them through when *writing* a /// DNG — but it never decodes them, and its `dng_tags` map is only ever filled /// by callers. So the bytes have to be read from the IFD directly, which this /// module was already walking for previews. /// /// # Coverage /// /// This is what the *file* claims, which is not the same as what is wrong with /// the sensor. DNGs written by cameras that do their own mapping carry it; /// most conversions from a proprietary raw do not, and no CR2 or scanner TIFF /// has it at all. An empty result is therefore the normal case rather than a /// failure, and means only that this source had nothing to say. /// /// Opcode payloads are **always big-endian**, whatever the TIFF's own byte /// order — the specification fixes it, and a file whose IFDs are little-endian /// still writes its opcodes the other way round. Reading these with the /// container's endianness is the mistake this comment exists to prevent. pub fn defects(tiff_data: &[u8]) -> Defects { let mut found = Defects::default(); let Some(tiff) = TiffReader::new(tiff_data) else { return found; }; for offset in tiff.ifd_offsets() { let Some(entries) = tiff.read_ifd(offset) else { continue; }; let Some(entry) = entries.iter().find(|e| e.tag == tag::OPCODE_LIST_1) else { continue; }; let Some(bytes) = tiff.value_bytes(entry) else { continue; }; read_opcode_list(bytes, &mut found); } found } /// Walk an opcode stream, collecting the defect opcodes and stepping over the /// rest. /// /// Each opcode declares its own byte count, which is what makes it safe to /// skip one this build does not implement rather than abandoning the list — /// and lists mixing a warp with a defect map are ordinary. fn read_opcode_list(bytes: &[u8], out: &mut Defects) { let Some(count) = be_u32(bytes, 0) else { return; }; // A sensor has a handful of opcodes, not thousands. A huge count is a // corrupt or hostile file. if count > 256 { return; } let mut at = 4usize; for _ in 0..count { // id, version, flags, byte count — four u32s of header. let (Some(id), Some(size)) = (be_u32(bytes, at), be_u32(bytes, at + 12)) else { return; }; let payload = at + 16; let Some(end) = payload.checked_add(size as usize) else { return; }; let Some(body) = bytes.get(payload..end) else { return; }; match id { OP_FIX_BAD_PIXELS_CONSTANT => read_bad_pixels_constant(body, out), OP_FIX_BAD_PIXELS_LIST => read_bad_pixels_list(body, out), // Warp, vignette, deltas — other stages' business. _ => {} } at = end; } } /// `FixBadPixelsConstant`: every photosite holding `constant` is dead. /// /// Not expanded into coordinates here, and it cannot be: the value names a /// *condition*, and which photosites meet it is only knowable once the mosaic /// is in hand. Recorded as nothing for now — the correction stage will need /// the constant itself, not a list. fn read_bad_pixels_constant(_body: &[u8], _out: &mut Defects) { // Deliberately empty until the raw-domain stage exists to consume it. A // half-implementation that guessed at coordinates would be worse than the // absence, because it would look like it worked. } /// `FixBadPixelsList`: explicit coordinates, and whole dead rows and columns. /// /// Layout after the two-field spacing header: a point count, a rect count, /// then that many points as (row, column) and that many rects as /// (top, left, bottom, right). Rows and columns are *rectangles* one unit /// wide in the specification, which is why a single "bad column" arrives here /// as a rect rather than as an index. fn read_bad_pixels_list(body: &[u8], out: &mut Defects) { // bayerPhase, then the two counts. let (Some(points), Some(rects)) = (be_u32(body, 4), be_u32(body, 8)) else { return; }; if points > 100_000 || rects > 10_000 { return; } let mut at = 12usize; for _ in 0..points { let (Some(row), Some(col)) = (be_u32(body, at), be_u32(body, at + 4)) else { return; }; out.pixels.push(BadPixel { x: col, y: row }); at += 8; } for _ in 0..rects { let (Some(top), Some(left), Some(bottom), Some(right)) = ( be_u32(body, at), be_u32(body, at + 4), be_u32(body, at + 8), be_u32(body, at + 12), ) else { return; }; // One unit wide in either direction is a line; anything else is an // area, which no camera has been observed to report and which this // does not invent a meaning for. if right == left + 1 { out.lines.push(BadLine::Column(left)); } else if bottom == top + 1 { out.lines.push(BadLine::Row(top)); } at += 16; } } /// Opcode payloads are big-endian regardless of the container's byte order. fn be_u32(bytes: &[u8], at: usize) -> Option { let slice = bytes.get(at..at.checked_add(4)?)?; Some(u32::from_be_bytes([slice[0], slice[1], slice[2], slice[3]])) } #[cfg(test)] mod defect_tests { use super::*; /// One opcode, framed as the specification frames it: id, version, flags, /// payload length, payload. All big-endian, whatever the container is. fn opcode(id: u32, body: &[u8]) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&id.to_be_bytes()); out.extend_from_slice(&1u32.to_be_bytes()); // version out.extend_from_slice(&0u32.to_be_bytes()); // flags out.extend_from_slice(&(body.len() as u32).to_be_bytes()); out.extend_from_slice(body); out } fn opcode_list(opcodes: &[Vec]) -> Vec { let mut out = (opcodes.len() as u32).to_be_bytes().to_vec(); for o in opcodes { out.extend_from_slice(o); } out } /// A `FixBadPixelsList` payload: bayer phase, then points, then rects. fn bad_pixel_list(points: &[(u32, u32)], rects: &[(u32, u32, u32, u32)]) -> Vec { let mut out = 0u32.to_be_bytes().to_vec(); // bayerPhase out.extend_from_slice(&(points.len() as u32).to_be_bytes()); out.extend_from_slice(&(rects.len() as u32).to_be_bytes()); for (row, col) in points { out.extend_from_slice(&row.to_be_bytes()); out.extend_from_slice(&col.to_be_bytes()); } for (t, l, b, r) in rects { out.extend_from_slice(&t.to_be_bytes()); out.extend_from_slice(&l.to_be_bytes()); out.extend_from_slice(&b.to_be_bytes()); out.extend_from_slice(&r.to_be_bytes()); } out } #[test] fn a_list_of_points_is_read_as_sensor_coordinates() { // The specification orders a point (row, column). Reading it the other // way round is the mistake that produces a correction which lands on // the wrong photosite — and on a square crop, silently. let list = bad_pixel_list(&[(7, 3), (100, 200)], &[]); let mut out = Defects::default(); read_opcode_list(&opcode_list(&[opcode(5, &list)]), &mut out); assert_eq!( out.pixels, vec![BadPixel { x: 3, y: 7 }, BadPixel { x: 200, y: 100 }] ); assert!(out.lines.is_empty()); } #[test] fn a_one_wide_rectangle_is_a_dead_column_and_a_one_tall_one_is_a_row() { // Which is how a failed readout line is reported: the specification has // no "column" type, only a rectangle that happens to be one wide. let list = bad_pixel_list(&[], &[(0, 42, 4000, 43), (17, 0, 18, 6000)]); let mut out = Defects::default(); read_opcode_list(&opcode_list(&[opcode(5, &list)]), &mut out); assert_eq!(out.lines, vec![BadLine::Column(42), BadLine::Row(17)]); } #[test] fn an_opcode_this_build_does_not_implement_is_stepped_over() { // Lists mixing a warp with a defect map are ordinary, and each opcode // declares its own length precisely so an unknown one can be skipped. // Abandoning the list at the first unfamiliar id would lose the map // whenever the camera also corrected its lens. let warp = opcode(1, &[0xAB; 40]); let list = opcode(5, &bad_pixel_list(&[(1, 2)], &[])); let mut out = Defects::default(); read_opcode_list(&opcode_list(&[warp, list]), &mut out); assert_eq!(out.pixels, vec![BadPixel { x: 2, y: 1 }]); } #[test] fn a_truncated_list_yields_what_was_read_rather_than_a_panic() { // Files are damaged in transit and cameras write bugs. Nothing here // may index past the end. let full = opcode_list(&[opcode(5, &bad_pixel_list(&[(1, 2), (3, 4)], &[]))]); for cut in 0..full.len() { let mut out = Defects::default(); read_opcode_list(&full[..cut], &mut out); } } #[test] fn a_file_with_no_opcodes_reports_no_defects() { // The normal case, and not a failure: a CR2 or a scanner TIFF has // nothing to say about its own sensor. assert!(defects(&[]).is_empty()); assert!(defects(b"II*\0\x08\0\0\0\0\0").is_empty()); } #[test] fn an_absurd_opcode_count_is_refused() { // A corrupt length field must not become an allocation. let mut bytes = u32::MAX.to_be_bytes().to_vec(); bytes.extend_from_slice(&[0u8; 32]); let mut out = Defects::default(); read_opcode_list(&bytes, &mut out); assert!(out.is_empty()); } }