Merge PR #1176 (rebased)

This commit is contained in:
Brandon Thomas
2026-10-15 00:52:31 -04:00
40 changed files with 2644 additions and 161 deletions
+1
View File
@@ -165,6 +165,7 @@ mod tests {
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
lens_metadata: Default::default(),
}
}
+1
View File
@@ -289,6 +289,7 @@ mod tests {
linearized: false,
opcodes: Default::default(),
metadata: Default::default(),
lens_metadata: Default::default(),
}
}
+1
View File
@@ -340,6 +340,7 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R
linearized,
opcodes,
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -540,6 +540,7 @@ pub fn write_rendered_dng(
linearized: false,
opcodes: crate::OpcodeLists::default(),
metadata: metadata.clone(),
lens_metadata: Default::default(),
};
let order = ByteOrder::Little;
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
+187
View File
@@ -0,0 +1,187 @@
//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft
//! applies.
//!
//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only
//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by
//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere
//! presence of a table. Everything else here is metadata for diagnostics and later validation work.
use crate::Rect;
use serde::{Deserialize, Serialize};
/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or
/// whose tables are read straight into opcodes.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LensMetadata {
/// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their
/// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them.
#[serde(default)]
pub sony: Option<SonyLensCorrections>,
}
/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table.
///
/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032`
/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration),
/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting
/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings
/// in every file inspected.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonyLensCorrections {
/// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not
/// established. Opaque signed data.
pub shading: SonyComponent<[i16; 16]>,
/// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue,
/// relative to green; that is not validated here.
pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>,
/// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation).
pub distortion: SonyComponent<[i16; 16]>,
/// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare.
pub sr2_read: bool,
/// The framing and coding the tables have to be interpreted against.
pub geometry: SonyLensGeometry,
}
/// One correction component: the camera's setting, its table, and whether LightCraft applies it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonyComponent<T> {
pub setting: SonyField<SonySetting>,
/// The table's samples after the leading sample count, which must equal their number.
pub table: SonyField<T>,
pub status: SonyLensStatus,
}
/// One field read from the raw IFD and from the `SR2SubIFD`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonyField<T> {
/// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD
/// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is
/// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes
/// the image parsed (its first occurrence, within the reader's limits).
pub value: Option<T>,
pub source: Option<SonySource>,
pub raw_ifd: SonyCopy,
pub sr2: SonyCopy,
/// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ.
pub copies_agree: Option<bool>,
/// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used,
/// as the TIFF reader does (the raw IFD's: the first one whose values it could read).
#[serde(default)]
pub raw_ifd_repeated: bool,
#[serde(default)]
pub sr2_repeated: bool,
}
/// Where a value was read.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonySource {
/// The plain raw image IFD.
RawIfd,
/// The encrypted `SR2SubIFD`.
Sr2SubIfd,
}
/// How one copy of a field was found.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonyCopy {
/// Not in that directory (or the `SR2SubIFD` could not be read).
Absent,
/// Present with exactly the supported type and layout.
Valid,
/// Present, but refused.
Rejected(SonyFieldError),
}
/// Why a present field was refused.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonyFieldError {
/// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting.
FieldType(u16),
/// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting).
Count(u64),
/// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the
/// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting.
CountPrefix(i16),
/// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`).
OutsideData,
/// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a
/// total value budget), so the image doesn't use it either.
PastReaderLimits,
/// A setting stored as a negative SSHORT.
NegativeSetting(i16),
}
/// A camera correction setting: the stored number and its documented meaning.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SonySetting {
pub value: u16,
pub meaning: SonySettingMeaning,
}
/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole;
/// they are not read as bit masks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonySettingMeaning {
/// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent.
Off,
/// Shading 257 or 272, CA 1, distortion 1.
Auto,
/// Distortion 17: Auto, fixed by the lens.
ForcedAuto,
/// "No correction params available": shading 511, CA 255, distortion 255.
Unavailable,
/// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off.
Unknown(u16),
}
/// Whether a component is applied, and if not, why.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SonyLensStatus {
/// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing).
Applied,
/// No usable table.
NoTable,
/// Decoded only: no independent validation covers this component's rendering semantics on any body (shading,
/// lateral CA).
NotValidatedComponent,
/// Decoded only: the component is validated on other camera models, not on this one.
NotValidatedModel,
/// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a
/// table found only in the `SR2SubIFD`.
NotValidatedMode,
/// Validated scope, but the table is all zeros: a valid no-op, nothing to apply.
Neutral,
/// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate
/// crop, or a fitted map that misses the table or folds).
Rejected,
}
/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SonyLensGeometry {
/// The stored raw frame, including any padding (lossless L files are padded).
pub storage_width: usize,
pub storage_height: usize,
/// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded.
pub sony_crop: Option<Rect>,
/// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded.
pub default_crop: Option<Rect>,
/// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size).
pub image_crop: Rect,
/// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when
/// the file has one, else the image crop. Clipped to the frame.
pub reference: Rect,
/// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6).
pub compression: u16,
/// Downsized lossless M/S data (linear YCbCr, not sensor CFA).
pub linear_ycbcr: bool,
/// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated.
pub native_3_2: bool,
}
impl<T> SonyField<T> {
/// Whether either directory holds the tag (well-formed or not).
pub fn is_present(&self) -> bool {
self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent
}
}
+13
View File
@@ -35,6 +35,7 @@ pub mod highlight;
#[cfg(feature = "jxl")]
mod jxl;
pub mod kelvin_presets;
pub mod lens_metadata;
pub mod ljpeg;
pub(crate) mod llvc;
pub mod opcodes;
@@ -51,6 +52,7 @@ mod wb_estimate;
pub use demosaic::{Method, demosaic};
pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng};
pub use lens_metadata::LensMetadata;
pub use lightcraft_color::Mat3;
pub use lightcraft_geom::Orientation;
pub use lightcraft_meta::Metadata;
@@ -430,6 +432,9 @@ pub struct RawInfo {
pub wb_multipliers: Option<[f32; 3]>,
pub opcodes: OpcodeLists,
pub metadata: Metadata,
/// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]).
#[serde(default)]
pub lens_metadata: LensMetadata,
}
impl RawInfo {
@@ -663,6 +668,10 @@ pub struct RawImage {
pub linearized: bool,
pub opcodes: OpcodeLists,
pub metadata: Metadata,
/// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only
/// validated components are in `opcodes`.
#[serde(default)]
pub lens_metadata: LensMetadata,
}
/// Black-subtracted, white-normalised data covering the active area.
@@ -694,6 +703,7 @@ impl RawImage {
wb_multipliers: self.wb_multipliers,
opcodes: self.opcodes.clone(),
metadata: self.metadata.clone(),
lens_metadata: self.lens_metadata.clone(),
}
}
@@ -713,6 +723,7 @@ impl RawImage {
wb_multipliers: self.wb_multipliers,
opcodes: self.opcodes,
metadata: self.metadata,
lens_metadata: self.lens_metadata,
}
}
@@ -880,6 +891,7 @@ mod tests {
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
lens_metadata: Default::default(),
};
let direct = raw.develop(Method::Bilinear).unwrap();
let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear));
@@ -920,6 +932,7 @@ mod tests {
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
lens_metadata: Default::default(),
};
assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image");
assert!(raw.normalized().is_err());
+1
View File
@@ -137,6 +137,7 @@ mod tests {
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Metadata::default(),
lens_metadata: Default::default(),
}
}
+46 -22
View File
@@ -45,8 +45,9 @@
//! through the generic TIFF path.
use super::arw6;
use crate::lens_metadata::SonyLensGeometry;
use crate::tiffraw::{Packing, check_image, read_image_in};
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg};
use lightcraft_geom::Orientation;
use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes};
use lightcraft_tiff::tags::{self as t, photometric};
@@ -553,6 +554,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option<Rect> {
None
}
/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`.
fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option<Rect> {
let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None };
let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None };
let n = |v: u64| usize::try_from(v).ok();
Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?))
}
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]).
///
@@ -749,6 +758,39 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
metadata.height = Some(crop.height as u32);
// The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has
// one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and
// the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area
// coordinates, so it stays on the optical centre whichever crop frames the image.
let reference = dng_default_crop(raw, w, h).unwrap_or(crop);
let geometry = SonyLensGeometry {
storage_width: w,
storage_height: h,
sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE),
default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE),
image_crop: crop,
reference,
compression: info.compression,
linear_ycbcr: linear_rgb,
native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference),
};
// decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by
// reading only three `SR2Private` entries (see `sr2::SubIfd::read`)
let (mut sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry);
// The ILCE-7RM6's tables follow other conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]);
// its allowlist and `arw_lens`'s are disjoint, so at most one of them produces an opcode. Both the image framing
// and the table geometry must stay within the validated 3:2 scope.
let rm6: Vec<crate::Opcode> = if linear_rgb || !super::arw_lens::is_3_2(crop) {
Vec::new()
} else {
super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference)
};
if !rm6.is_empty()
&& let Some(record) = sony_lens.as_mut()
{
// distortion only: its lateral CA planes are off (see `arw_lens_rm6::lens_opcodes`)
record.distortion.status = crate::lens_metadata::SonyLensStatus::Applied;
}
let img = RawImage {
format: RawFormat::Arw,
width: w,
@@ -768,28 +810,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// makes a neutral surface magenta. WB edits remain relative to this as-shot RGB.
wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb },
linearized: false,
// The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A
// it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was
// validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so
// it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other
// conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are
// disjoint, so at most one of them produces an opcode.
opcodes: OpcodeLists {
// DefaultCrop can retain native geometry while Sony's tags select an aspect crop.
// Both the image framing and the table geometry must stay within the validated 3:2 scope.
list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 {
Vec::new()
} else {
let geometry = dng_default_crop(raw, w, h).unwrap_or(crop);
let active = Rect::new(0, 0, w, h);
super::arw_lens::distortion(&model, raw, active, geometry)
.into_iter()
.chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry))
.collect()
},
..Default::default()
},
// only validated components become opcodes (see `arw_lens`, and `arw_lens_rm6` for the ILCE-7RM6)
opcodes: OpcodeLists { list3: warp.into_iter().chain(rm6).collect(), ..Default::default() },
metadata,
lens_metadata: LensMetadata { sony: sony_lens },
};
img.validate_for(mode)?;
Ok(img)
+1055 -46
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -251,6 +251,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -393,6 +393,7 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -255,6 +255,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
image.validate_for(mode)?;
Ok(image)
+1
View File
@@ -123,6 +123,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -117,6 +117,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -121,6 +121,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -296,6 +296,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
..Default::default()
},
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -336,6 +336,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -337,6 +337,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -214,6 +214,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() },
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+1
View File
@@ -616,6 +616,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() },
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)
+264 -6
View File
@@ -45,7 +45,7 @@
//! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry
//! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`).
use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t};
use lightcraft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t};
/// `SR2Private` tags.
const SUBIFD_OFFSET: u16 = 0x7200;
@@ -136,11 +136,8 @@ impl SubIfd {
/// [`MAX_LEN`].
pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option<Self> {
let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?;
let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() };
let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?;
let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?;
let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?;
let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?;
let fields = private_fields(bytes, u64::from(at), order);
let (start, len, key) = (fields.offset?, fields.length?, fields.key?);
if len > MAX_LEN {
return None;
}
@@ -165,6 +162,13 @@ impl SubIfd {
self.order
}
/// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its
/// value offsets count from: for readers that check each entry's exact type and count themselves
/// (`arw_lens`).
pub(crate) fn block(&self) -> (&[u8], usize) {
(&self.plain, self.start)
}
/// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type
/// or its values lie outside the block.
pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> {
@@ -193,9 +197,69 @@ impl SubIfd {
}
}
/// The `SR2Private` fields that locate the block.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PrivateFields {
offset: Option<usize>,
length: Option<usize>,
key: Option<[u8; 4]>,
}
/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would
/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the
/// directory. The selection uses the reader's own rules: its directory layout and entry limit
/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first
/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle
/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries,
/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked.
fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields {
let opts = ParseOptions::default();
let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() };
let mut budget = opts.budget_for(bytes.len());
let (mut offset, mut length, mut key) = (None, None, None);
for p in dir.positions() {
let Some(span) = entry_span(bytes, p, order, false, 0) else { continue };
if span.len > budget {
continue;
}
budget -= span.len;
match span.tag {
SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)),
SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)),
SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)),
_ => {}
}
}
PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() }
}
/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`.
fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<usize> {
if span.count == 0 {
return None;
}
let start = usize::try_from(span.start).ok()?;
let first = bytes.get(start..start.checked_add(span.field_type.size())?)?;
usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok()
}
/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five
/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which
/// decoding never makes shorter.
fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> {
let start = usize::try_from(span.start).ok()?;
let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?;
<[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok()
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// TIFF field types.
const BYTE: u16 = 1;
const LONG: u16 = 4;
const UNDEFINED: u16 = 7;
const IFD: u16 = 13;
use lightcraft_tiff::ByteOrder::{Big, Little};
/// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values
@@ -345,6 +409,200 @@ pub(crate) mod tests {
}
}
/// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0.
fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec<u8> {
let mut out = Vec::new();
order.put_u16(&mut out, entries.len() as u16);
for &(tag, kind, count, value) in entries {
order.put_u16(&mut out, tag);
order.put_u16(&mut out, kind);
order.put_u32(&mut out, count);
out.extend(value);
}
order.put_u32(&mut out, 0);
out
}
/// `(offset, length, key)` as one of the readers selects them.
type Selected = (Option<usize>, Option<usize>, Option<[u8; 4]>);
/// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the
/// directory's `u64` and `bytes` lookups.
fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected {
let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() };
let Ok((private, _)) = lightcraft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) };
let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok());
(n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok()))
}
/// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and
/// unreadable, unusual or valid first occurrences of each field ahead of a valid second one.
fn differential_cases(order: ByteOrder) -> Vec<(String, Vec<u8>)> {
let w = |v: u32| {
let mut b = Vec::new();
order.put_u32(&mut b, v);
[b[0], b[1], b[2], b[3]]
};
let h = |a: u16, b: u16| {
let mut out = Vec::new();
order.put_u16(&mut out, a);
order.put_u16(&mut out, b);
[out[0], out[1], out[2], out[3]]
};
let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)];
let mut cases = Vec::new();
for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] {
let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers];
entries.extend(valid);
cases.push((format!("{fillers} fillers"), private(&entries, order)));
}
// a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory
let tail_at = 2 + 12 * 4 + 4;
let mut tail: Vec<u8> = (1..=32).collect();
tail[4..8].copy_from_slice(b"\x11\x22\x33\x44");
tail[8] = 0;
let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![
("unknown type 0", 0, 1, w(8)),
("unknown type 14", 14, 1, w(8)),
("unknown type 0xffff", 0xffff, 1, w(8)),
("LONG[2] past the file", LONG, 2, w(u32::MAX)),
("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)),
("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)),
("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)),
("LONG[0]", LONG, 0, w(0)),
("LONG[2] in the file", LONG, 2, w(tail_at as u32)),
("SHORT[1]", SHORT, 1, h(9, 0)),
("SHORT[2] inline", SHORT, 2, h(9, 10)),
("BYTE[3]", BYTE, 3, [5, 6, 7, 0]),
("SSHORT -1", SSHORT, 1, h(0xffff, 0)),
("SSHORT 5", SSHORT, 1, h(5, 0)),
("SLONG -1", 9, 1, w(u32::MAX)),
("LONG8 at a pointer", 16, 1, w(tail_at as u32)),
("RATIONAL", 5, 1, w(tail_at as u32)),
("ASCII", 2, 4, *b"abc\0"),
("IFD", IFD, 1, w(4096)),
];
for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] {
for (what, kind, count, field) in &numeric {
let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] };
let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order);
d.extend(&tail);
cases.push((format!("{tag:#x}: first {what}"), d));
}
}
let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![
("unknown type", 0, 4, KEY),
("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)),
("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)),
("UNDEFINED[3]", UNDEFINED, 3, KEY),
("UNDEFINED[0]", UNDEFINED, 0, KEY),
("BYTE[4]", BYTE, 4, [1, 2, 3, 4]),
("SBYTE[4]", 6, 4, KEY),
("ASCII[4]", 2, 4, KEY),
("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]),
("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]),
("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]),
("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)),
("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)),
("LONG[1]", LONG, 1, KEY),
];
for (what, kind, count, field) in &keys {
let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order);
d.extend(&tail);
cases.push((format!("key: first {what}"), d));
}
// the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data
for aliases in [23u32, 24] {
let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize];
entries.extend(valid);
let mut d = private(&entries, order);
d.resize(1 << 16, 0);
cases.push((format!("{aliases} entries using the value budget"), d));
}
// no entries; more entries declared than the data holds
cases.push(("empty directory".into(), private(&[], order)));
let mut long = private(&valid, order);
long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() });
long.truncate(2 + 12 * 3);
cases.push(("10 entries declared, 3 present".into(), long));
cases
}
#[test]
fn private_fields_match_the_tiff_readers_selection() {
let mut differ = Vec::new();
for order in [Little, Big] {
for (case, d) in differential_cases(order) {
let f = private_fields(&d, 0, order);
let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order));
if ours != reader {
differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}"));
}
}
assert_eq!(private_fields(&[], 0, order), PrivateFields::default());
assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default());
}
assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n"));
// what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later
// copy stands in for an unreadable first one, and a used-up value budget hides the fields
let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) };
let cases = differential_cases(Little);
let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap();
for name in [
"0 fillers",
"256 fillers",
"8189 fillers",
"0x7200: first LONG[2] past the file",
"0x7201: first unknown type 14",
"key: first UNDEFINED[8] past the file",
"23 entries using the value budget",
] {
assert!(found(&case(name)), "{name}");
}
for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] {
assert!(!found(&case(name)), "{name}");
}
}
/// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of
/// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it
/// holds are lost).
#[test]
fn the_block_is_found_behind_filler_and_unreadable_entries() {
for order in [Little, Big] {
for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] {
let w = |v: u32| {
let mut b = Vec::new();
order.put_u32(&mut b, v);
[b[0], b[1], b[2], b[3]]
};
let n = fillers + 3 + usize::from(unreadable_first);
let start = 2 + 12 * n + 4;
let block = block(start, &[(0x7310, SHORT, &[800; 4])], order);
let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers];
if unreadable_first {
entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX)));
}
entries.extend([
(SUBIFD_OFFSET, LONG, 1, w(start as u32)),
(SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)),
(SUBIFD_KEY, UNDEFINED, 4, KEY),
]);
let mut file = private(&entries, order);
assert_eq!(file.len(), start);
file.extend(&block);
let mut pointer = Vec::new();
order.put_u32(&mut pointer, 0);
let ifd0 = Ifd {
entries: vec![lightcraft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: lightcraft_tiff::Value::Byte(pointer), offset: 0 }],
..Default::default()
};
let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310));
assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}");
}
}
}
#[test]
fn hostile_directories_give_none() {
let start = 4096;
+1
View File
@@ -186,6 +186,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
};
img.validate_for(mode)?;
Ok(img)