Merge upstream main into docking-adoption

This commit is contained in:
aiongg
2026-10-26 02:18:38 +00:00
45 changed files with 10988 additions and 294 deletions
Generated
+591 -187
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -10,8 +10,9 @@ rust-version = "1.95"
repository = "https://github.com/storytold/lightcraft"
[workspace.dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["serde"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] }
# internal
lightcraft-denoise-core = { path = "crates/denoise-core" }
lightcraft-denoise = { path = "crates/denoise", default-features = false }
@@ -61,7 +62,7 @@ dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and h
# formats
quick-xml = "0.41"
miniz_oxide = "0.8"
miniz_oxide = "0.9"
# codecs
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] }
+1 -1
View File
@@ -348,7 +348,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, ILCE-7RM5, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
+1 -1
View File
@@ -305,7 +305,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
较老佳能的 raw 都很出色。
- **最大的缺口:**
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
+1 -1
View File
@@ -320,7 +320,7 @@ LightCraft 還很年輕,而且進展飛快:**Alpha 階段**。**老實說,
在單機上處理 JPEG/DNG,以及大多數 Sony/Panasonic/
Fujifilm/Nikon/較舊款 Canon 的 raw 都很出色;各機型詳情見 [docs/raw-parity.md](docs/raw-parity.md)。
- **最大的缺口:**
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **相機色彩校正:** Sony、Nikon、Panasonic、Fujifilm 和 Canon CR3 的 raw 會從各自的相機 JPEG 得到受保護的估計值,內建 ILCA-99M2、ILCE-7CR、ILCE-7M4、ILCE-7RM5、X-H2S、X-T4、Nikon Z 6II、Z 8 和 D5300 描述檔;仍缺少實測校正,其他 raw 或被拒絕的擬合會保留中性矩陣;
- **壓縮的 Olympus raw 與不受支援的 CR3 變體:** 這些在有內嵌 JPEG 預覽時會改用預覽畫面。Fujifilm 無損/有損壓縮 RAF 現在能解碼感光元件資料;[驗證與現有照片庫重新載入說明](docs/raf-compression.md);
- **AI 遮色片與降噪:** 主體與天空選取目前是傳統啟發式演算法;
- **網路照片庫:** 預覽只讀取 raw 的標頭 — 或者,對於 CR3 或預覽標籤位於其後的 TIFF raw,只讀取稀疏規劃器指定的範圍 — 以及其內嵌的 JPEG(56.5 MB → 5.9 MB,10 秒 → 1 秒,測得於測試用的網路共用資料夾),但*顯影後*的畫面仍需要透過網路傳輸感光元件資料;詳見 [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
+3
View File
@@ -55,3 +55,6 @@ objc2-foundation = { workspace = true }
[dev-dependencies]
# tests record why they skip (`cargo xtask ci` lists the skips)
lightcraft-testkit = { workspace = true }
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+6
View File
@@ -883,6 +883,11 @@ fn panic_log_path_with(env: impl Fn(&str) -> Option<std::ffi::OsString>, temp: s
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::LIGHTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record is kept for the log file (`logging`).
let logger = logging::install();
lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir()));
@@ -1011,6 +1016,7 @@ fn main() -> eframe::Result {
// HDR display: SDR unless the surface, platform and display all allow HDR
hdr_display::report(cc.wgpu_render_state.as_ref().map(|r| r.target_format));
let mut app = LightcraftApp::new(session, services(cc, app_log_file.as_deref()));
app.set_startup_update_check(true);
if let Some(ui) = prefs {
app.ui = ui;
}
+1
View File
@@ -22,6 +22,7 @@ covers a whole directory), or if a licence file referenced in the Licence column
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
| `assets/camera-profiles/ILCE-7RM5.json` | Sony ILCE-7RM5 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Yule (Brian) Wang (@peaperfish) | original work: fitted by `lightcraft-cli calibrate` to 220 usable ILCE-7RM5 ARWs of my own (of 241, 2025 to 2026, Standard creative style; Sigma 24-70mm F2.8 DG DN II Art and Sony FE 200-600mm F5.6-6.3 G OSS) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-26 | none |
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
File diff suppressed because it is too large Load Diff
+1
View File
@@ -24,3 +24,4 @@ web-time = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
dhat = { workspace = true }
+35 -2
View File
@@ -9,7 +9,7 @@
//! compressed length and CRC-32, then the strips' bytes. `key` says what the product was made from (the photo's
//! content, the model, this code's version, the settings it depends on); a file with another key is stale.
use std::io::{Read, Write};
use std::io::{Read, Seek, Write};
use std::path::Path;
use lightcraft_raster::Rgb32f;
@@ -216,6 +216,10 @@ fn read_start(r: &mut impl Read) -> Result<Start, ProductError> {
r.read_exact(&mut e)?;
let l = u32::from_le_bytes([e[0], e[1], e[2], e[3]]) as usize;
let c = u32::from_le_bytes([e[4], e[5], e[6], e[7]]);
// a strip never compresses to nothing (it has at least one row, so at least one deflated pixel)
if l == 0 {
return Err(ProductError::Format("a strip is empty".into()));
}
// a strip never compresses to much more than its raw size
if l > 12 * h.strip * h.width + 4096 {
return Err(ProductError::Format("a strip is larger than it can be".into()));
@@ -305,7 +309,6 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
}
// the picture rows and columns the blocks cover
let (x_end, y_end) = if whole { (w.x + w.width, w.y + w.height) } else { (w.x + ow * factor, w.y + oh * factor) };
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
let lut = f16_table();
let value = |raw: &[u8], c: usize, n: usize, i: usize| -> f32 {
let (lo, hi) = (raw.get(2 * c * n + i).copied().unwrap_or(0), raw.get((2 * c + 1) * n + i).copied().unwrap_or(0));
@@ -318,6 +321,21 @@ pub fn read_window(path: &Path, key: &str, window: Option<Window>, factor: usize
let strips: Vec<usize> = (0..table.len()).filter(|&s| wanted(s)).collect();
// skip the strips before the first one wanted
let skipped: usize = table.iter().take(strips.first().copied().unwrap_or(0)).map(|e| e.0).sum();
// The output is reserved only after the wanted strips' compressed bytes are present and match their checksums:
// a malformed cache whose strips are empty, damaged or short are rejected here, before the (large) result is
// allocated, not after. Reading them twice is cheap next to decoding them, and the fold below re-seeks here.
let strips_at = f.stream_position()?;
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
for &s in &strips {
let Some(&(len, expected)) = table.get(s) else { return Err(ProductError::Format("a strip is missing".into())) };
let mut packed = allocate(len, 0u8)?;
f.read_exact(&mut packed)?;
if crc(&packed) != expected {
return Err(ProductError::Format(format!("strip {s} is damaged")));
}
}
let mut out = Rgb32f { width: ow, height: oh, data: allocate(ow * oh, [0.0; 3])? };
f.seek(std::io::SeekFrom::Start(strips_at))?;
f.seek_relative(i64::try_from(skipped).map_err(|_| ProductError::Format("the file is too large".into()))?)?;
for group in strips.chunks(GROUP) {
let mut packed = Vec::with_capacity(group.len());
@@ -422,6 +440,21 @@ mod tests {
let _ = std::fs::remove_file(p);
}
#[test]
fn an_empty_strip_entry_is_rejected_by_the_header() {
let p = temp("empty-strip");
let header = serde_json::to_vec(&Header { width: 16, height: 1, strip: STRIP_ROWS, key: "k".into() }).unwrap();
let mut bytes = Vec::from(MAGIC.as_slice());
bytes.extend_from_slice(&[VERSION, 0, 0, 0]);
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
bytes.extend_from_slice(&header);
// one strip with a zero-length entry: it has a row, so its deflated bytes are never empty
bytes.extend_from_slice(&[0; 8]);
std::fs::write(&p, bytes).unwrap();
assert!(matches!(read_header_at(&p), Err(ProductError::Format(why)) if why.contains("empty")));
let _ = std::fs::remove_file(p);
}
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
}
+50
View File
@@ -0,0 +1,50 @@
//! Peak heap of a `read_window` that must reject a product's strips (`crates/denoise-core/src/product.rs`), measured
//! with dhat's testing mode. The result is allocated only after the wanted strips' checksums are verified, so a cache
//! whose compressed bytes are damaged or absent fails without first reserving the (multi-megabyte) output. One test
//! per binary (dhat's profiler is global).
use lightcraft_denoise_core::product::read_window;
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn temp(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("lc-denoise-{name}-{}.lcdn", std::process::id()))
}
/// A 2000 × 1024 product of key `k` (a 24 MB result): its 8 strips each claim 8 bytes that do not match their
/// checksum, and the file is exactly the length its header and table declare. A 24 MB peak means the output was
/// reserved; the cap here bounds the header, the table and the strips' own bytes.
fn damaged_product() -> Vec<u8> {
let header = br#"{"width":2000,"height":1024,"strip":128,"key":"k"}"#;
const STRIP_LEN: usize = 8;
let mut bytes = Vec::from(b"LCDN".as_slice());
bytes.extend_from_slice(&[1, 0, 0, 0]);
bytes.extend_from_slice(&(header.len() as u32).to_le_bytes());
bytes.extend_from_slice(header);
let expected_len = 12 + header.len() + 8 * 8 + 8 * STRIP_LEN;
for _ in 0..8 {
// 8 claimed bytes whose recorded checksum does not match theirs (0xAA bytes do not have this CRC)
bytes.extend_from_slice(&(STRIP_LEN as u32).to_le_bytes());
bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
}
for _ in 0..8 {
bytes.extend_from_slice(&[0xAAu8; STRIP_LEN]);
}
assert_eq!(bytes.len(), expected_len, "the crafted file lies about its length");
bytes
}
#[test]
fn a_damaged_product_is_rejected_before_its_output_is_allocated() {
let bytes = damaged_product();
let p = temp("damaged-product-heap");
std::fs::write(&p, &bytes).unwrap();
let _profiler = dhat::Profiler::builder().testing().build();
let why = read_window(&p, "k", None, 1, None).err().unwrap().to_string();
let peak = dhat::HeapStats::get().max_bytes;
let _ = std::fs::remove_file(p);
assert!(why.contains("damaged"), "{why}");
eprintln!("peak heap {peak} bytes");
assert!(peak < 4 << 20, "peak heap {peak} bytes: the output was reserved before the strips were checked");
}
+71 -5
View File
@@ -4,6 +4,12 @@
use crate::net::{Net, NetError, Op, TensorId};
use crate::run::{Error, TileRunner};
/// The tensors' size cap: the same one the real runner's buffers share (`crates/denoise/src/cpu.rs`). A hostile `Net`
/// only ever has to satisfy the final shape: a `ConvT2` doubles the sides and a `MaxPool` halves them, so a small
/// accepted graph can zoom its intermediates far out and back. Every tensor is counted here, and a run whose layers
/// would retain more than one real tile's whole working set is refused before the memory behind it is reserved.
const MAX_VALUES: usize = 128 * 1024 * 1024;
/// A tensor in `[channels][side][side]` order.
struct T {
channels: usize,
@@ -11,12 +17,36 @@ struct T {
data: Vec<f32>,
}
/// A tensor's plain float count, in bounds.
fn count(channels: usize, side: usize) -> Result<usize, NetError> {
channels
.checked_mul(side)
.and_then(|n| n.checked_mul(side))
.filter(|&n| n > 0 && n <= MAX_VALUES)
.ok_or_else(|| NetError::Invalid("a tensor has no size or is too large".into()))
}
/// A zeroed tensor, keeping the memory the run keeps alive in bounds (`tensors` retains every layer's result).
fn tensor(channels: usize, side: usize, total: &mut usize) -> Result<Vec<f32>, NetError> {
let n = count(channels, side)?;
*total = (*total).saturating_add(n);
if *total > MAX_VALUES {
return Err(NetError::Invalid("the tile needs too much working memory".into()));
}
let mut data = Vec::new();
data.try_reserve_exact(n).map_err(|_| NetError::Invalid("not enough memory for a reference tile".into()))?;
data.resize(n, 0.0);
Ok(data)
}
/// Run `net` on `input` (`in_channels` planes of `tile × tile`).
pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError> {
if !net.fits_tile(tile) || input.len() != net.in_channels * tile * tile {
return Err(NetError::Invalid("the tile does not fit the network".into()));
}
let mut tensors: Vec<T> = vec![T { channels: net.in_channels, side: tile, data: input.to_vec() }];
let input = input.to_vec();
let mut total = input.len();
let mut tensors = vec![T { channels: net.in_channels, side: tile, data: input }];
let get = |tensors: &[T], id: TensorId| -> Result<usize, NetError> {
if id < tensors.len() { Ok(id) } else { Err(NetError::Invalid("a layer reads a tensor that does not exist yet".into())) }
};
@@ -27,7 +57,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let b = c.src2.map(|s| get(&tensors, s)).transpose()?;
let side = tensors[a].side;
let pad = (c.k - 1) / 2;
let mut data = vec![0f32; c.cout * side * side];
let mut data = tensor(c.cout, side, &mut total)?;
// the input channels in order: the first source's, then the second's
let planes: Vec<&[f32]> =
std::iter::once(&tensors[a]).chain(b.map(|b| &tensors[b])).flat_map(|t| t.data.chunks(side * side)).collect();
@@ -56,7 +86,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let a = get(&tensors, c.src)?;
let side = tensors[a].side;
let out_side = 2 * side;
let mut data = vec![0f32; c.cout * out_side * out_side];
let mut data = tensor(c.cout, out_side, &mut total)?;
for co in 0..c.cout {
for y in 0..side {
for x in 0..side {
@@ -77,7 +107,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
Op::MaxPool2 { src } => {
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels, tensors[a].side / 2);
let mut data = vec![0f32; channels * side * side];
let mut data = tensor(channels, side, &mut total)?;
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
@@ -92,7 +122,7 @@ pub fn run(net: &Net, tile: usize, input: &[f32]) -> Result<Vec<f32>, NetError>
let a = get(&tensors, *src)?;
let (channels, side) = (tensors[a].channels / 4, tensors[a].side);
let out_side = 2 * side;
let mut data = vec![0f32; channels * out_side * out_side];
let mut data = tensor(channels, out_side, &mut total)?;
for ch in 0..channels {
for y in 0..side {
for x in 0..side {
@@ -131,3 +161,39 @@ impl TileRunner for ReferenceRunner {
run(&self.net, self.tile, input).map_err(|e| Error::Runtime(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::ConvT2;
fn convt(src: TensorId, cin: usize, cout: usize) -> Op {
Op::ConvT2(ConvT2 { src, cin, cout, weight: vec![0.1; cin * cout * 4], bias: vec![0.0; cout], leaky: None })
}
/// A graph that fits the final-shape check while zooming its intermediates out and back: 256 `ConvT2`s double
/// the sides (256 tiers), 255 `MaxPool`s halve them back to the layer above the input.
fn hostile() -> Net {
let mut ops = Vec::new();
let mut last = 0usize;
for _ in 0..256 {
ops.push(convt(last, 1, 1));
last += 1;
}
for _ in 0..255 {
ops.push(Op::MaxPool2 { src: last });
last += 1;
}
Net::new(1, ops).unwrap()
}
/// The zoomed-out layers' memory was never reserved: either one tensor or the retained whole (the two bounds
/// share the cap) says why the run stops.
#[test]
fn a_graph_that_zooms_its_intermediates_out_and_back_is_refused_before_their_memory_is_reserved() {
assert!(matches!(
run(&hostile(), 1, &[1.0]),
Err(NetError::Invalid(why)) if why.contains("too large") || why.contains("too much working memory")
));
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ thiserror = { workspace = true }
log = { workspace = true }
ab_glyph = { workspace = true }
# Apply the font’s vertical substitutions and origins to Japanese watermarks.
harfrust = { version = "0.12", default-features = false, features = ["std"] }
harfrust = { version = "0.13", default-features = false, features = ["std"] }
unicode-segmentation = "1.13"
lightcraft-segment = { workspace = true, optional = true }
+1
View File
@@ -28,6 +28,7 @@ pub const BUNDLED: &[(&str, &str)] = &[
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
("ILCE-7RM5", include_str!("../../../assets/camera-profiles/ILCE-7RM5.json")),
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
+122
View File
@@ -0,0 +1,122 @@
//! Caps on the container's metadata boxes, counted before heic-rs touches the file.
//!
//! heic-rs 0.1.4 (pinned `=0.1.4`) has no aggregate work budget inside its container parsing:
//! every `ipma` association entry rescans the vector of already-seen item ids (quadratic in
//! the number of entries), and an `iloc` extent using construction method 2 reconstructs its
//! source item afresh for every extent that references it, with no shared cache. Legitimate
//! phone-photo metadata never comes near these caps — real corpora keep both well under a few
//! kilobytes — so anything above them is hostile or corrupt and is refused before the
//! countable CPU work happens.
//!
//! [`depth_boxes`] stops silently at structural blips: heic-rs's own parser reports those
//! better than this walk could, and an `ipma` box past the cap is caught wherever it parses.
use heic_rs::boxes::BoxIter;
use crate::Error;
/// The largest `ipma` box (item ↔ property associations) payload accepted, 64 KiB. An entry
/// costs at least 3 bytes, so this bounds heic-rs's per-entry rescan of earlier item ids to
/// ~21 000 entries ≈ 220 million comparisons: long enough to be annoying, never minutes.
const MAX_IPMA_BYTES: usize = 64 << 10;
/// The largest `iloc` box (item locations) payload accepted, 64 KiB. An extent costs at least
/// 12 bytes, so this bounds both the parse itself and how many extents a
/// construction-method-2 reconstruction chain can visit.
const MAX_ILOC_BYTES: usize = 64 << 10;
/// Falls through unless the container declares more `ipma` or `iloc` payload than a
/// legitimate phone photo ever does.
pub fn cap(bytes: &[u8]) -> Result<(), Error> {
let mut c = Census::default();
depth_boxes(&mut c, BoxIter::new(bytes), 0);
if c.ipma > MAX_IPMA_BYTES {
return Err(Error::Limit(format!("the image carries {} bytes of property associations, over the {MAX_IPMA_BYTES}-byte cap", c.ipma)));
}
if c.iloc > MAX_ILOC_BYTES {
return Err(Error::Limit(format!("the image carries {} bytes of item locations, over the {MAX_ILOC_BYTES}-byte cap", c.iloc)));
}
Ok(())
}
/// The metadata-box payload sizes seen so far.
#[derive(Default)]
struct Census {
ipma: usize,
iloc: usize,
}
/// Walks boxes, adding the payload sizes of those named in [`Census`].
///
/// Only `meta` and `iprp` (the two nesting boxes the metadata is built from) are descended
/// into, no deeper than that depth, so a nested-box trick can't hide a box from the count.
fn depth_boxes(c: &mut Census, boxes: BoxIter<'_>, depth: u32) {
if depth > 3 {
return;
}
for b in boxes {
// A structural error stops the walk; `heic_rs::probe` reports it properly afterwards.
let Ok(b) = b else { return };
if b.is(b"ipma") {
c.ipma = c.ipma.saturating_add(b.payload.len());
} else if b.is(b"iloc") {
c.iloc = c.iloc.saturating_add(b.payload.len());
} else if b.is(b"meta") {
// `meta` is a FullBox: version and flags sit between its header and its children.
if b.payload.len() >= 4 {
depth_boxes(c, BoxIter::new(&b.payload[4..]), depth + 1);
}
} else if b.is(b"iprp") {
depth_boxes(c, b.children(), depth + 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Assembles ftyp + meta(version/flags + iprp(ipco, ipma)) by hand; only the structure
/// the census walks matters, no item is ever defined.
fn file_with(ipma_payload: &[u8]) -> Vec<u8> {
let mut out = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic".to_vec();
let mut iprp = Vec::new();
iprp.extend(box_of(b"ipco", b""));
iprp.extend(box_of(b"ipma", ipma_payload));
let mut meta = vec![0u8, 0, 0, 0]; // version 0, flags 0
meta.extend(box_of(b"iprp", &iprp));
out.extend(box_of(b"meta", &meta));
out
}
fn box_of(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + body.len());
let size = u32::try_from(8 + body.len()).expect("test box fits in u32");
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(body);
out
}
#[test]
fn an_oversized_ipma_is_refused() {
// 40 000 entries of 6 bytes: 240 KiB of ipma, far over the cap, and quadratic if
// parsed (each entry rescans the earlier item ids in heic-rs 0.1.4).
let mut ipma = Vec::new();
for _ in 0..40_000u32 {
ipma.extend_from_slice(&[0u8, 0, 0, 0, 0, 0]);
}
let bytes = file_with(&ipma);
let r = crate::probe(&bytes).map(|_| ());
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("associations")), "{r:?}");
}
#[test]
fn a_undersized_ipma_is_untouched_by_the_caps() {
// The same shape with a normal ipma (two entries): the caps pass, and whatever error
// follows must not come from them.
let bytes = file_with(&[0, 0, 4, 1, 1, 2, 0, 0]);
let r = crate::probe(&bytes);
assert!(!matches!(r, Err(Error::Limit(ref m)) if m.contains("cap")), "{r:?}");
}
}
+108 -9
View File
@@ -19,6 +19,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod census;
mod orient;
/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`).
#[cfg(any(test, feature = "testdata"))]
@@ -149,20 +150,50 @@ pub fn probe(bytes: &[u8]) -> Result<Info, Error> {
}
fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
let info = heic_rs::probe(bytes).map_err(|e| match e {
// No `meta` box: no still image, only a `moov` image sequence (a video track).
census::cap(bytes)?;
// No `meta` box: no still image, only a `moov` image sequence (a video track).
let open_error = |e| match e {
heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE),
e => err(e),
})?;
let ctx = Context::open(bytes).map_err(err)?;
};
let ctx = Context::open(bytes).map_err(open_error)?;
let id = ctx.meta.primary;
let coded = match ctx.grid(id).map_err(err)? {
// heic-rs 0.1.4 caps the size of what it assembles, not the work: an item built from other
// items (`iloc` construction method 2) is rebuilt once per extent, so a chain of such items
// multiplies at every level while each result stays small. Count those visits from the
// `iloc` table before reconstructing anything: the primary item first (`heic_rs::probe` and
// `Context::grid` both rebuild its descriptor), then its alpha plane, then every tile
// reference, repeats included.
let mut visits = 0_usize;
item_visits(&ctx, id, 0, &mut visits)?;
let info = heic_rs::probe(bytes).map_err(open_error)?;
if let Some(alpha) = ctx.alpha_item(id).map_err(err)? {
item_visits(&ctx, alpha, 0, &mut visits)?;
}
let mut coded = match ctx.grid(id).map_err(err)? {
Some((_, tiles)) => tiles,
None => vec![id],
};
for &item in coded.iter().filter(|&&item| item != id) {
item_visits(&ctx, item, 0, &mut visits)?;
}
// A `dimg` listing may name the same tile more than once, and reconstructable items are
// moreover rebuilt for every extent that references them in heic-rs 0.1.4. For the size
// check each unique item counts once: the total reconstruction (all of it assembled from
// `bytes` by heic-rs) is held below one copy of the file, above which a repeat or chain
// wants more bytes than exist. Order doesn't matter here: this only checks that every
// tile's bytes lie in the file.
coded.sort_unstable();
coded.dedup();
let mut reconstructed = 0_usize;
for item in coded {
// Borrowed from `bytes` unless split across extents: cheap.
ctx.item_data(item).map_err(err)?;
reconstructed = reconstructed.saturating_add(ctx.item_data(item).map_err(err)?.len());
if reconstructed > bytes.len() {
return Err(Error::Limit(format!(
"the container holds {reconstructed} bytes of item data, over the {} bytes of the file itself",
bytes.len()
)));
}
}
Ok(Info {
width: info.width,
@@ -174,6 +205,36 @@ fn probe_unguarded(bytes: &[u8]) -> Result<Info, Error> {
})
}
/// Most extent visits heic-rs may spend reconstructing the primary image's coded items; real
/// files need one per tile (plus a few for the rare derived item).
const MAX_ITEM_VISITS: usize = 4096;
/// Deepest chain of items built from items we follow (heic-rs refuses beyond eight itself).
const MAX_ITEM_CHAIN: u32 = 8;
/// Adds to `visits` the extents heic-rs would read to reconstruct `id`, following items built
/// from other items, and fails once that passes [`MAX_ITEM_VISITS`] or the chain is too deep.
fn item_visits(ctx: &Context<'_>, id: u32, depth: u32, visits: &mut usize) -> Result<(), Error> {
use heic_rs::meta::iloc::Construction;
if depth > MAX_ITEM_CHAIN {
return Err(Error::Limit(format!("items built from items nest deeper than {MAX_ITEM_CHAIN} levels")));
}
let Some(loc) = ctx.meta.location(id) else { return Ok(()) };
*visits = visits.saturating_add(loc.extents.len().max(1));
if *visits > MAX_ITEM_VISITS {
return Err(Error::Limit(format!("reconstructing the image takes over {MAX_ITEM_VISITS} item extent reads")));
}
if loc.construction == Construction::Item {
for e in &loc.extents {
if let Ok(source) = u32::try_from(e.index)
&& source != id
{
item_visits(ctx, source, depth + 1, visits)?;
}
}
}
Ok(())
}
/// Decodes the primary image, with its metadata.
pub fn decode(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
guarded(|| decode_unguarded(bytes, options))
@@ -188,7 +249,13 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
let ctx = Context::open(bytes).map_err(err)?;
let primary = ctx.meta.primary;
let mut best: Option<(u32, u32)> = None;
// A thumbnail whose own reconstruction would cost too much is not a candidate; the visits
// of all the candidates share one budget.
let mut candidate_visits = 0_usize;
for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) {
if item_visits(&ctx, r.from, 0, &mut candidate_visits).is_err() {
continue;
}
let Ok(p) = ctx.props(r.from) else { continue };
let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue };
let edge = w.max(h);
@@ -197,6 +264,15 @@ pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Resul
}
}
let Some((id, _)) = best else { return Ok(None) };
// The same pre-scan as `probe`: the thumbnail's own descriptor and every tile reference
// are counted before heic-rs rebuilds any of them.
let mut visits = 0_usize;
item_visits(&ctx, id, 0, &mut visits)?;
if let Some((_, tiles)) = ctx.grid(id).map_err(err)? {
for &tile in &tiles {
item_visits(&ctx, tile, 0, &mut visits)?;
}
}
let mut decoded = decode_item(&ctx, id, options, false)?;
if decoded.icc.is_none() {
decoded.icc = item_icc(&ctx, primary);
@@ -232,6 +308,13 @@ fn decode_unguarded(bytes: &[u8], options: &Options) -> Result<Decoded, Error> {
/// `with_alpha` and the item has an alpha auxiliary image.
fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result<Decoded, Error> {
let p = ctx.props(id).map_err(err)?;
// heic-rs 0.1.4 keeps every associated transform and its [`orient::apply`] allocates the
// full picture again for each: a file could chain the repetition and multiply decode CPU
// without changing any checked size. Real files carry at most one `clap`, `irot` and
// `imir`, so refuse anything approaching [`MAX_TRANSFORMS`] (CodeRabbit #44).
if p.transforms.len() > MAX_TRANSFORMS {
return Err(Error::Limit(format!("the image carries {} transform properties, over the {MAX_TRANSFORMS}-property cap", p.transforms.len())));
}
let limit = options.max_pixels;
// The declared sizes are refused before anything is decoded or allocated.
let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?;
@@ -245,6 +328,14 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range };
let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None };
let (w, h) = planes.size();
// A grid's canvas dimensions come from its payload, not the checked `ispe` (CodeRabbit
// #48): check the real one, before [`ycc::to_rgb16`] allocates for it.
match (u32::try_from(w), u32::try_from(h)) {
(Ok(wu), Ok(hu)) => {
heic_rs::image::check_pixels(wu, hu, limit).map_err(err)?;
}
_ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))),
}
let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest };
let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode)
.ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?;
@@ -281,6 +372,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool)
/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1.
const GRID_BUDGET: u64 = 2;
/// The most transform properties (`clap`/`irot`/`imir`) one item may carry. Real files have at
/// most one of each, so anything approaching this is a reordered or hostile association list.
const MAX_TRANSFORMS: usize = 12;
/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order.
struct Coded<'a> {
frames: Vec<Frame>,
@@ -370,8 +465,12 @@ fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour {
/// metadata item never fails a decode whose pixels came out fine.
fn metadata(ctx: &Context<'_>) -> (Option<Vec<u8>>, Option<String>) {
let id = ctx.meta.primary;
let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec);
let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
// Metadata is best effort, so an item that would cost too much to rebuild is skipped, not an error.
let mut visits = 0_usize;
let cheap = |item: Option<u32>, visits: &mut usize| item.is_none_or(|item| item_visits(ctx, item, 0, visits).is_ok());
let exif = if cheap(ctx.exif_item(id), &mut visits) { ctx.exif(id).ok().flatten().map(<[u8]>::to_vec) } else { None };
let xmp_item = ctx.xmp_item(id).filter(|&x| cheap(Some(x), &mut visits));
let xmp = xmp_item.and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d {
Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(),
Cow::Owned(b) => String::from_utf8(b).ok(),
});
+13
View File
@@ -185,3 +185,16 @@ fn truncated_and_corrupted_files_are_errors_not_panics() {
let _ = decode_thumbnail(&b, 1, &Options::default());
}
}
/// CodeRabbit #44: heic-rs keeps every associated transform and each one re-allocates the
/// whole picture, so 13 of them are over the [`crate::MAX_TRANSFORMS`] cap and refused.
#[test]
fn too_many_transform_properties_are_refused() {
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 13).collect(), ..Spec::new(32, 32, &grey) });
let r = decode(&bytes, &Options::default());
assert!(matches!(r, Err(Error::Limit(ref m)) if m.contains("transform")), "{r:?}");
// At the cap they're not: 12 identity rotations decode normally.
let bytes = build(&Spec { props: std::iter::repeat_n(Prop::Irot(0), 12).collect(), ..Spec::new(32, 32, &grey) });
let d = decode(&bytes, &Options::default()).unwrap();
assert_eq!((d.width, d.height), (32, 32));
}
+26
View File
@@ -161,6 +161,13 @@ pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampli
if !(8..=16).contains(&bd) || w == 0 || h == 0 {
return None;
}
// The default caller limit is 2²8 pixels, at most four channels per pixel, so the largest
// legitimate `w·h·ch` is 2²30 samples. Anything more is a malformed or hostile canvas the
// decode path would have refused before calling here (CodeRabbit #48): refuse rather than
// ask for a multi-gigabuffer (or worse, over usize).
if w.checked_mul(h)?.checked_mul(ch)? > 1 << 30 {
return None;
}
let max = ((1u32 << bd) - 1) as f32;
let half = (1u32 << (bd - 1)) as f32;
let scale = (1u32 << (bd - 8)) as f32;
@@ -323,4 +330,23 @@ mod tests {
.is_none()
);
}
/// CodeRabbit #48: a plane claiming 500 000 × 500 000 pixels must be refused without ever
/// allocating for it (before the cap this vec was never built; upper-bounding allocation
/// would have been a 7.5-gibabuffer or a panic instead of an error).
#[test]
fn an_oversized_canvas_is_refused_without_allocation() {
let f = Frame {
width: 500_000,
height: 500_000,
bit_depth: 8,
chroma: ChromaFormat::Yuv420,
y: vec![128],
cb: vec![128; 4],
cr: vec![128; 4],
y_stride: 0,
c_stride: 0,
};
assert!(to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).is_none());
}
}
+2
View File
@@ -34,6 +34,8 @@ pub use pano::{PanoOptions, PanoResult, Projection, stitch};
pub enum MergeError {
#[error("{0} photo(s) selected; at least {1} are needed")]
TooFew(usize, usize),
#[error("{0} photo(s) selected; a stitch of more than {1} is refused: matching every pair would take far too long")]
TooMany(usize, usize),
#[error("{0}")]
Mismatch(String),
#[error("could not read a photo: {0}")]
+6 -4
View File
@@ -61,10 +61,12 @@ pub struct PairMatch {
/// Match every pair of images. `feats[k]` are in work coordinates scaled by `scales[k]`.
pub fn match_pairs(feats: &[Features], scales: &[f64]) -> Vec<PairMatch> {
let n = feats.len();
let pairs: Vec<(usize, usize)> = (0..n).flat_map(|i| (i + 1..n).map(move |j| (i, j))).collect();
let mut out: Vec<PairMatch> = pairs
.par_iter()
.filter_map(|&(i, j)| {
// the pairs are generated lazily: materialising all n(n−1)/2 up front is quadratic memory, including
// when every comparison comes up empty (no usable features)
let mut out: Vec<PairMatch> = (0..n)
.into_par_iter()
.flat_map_iter(|i| ((i + 1)..n).map(move |j| (i, j)))
.filter_map(|(i, j)| {
let m = features::match_features(&feats[i], &feats[j], 0.8);
if m.len() < 12 {
return None;
+8
View File
@@ -53,6 +53,11 @@ impl Default for PanoOptions {
/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down.
pub const MAX_MEGAPIXELS: f64 = 512.0;
/// Most frames one stitch accepts: pair matching compares every pair of frames (n(n−1)/2 runs), so an
/// unbounded frame count is quadratic CPU as well as quadratic memory while the frames load. A panorama
/// with more than [`MAX_FRAMES`] shots is refused with an error, not attempted.
pub const MAX_FRAMES: usize = 256;
/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down.
pub const MAX_EDGE: f64 = 65_000.0;
@@ -224,6 +229,9 @@ pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -
if n < 2 {
return Err(MergeError::TooFew(n, 2));
}
if n > MAX_FRAMES {
return Err(MergeError::TooMany(n, MAX_FRAMES));
}
check(progress, 0.01, "Finding features")?;
let (cams, rms_before, rms) = register(&frames, progress)?;
let used: Vec<usize> = (0..n).filter(|&i| cams[i].is_some()).collect();
+12
View File
@@ -246,6 +246,18 @@ fn unrelated_photos_do_not_stitch() {
assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err());
}
/// Pair matching compares every pair of frames before anything is known about them: an unbounded
/// photo count is quadratic CPU and memory (issue #1519). More than `MAX_FRAMES` is refused at once,
/// before any frame is worked on.
#[test]
fn a_stitch_of_more_than_the_frame_cap_is_refused_up_front() {
let frames: Vec<Frame> = (0..lightcraft_merge::pano::MAX_FRAMES + 1)
.map(|i| Frame::from_linear_rec2020(Rgb32f::from_fn(8, 8, |_, _| [(i % 255) as f32 / 255.0, 0.0, 0.0]), None))
.collect();
let err = stitch(frames, &PanoOptions::default(), &no_progress).unwrap_err();
assert!(err.to_string().contains("refused"), "the frame cap errors, it does not begin the quadratic match: {err}");
}
/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their
/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down,
/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail.
+10 -2
View File
@@ -1,7 +1,7 @@
//! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics).
use crate::{DateTime, Flash, Gps, Metadata, Orientation};
use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t};
use lightcraft_tiff::{Ifd, ParseOptions, Tiff, TiffError, tags as t};
/// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks.
pub fn strip_exif_header(b: &[u8]) -> &[u8] {
@@ -16,8 +16,16 @@ pub fn read_exif(bytes: &[u8]) -> Metadata {
/// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0.
pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> {
try_read_exif_with(bytes, &ParseOptions::default())
}
/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular).
/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually
/// loaded, the rest is zeros — must not size its allocations by the logical file length, which
/// unrelated bytes of the file can inflate at no cost to the attacker.
pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result<Metadata, TiffError> {
let data = strip_exif_header(bytes);
let tiff = Tiff::parse(data)?;
let tiff = Tiff::parse_with(data, opts)?;
let mut m = from_tiff(&tiff);
// Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif)
if m.lens_model.is_none() {
+9 -1
View File
@@ -171,7 +171,15 @@ fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> {
loaded.insert(0, head.len() as u64);
for _ in 0..MAX_ROUNDS {
match plan(&buf, &loaded) {
Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)),
Step::Ready => {
// The decoder's allocation budget follows the bytes actually fetched, not the logical
// file length: only the loaded ranges are data, and an unrelated entry that claims a
// huge value must be cut by what leaving it cost, not by what the buffer claims to hold.
// The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead.
let opts =
lightcraft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() };
return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus));
}
Step::Nothing => return Ok(None),
Step::Need(ranges) => {
for (a, b) in ranges {
+1 -1
View File
@@ -27,7 +27,7 @@ mod xmp_merge;
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif};
pub use focus::FocusPoint;
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
+63 -10
View File
@@ -154,6 +154,11 @@ const MAX_NODES: usize = 200_000;
// Expanding a long namespace URI on many short names must not amplify a small packet without bound.
pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024;
/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor
/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise
/// retains its prefix once per property: node limits alone cannot bound that duplication.
const MAX_KEY_BYTES: usize = 1 << 20;
/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque.
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result<String, XmpError> {
canonical(qname, scopes, opaque_budget)
@@ -332,18 +337,29 @@ fn is_rdf_meta_attr(k: &str) -> bool {
matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns")
}
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
for (k, v) in &d.attrs {
if !is_rdf_meta_attr(k) {
if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) {
props.entry(format!("{prefix}{k}")).or_default().push(v.clone());
}
}
for c in &d.children {
collect_property(c, prefix, props);
collect_property(c, prefix, props, budget);
}
}
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>) {
/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the
/// property is then left out, like other elements a limit has cut).
fn take_key_bytes(key: &str, budget: &mut usize) -> bool {
let n = key.len();
if *budget < n {
return false;
}
*budget -= n;
true
}
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
let key = format!("{prefix}{}", p.name);
if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
let mut items: Vec<(bool, String)> = arr
@@ -357,29 +373,36 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str
if arr.name == "rdf:Alt" {
items.sort_by_key(|(default, _)| !*default);
}
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
if take_key_bytes(&key, budget) {
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
}
return;
}
let struct_prefix = format!("{key}/");
if p.attr("rdf:parseType") == Some("Resource") {
for c in &p.children {
collect_property(c, &struct_prefix, props);
collect_property(c, &struct_prefix, props, budget);
}
return;
}
if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {
collect_description(d, &struct_prefix, props);
collect_description(d, &struct_prefix, props, budget);
return;
}
let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect();
if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() {
for (k, v) in field_attrs {
props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone());
let full = format!("{struct_prefix}{k}");
if take_key_bytes(&full, budget) {
props.entry(full).or_default().push(v.clone());
}
}
return;
}
let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string());
props.entry(key).or_default().push(v);
if take_key_bytes(&key, budget) {
props.entry(key).or_default().push(v);
}
}
fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
@@ -497,8 +520,10 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
find_descriptions(&tree, &mut descs);
let mut props = BTreeMap::new();
let mut values = BTreeMap::new();
// One budget for the whole document: several descriptions cannot each spend a fresh one.
let mut key_budget = MAX_KEY_BYTES;
for d in descs {
collect_description(d, "", &mut props);
collect_description(d, "", &mut props, &mut key_budget);
if let XmpValue::Struct(m) = node_struct(d) {
values.extend(m);
}
@@ -1171,4 +1196,32 @@ mod tests {
assert_eq!(entity("#x41"), Some('A'));
assert_eq!(entity("#9"), Some('\t'));
}
/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child
/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it
/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text).
#[test]
fn flattened_keys_stay_within_the_cumulative_budget() {
let level = "n".repeat(200);
let mut doc = String::from(
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:lc=\"https://lightcraft.example/lc/\"><rdf:RDF><rdf:Description><lc:root rdf:parseType=\"Resource\">",
);
for _ in 0..4 {
doc.push_str(&format!("<lc:{level} rdf:parseType=\"Resource\">"));
}
// each leaf has a name of its own, so each one adds a key
for i in 0..3000 {
doc.push_str(&format!("<lc:leaf{i} lc:att=\"v\"/>"));
}
doc.push_str("</lc:root>");
for _ in 0..4 {
doc.push_str(&format!("</lc:{level}>"));
}
doc.push_str("</rdf:Description></rdf:RDF></x:xmpmeta>");
let d = parse_xmp(&doc).unwrap();
let total: usize = d.properties.keys().map(String::len).sum();
assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget");
assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent");
assert!(!d.properties.is_empty(), "properties parsed before the budget are kept");
}
}
+55 -2
View File
@@ -94,6 +94,17 @@ pub const SOFTWARE: &str = "LightCraft DNG 2";
/// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs.
const DEFLATE_LEVEL: u8 = 1;
/// Largest Deflate tile edge the writer pads to (`tile` is caller-supplied in [`DngCompression::Deflate`]):
/// square-tile buffers grow with the square, so an unclamped edge lets a request pad a tiny valid image into
/// a multi-gigabyte buffer and billions of edge-replicate iterations. 2048² samples (≤ 24 MB of f32) is far
/// above the 256 default and every real tile this writer makes.
const MAX_DEFLATE_TILE: u32 = 2048;
/// `tile` aligned down to a multiple of 16 and clamped to [`MAX_DEFLATE_TILE`].
fn deflate_tile(tile: u32) -> usize {
(tile.max(16) & !15).min(MAX_DEFLATE_TILE) as usize
}
/// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept).
pub fn f32_to_f16(v: f32) -> u16 {
let b = v.to_bits();
@@ -318,7 +329,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
match (&raw.data, opts.compression) {
(RawData::F32(v), DngCompression::Deflate { tile, half }) => {
let tile = (tile.max(16) & !15) as usize;
let tile = deflate_tile(tile);
let bp = if half { 2 } else { 4 };
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![(bp * 8) as u16; cpp]));
ifd.set(t::SAMPLE_FORMAT, Value::Short(vec![3; cpp]));
@@ -417,7 +428,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
ifd.set_image(ImageData::Strips { rows_per_strip: rows as u32, strips });
}
DngCompression::Deflate { tile, .. } => {
let tile = (tile.max(16) & !15) as usize;
let tile = deflate_tile(tile);
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16; cpp]));
ifd.set(t::COMPRESSION, Value::Short(vec![compression::ADOBE_DEFLATE]));
ifd.set(t::PREDICTOR, Value::Short(vec![2]));
@@ -546,3 +557,45 @@ pub fn write_rendered_dng(
let order = ByteOrder::Little;
write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BlackLevel, OpcodeLists, RawData, RawFormat, RawImage, Rect};
/// Deflate tiles of `tile × tile` pad into a square buffer that grows with the square: a
/// caller-supplied edge of `u32::MAX` would make the writer pad an 8 × 8 image into billions of
/// edge-replicate iterations and a multi-gigabyte buffer. The clamp keeps the bounded shape — same
/// file as requesting the cap exactly, and a DNG either way.
#[test]
fn a_hostile_deflate_tile_edge_is_clamped() {
let (w, h) = (8usize, 8usize);
let full = Rect::new(0, 0, w, h);
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::U16((0..64).map(|i| i as u16 * 129).collect()),
cfa: None,
bits: 12,
black: BlackLevel::uniform(0.0),
white: vec![4095.0],
active_area: full,
crop: full,
orientation: lightcraft_geom::Orientation::Normal,
color: crate::ColorData::default(),
wb_multipliers: None,
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: Default::default(),
lens_metadata: Default::default(),
};
let opts = |tile: u32| DngWriteOptions { compression: DngCompression::Deflate { tile, half: false }, ..DngWriteOptions::default() };
let hostile = write_dng(&raw, &opts(u32::MAX)).expect("the clamped tile writes");
assert_eq!(hostile, write_dng(&raw, &opts(2048)).expect("the cap itself writes"), "u32::MAX is clamped to the cap");
assert_ne!(hostile, write_dng(&raw, &opts(256)).expect("a normal tile writes"), "the clamp is not a no-op");
assert_eq!(crate::probe(&hostile), Some(RawFormat::Dng), "the clamped output is still a DNG");
}
}
+43 -2
View File
@@ -656,18 +656,38 @@ fn fix_pixels(buf: &mut [f32], w: usize, h: usize, cpp: usize, cfa: Option<&Cfa>
}
}
/// Most full-image passes of one `OpcodeList3`: every warp record is a full-image allocation and resample
/// pass and every bad-pixel record a full flatten-and-rebuild, and a hostile record may repeat either
/// thousands of times. Real DNGs carry at most a couple; passes past the cap are skipped.
const MAX_LIST3_PASSES: usize = 8;
/// Apply `OpcodeList3` (on the demosaiced RGB image, values normalised to [0, 1]).
pub fn apply_list3(list: &[Opcode], img: &mut Rgb32f) {
if list.is_empty() {
return;
}
let (w, h) = (img.width, img.height);
let mut passes = 0usize;
for op in applied_ops(list) {
match op {
Opcode::WarpRectilinear { planes, center } => *img = warp_rectilinear(img, planes, *center),
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => *img = warp_rectilinear2(img, planes, *center, *reciprocal),
Opcode::WarpRectilinear { planes, center } => {
if passes < MAX_LIST3_PASSES {
passes += 1;
*img = warp_rectilinear(img, planes, *center);
}
}
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => {
if passes < MAX_LIST3_PASSES {
passes += 1;
*img = warp_rectilinear2(img, planes, *center, *reciprocal);
}
}
Opcode::WarpFisheye { .. } | Opcode::TrimBounds { .. } | Opcode::Unknown { .. } => {}
other => {
if passes >= MAX_LIST3_PASSES {
continue;
}
passes += 1;
let mut flat: Vec<f32> = img.data.iter().flat_map(|p| *p).collect();
apply_list(std::slice::from_ref(other), &mut flat, w, h, 3, None, 1.0);
for (d, c) in img.data.iter_mut().zip(flat.as_chunks::<3>().0) {
@@ -765,6 +785,27 @@ pub(crate) mod tests {
a.iter().flat_map(|v| v.to_be_bytes()).collect()
}
/// Warp and bad-pixel records each cost a full-image pass, and a hostile `OpcodeList3` repeats them
/// thousands of times ([`MAX_LIST3_PASSES`] bounds the multiplier); identity warps below the cap must
/// still apply, exactly, so the same image comes out.
#[test]
fn list3_pass_cap_skips_repeated_records() {
let mut img = Rgb32f::new(8, 6);
for (i, px) in img.data.iter_mut().enumerate() {
*px = [i as f32 / 2.0, (i % 8) as f32, (i / 8) as f32];
}
let orig: Vec<[f32; 3]> = img.data.clone();
let list: Vec<Opcode> =
(0..MAX_LIST3_PASSES * 5).map(|_| Opcode::WarpRectilinear { planes: vec![[1.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }).collect();
apply_list3(&list, &mut img);
assert_eq!(img.data, orig, "identity warps change nothing, and the cap skips the repeated rest");
// a real warp below the cap is still applied
let one = [Opcode::WarpRectilinear { planes: vec![[2.0, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }];
apply_list3(&one, &mut img);
assert_ne!(img.data, orig, "a genuine warp record is not skipped");
}
#[test]
fn parse_and_apply_area_ops() {
let (w, h) = (4usize, 3usize);
+62 -10
View File
@@ -103,8 +103,6 @@ fn jpeg_frame(b: &[u8]) -> Option<(u32, u32, u8)> {
let be16 = |k: usize| Some(u32::from(u16::from_be_bytes([at(k)?, at(k + 1)?])));
Some((be16(7)?, be16(5)?, at(9)?))
}
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
fn is_jxl(b: &[u8]) -> bool {
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
}
@@ -128,20 +126,17 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
}
if matches!(ifd.u16(t::COMPRESSION), Some(6) | Some(7) | Some(34892))
&& let Ok(info) = ifd.image()
&& let Some(chunk) = info.single_chunk(data.len() as u64)
&& let Some(s) = chunk_bytes(data, &chunk)
{
let chunks = info.chunks(data.len() as u64);
if chunks.len() == 1
&& let Some(s) = chunk_bytes(data, &chunks[0])
{
out.push(s);
}
out.push(s);
}
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
&& let Ok(info) = ifd.image()
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
&& let Some(s) = chunk_bytes(data, chunk)
&& let Some(chunk) = info.single_chunk(data.len() as u64)
&& let Some(s) = chunk_bytes(data, &chunk)
&& is_jxl(s)
{
out.push(s);
@@ -397,8 +392,14 @@ fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
/// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0
/// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller
/// `THMB` box has the same shape. Returns the larger valid one.
/// Marker-walk budget of the CR3 fallback: candidates can overlap (each claiming the rest of the file) and
/// [`is_dct_jpeg`] walks each marker by marker, so walking every candidate would be quadratic in file size.
/// The `THMB`/`PRVW` previews this finds are far below the cap together; once it is spent the scan stops.
const CR3_MAX_WALK: usize = 8 << 20;
fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
let mut best: Option<&[u8]> = None;
let mut walked = 0usize;
for tag in [b"PRVW", b"THMB"] {
let mut from = 0;
while let Some(i) = bytes.get(from..).and_then(|s| s.windows(4).position(|w| w == tag)).map(|p| p + from) {
@@ -411,6 +412,11 @@ fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
continue;
}
let jpeg = &bytes[j..j + len];
// One that doesn't fit the budget left is skipped; a smaller one later may still fit.
if walked + jpeg.len() > CR3_MAX_WALK {
continue;
}
walked += jpeg.len();
if is_dct_jpeg(jpeg) {
if best.is_none_or(|b| b.len() < jpeg.len()) {
best = Some(jpeg);
@@ -440,6 +446,52 @@ mod tests {
j
}
/// Preview extraction only serves single-chunk JPEG-compressed IFDs and asks the `single_chunk` first:
/// a TIFF whose strip array declares 100 000 one-row strips and no StripByteCounts must not build their
/// descriptor vector, and of course finds no preview in it.
#[test]
fn a_multi_strip_jpeg_ifd_costs_no_descriptor_vector() {
let mut ifd = IfdBuilder::new();
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![100_000]));
ifd.set(t::ROWS_PER_STRIP, Value::Short(vec![1]));
ifd.set(t::STRIP_OFFSETS, Value::Byte((0..100_000).map(|i| (i % 255 + 1) as u8).collect()));
let file = TiffWriter::default().write(&[ifd]).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(embedded_preview(&file)));
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None));
}
/// CR3 candidates claim overlapping extents: each block's first bytes double as a 40-byte jump marker so
/// a walk jogs past the next header and reaches the end of the file, making every one of the 9000
/// candidates walk its whole suffix before the marker-walk budget — quadratic without it. Nothing here
/// is a JPEG (no frame header), so the answer is `None` either way.
#[test]
fn cr3_preview_boxes_stop_walking_when_the_budget_is_spent() {
// one 256-byte block per candidate, all marker-aligned so walks chain through later blocks:
// 0..4, size (itself a jump marker of 40 bytes); 4..8, "PRVW"; 20..24, the claimed length
// (to the file end); 24..28, an SOI plus a marker jumping to the fill at 40; 64..256, a fill of
// 4-byte markers (`ff 01 00 02`).
let blocks = 9000usize;
let total = blocks * 256;
let mut f = Vec::with_capacity(total);
for k in 0..blocks {
let base = k * 256;
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x26, b'P', b'R', b'V', b'W', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
f.extend_from_slice(&u32::try_from(total - base - 24).unwrap().to_be_bytes());
f.extend_from_slice(&[0xff, 0xd8, 0x00, 0x24, 0xff, 0x01, 0x00, 0x26, 0, 0, 0, 0, 0, 0, 0, 0]);
f.resize(base + 64, 0); // 40..64 filler: walks jump over it, so its bytes are free
for _ in 0..48 {
f.extend_from_slice(&[0xff, 0x01, 0x00, 0x02]);
}
}
assert_eq!(f.len(), total);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(cr3_preview_boxes(&f).map(|s| s.len())));
assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(2)), Ok(None), "no PRVW candidate is a JPEG");
}
/// A minimal RAF: the fixed header (JPEG offset/length at 84/88), the JPEG, then a raw tail the
/// caller must not have to read. `jpeg_len` is written as declared, so a test can claim more than
/// the JPEG holds the way a padded container does.
+94 -5
View File
@@ -35,6 +35,12 @@ const MAX_ENTRIES: u64 = 8192;
const MAX_BOXES: usize = 4096;
/// Child IFDs listed by one `SubIFDs` entry.
const MAX_CHILDREN: u64 = 4096;
/// Pending child-IFD offsets the walker may queue at once: no more than it can ever visit
/// (the same [`MAX_IFDS`] the seen set is held to, deduplicated as they are popped), so an alley
/// of hostile `SubIFDs` entries can't grow the queue into the tens of millions of pointers.
/// The plan never guesses: the walk refuses and the plan reports the whole file when it would go
/// past the cap.
const MAX_PENDING: usize = MAX_IFDS;
/// The RAF raw container's pointer to its raw IFD.
const RAF_RAW_IFD: u16 = 0xf000;
@@ -308,6 +314,13 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
};
let mut stack = vec![first];
let mut seen = HashSet::new();
// Every offset ever queued: the pending cap counts distinct IFDs, so a thousand references to
// one child cost one visit, not a thousand.
let mut queued = HashSet::from([first]);
// Every IFD table and out-of-line value, collected and merged once at the end: an insert
// per entry would scan the ranges of every entry before it — quadratic in the metadata
// this walker collects.
let mut spans: Vec<(u64, u64)> = Vec::new();
while let Some(off) = stack.pop() {
if off == 0 || off >= len || seen.len() >= MAX_IFDS || !seen.insert(off) {
continue;
@@ -329,7 +342,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
if !w.want(at(off), at(end)) {
continue;
}
meta.insert(at(off), at(end));
spans.push((at(off), at(end)));
for i in 0..n_read {
let p = table + i * entry;
let Ok(pu) = usize::try_from(p) else { continue };
@@ -348,7 +361,7 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
};
let Some(value_end) = start.checked_add(total).filter(|&e| e <= len) else { continue };
if total > inline {
meta.insert(at(start), at(value_end));
spans.push((at(start), at(value_end)));
}
let child = matches!(tag, t::SUB_IFDS | t::EXIF_IFD | t::GPS_IFD | t::INTEROP_IFD) || extra.contains(&tag);
let locates = child || matches!(tag, t::STRIP_OFFSETS | t::STRIP_BYTE_COUNTS | t::TILE_OFFSETS | t::TILE_BYTE_COUNTS);
@@ -359,14 +372,24 @@ fn walk_tiff(w: &mut Want, origin: u64, data: &[u8], extra: &[u16]) -> Result<Ra
let take = if tag == t::SUB_IFDS { count.min(MAX_CHILDREN) } else { count.min(1) };
for k in 0..take {
if let Some(v) = unsigned(data, order, typ, start + k * typ.size() as u64) {
if v == 0 || v >= len || !queued.insert(v) {
continue;
}
if stack.len() >= MAX_PENDING {
return Err("too many child IFDs queued to visit");
}
stack.push(v);
}
}
}
if complete && let Some(next) = offset_at(table_end) {
if complete && let Some(next) = offset_at(table_end).filter(|&next| next != 0 && next < len && queued.insert(next)) {
if stack.len() >= MAX_PENDING {
return Err("too many child IFDs queued to visit");
}
stack.push(next);
}
}
meta.extend(spans);
Ok(meta)
}
@@ -585,8 +608,74 @@ mod tests {
assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles"));
}
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is
/// built in one sort and merge, not one pass over all strips (or all holes) per strip.
/// An IFD whose `SubIFDs` entries would queue 33 million child pointers (8192 entries of
/// 4096 values apiece, all pointing at the same array) is refused at the walker's pending
/// cap instead of growing the queue — and the plan reports the whole file, never guessing.
#[test]
fn a_subifds_alley_is_refused_at_the_pending_cap() {
// First read everything, so the walk reaches the directory table itself.
let n = 8192u32;
let len = 8 + 2 + (n * 12 + 4 + 4 * 4096) as usize;
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend_from_slice(&(n as u16).to_le_bytes());
let mut entry = |tag: u16, typ: u16, count: u32, value: u32| {
tiff.extend_from_slice(&tag.to_le_bytes());
tiff.extend_from_slice(&typ.to_le_bytes());
tiff.extend_from_slice(&count.to_le_bytes());
tiff.extend_from_slice(&value.to_le_bytes());
};
// header (8) + count field (2) + the {n} numbers and pointer (4), then the value array
let values_at = (8 + 2u64 + u64::from(n) * 12 + 4) as u32;
for _ in 0..n {
entry(t::SUB_IFDS, 4, MAX_CHILDREN as u32, values_at);
}
tiff.extend_from_slice(&0u32.to_le_bytes());
tiff.extend((1..=4096u32).flat_map(u32::to_le_bytes));
let mut loaded = Ranges::new();
loaded.insert(0, len as u64);
let mut w = Want::new(&tiff, &loaded);
assert_eq!(walk_tiff(&mut w, 0, &tiff, &[]), Err("too many child IFDs queued to visit"));
}
/// A TIFF with many out-of-line values across 512 directories is planned in one sort and
/// merge, not one pass over all accumulated ranges per value (CodeRabbit #52: before, each
/// `Ranges::insert` copied every range found before it — the ~million disjoint values of the
/// fixture below ask for more copying than the timeout allows).
#[test]
fn many_out_of_line_values_plan_fast() {
let (ifds, values) = (512usize, 2048usize);
let ifd_len = 2 + values * 12 + 4;
let values_at = 8 + ifds * ifd_len;
let len = values_at + values * 8;
let mut tiff = vec![0u8; len];
tiff[..8].copy_from_slice(b"II*\0\x08\0\0\0");
for i in 0..ifds {
let at = 8 + i * ifd_len;
// {values} out-of-line strings (count 8 > the 4 inline bytes), then the next-IFD
// pointer; every value is a disjoint 8-byte range for the merge to handle.
tiff[at..at + 2].copy_from_slice(&(values as u16).to_le_bytes());
for k in 0..values {
let e = at + 2 + k * 12;
tiff[e..e + 2].copy_from_slice(&t::MAKE.to_le_bytes());
tiff[e + 2..e + 4].copy_from_slice(&2u16.to_le_bytes()); // ASCII: 8 bytes > inline — a span per entry
tiff[e + 4..e + 8].copy_from_slice(&8u32.to_le_bytes());
tiff[e + 8..e + 12].copy_from_slice(&(values_at as u32 + (k * 8) as u32).to_le_bytes());
}
let next = if i == ifds - 1 { 0u32 } else { (at + ifd_len) as u32 };
tiff[at + ifd_len - 4..at + ifd_len].copy_from_slice(&next.to_le_bytes());
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut loaded = Ranges::new();
loaded.insert(0, len as u64);
let _ = tx.send(header_plan(&tiff, &loaded));
});
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s");
}
/// A TIFF with a million one-byte strips listed in descending order and all byte counts 0:
/// the plan is built in one sort and merge, not one pass over all strips (or all holes) per
/// strip.
#[test]
fn many_strips_without_byte_counts_plan_fast() {
let n = 1_000_000u32;
+205 -36
View File
@@ -105,48 +105,93 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
let chunks = info.chunks(data.len() as u64);
let planar = info.planar == 2 && cpp > 1;
let ccpp = if planar { 1 } else { cpp };
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
chunks.par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
// The chunks are decoded and copied into the output in groups whose combined nominal size stays within
// a few times the output buffer's: everything decoded at once would retain the whole image twice, and
// a hostile tile table's chosen padding could stretch that much further (issue #1517). One group
// still decodes in parallel; the grid order groups make, the copies stay disjoint.
// The tile grid decides how much is decoded, whatever the file's bytes: the nominal sizes of all
// the chunks stay within a few times the image (edge tiles pad it) before any is decoded.
let nominal = chunks.iter().fold(0usize, |sum, c| sum.saturating_add(chunk_samples(c, ccpp)));
if nominal > total.saturating_mul(4).saturating_add(1 << 24) {
return Err(RawError::Corrupt("the tile grid is much larger than the image".into()));
}
let group_samples = (total / 4).max(1 << 20);
let mut out_u16 = if float { Vec::new() } else { vec![0u16; total] };
let mut out_f32 = if float { vec![0f32; total] } else { Vec::new() };
for r in decoded {
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
continue;
let mut decode_from = 0usize;
while decode_from < chunks.len() {
let mut to = decode_from;
let mut taken = 0usize;
for (k, c) in chunks[decode_from..].iter().enumerate() {
let n = chunk_samples(c, ccpp);
if taken > 0 && taken.saturating_add(n) > group_samples {
break;
}
taken = taken.saturating_add(n);
to = decode_from + k + 1;
}
let copy_w = cw.min(w - x0);
let copy_h = ch.min(h - y0);
for y in 0..copy_h {
for x in 0..copy_w {
for s in 0..ccpp {
let src = (y * cw + x) * ccpp + s;
let dst_s = if planar { c.plane as usize } else { s };
if dst_s >= cpp {
continue;
}
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
match &px {
ChunkPx::U16(v) => {
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
*o = s;
}
let decoded: Vec<Result<(Chunk, ChunkPx)>> =
chunks[decode_from..to].par_iter().map(|c| decode_chunk(data, info, c, order, packing, ccpp, bits, float).map(|p| (*c, p))).collect();
for r in decoded {
// a chunk that cannot be decoded is not a partial image: the file stays preview-only
let (c, px) = r?;
let (cw, ch) = (c.width as usize, c.height as usize);
let (x0, y0) = (c.x as usize, c.y as usize);
if x0 >= w || y0 >= h {
continue;
}
let copy_w = cw.min(w - x0);
let copy_h = ch.min(h - y0);
for y in 0..copy_h {
for x in 0..copy_w {
for s in 0..ccpp {
let src = (y * cw + x) * ccpp + s;
let dst_s = if planar { c.plane as usize } else { s };
if dst_s >= cpp {
continue;
}
ChunkPx::F32(v) => {
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
*o = s;
let dst = ((y0 + y) * w + x0 + x) * cpp + dst_s;
match &px {
ChunkPx::U16(v) => {
if let (Some(o), Some(&s)) = (out_u16.get_mut(dst), v.get(src)) {
*o = s;
}
}
ChunkPx::F32(v) => {
if let (Some(o), Some(&s)) = (out_f32.get_mut(dst), v.get(src)) {
*o = s;
}
}
}
}
}
}
}
decode_from = to;
}
Ok(if float { RawData::F32(out_f32) } else { RawData::U16(out_u16) })
}
/// Bytes that `n` samples take in a row of an uncompressed or inflated chunk, laid out as
/// [`unpack_chunk`] reads them: whole bytes or 16-bit words, or 32-bit words for `Words32Msb`.
fn packed_row_bytes(n: u64, bits: u32, packing: Packing, float: bool) -> u64 {
if float {
return n.saturating_mul(u64::from(bits.div_ceil(8)));
}
let bit_len = n.saturating_mul(u64::from(bits));
match (bits, packing) {
(8, _) => n,
(16, _) | (_, Packing::Word16) => n.saturating_mul(2),
(_, Packing::Msb | Packing::Lsb) => bit_len.div_ceil(8),
(_, Packing::Words32Msb) => bit_len.div_ceil(32).saturating_mul(4),
}
}
/// A chunk's nominal decoded size in samples (per plane, as the decoder builds it).
fn chunk_samples(c: &Chunk, ccpp: usize) -> usize {
(c.width as usize).saturating_mul(c.height as usize).saturating_mul(ccpp)
}
#[allow(clippy::too_many_arguments)]
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
@@ -179,9 +224,13 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
}
comp::LOSSY_JPEG => {
// lossy DNG: each chunk is a baseline (DCT) JPEG of 8-bit samples
let (px, jw, jh, jc) = lossy_jpeg(src, cpp)?;
let mut v = vec![0u16; n];
for y in 0..ch.min(jh) {
let (px, jw, jh, jc) = lossy_jpeg(src, cpp, MAX_JPEG_SAMPLES)?;
// the buffer holds the rows the decoded JPEG covers (the caller zero-fills the tile's
// remainder and reads only rows it fills): a tile-many-larger decoded JPEG does not
// get the nominal tile's full buffer (issue #1517)
let rows = ch.min(jh);
let mut v = vec![0u16; rows.max(1).saturating_mul(cw).saturating_mul(cpp)];
for y in 0..rows {
for x in 0..cw.min(jw) {
for k in 0..cpp {
v[(y * cw + x) * cpp + k] = px[(y * jw + x) * jc + k.min(jc - 1)] as u16;
@@ -191,13 +240,28 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
Ok(ChunkPx::U16(v))
}
comp::ADOBE_DEFLATE | comp::DEFLATE => {
let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 };
// row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate
let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) };
let row_bytes = packed_row_bytes(cw as u64 * cpp as u64, bits, packing, float);
// the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too
let inflate_len =
crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?;
// Deflate compresses at most about 1:1032, so a source this far under half of its
// nominal length cannot reconstruct the chunk: refused before the nominal buffer is
// inflated and predicted, which padding-only work would make gigabytes.
if (src.len() as u64).saturating_mul(2048) < inflate_len {
return Err(RawError::Corrupt(format!("a deflated chunk of {inflate_len} bytes is packed into only {} bytes", src.len())));
}
let raw = inflate(src, inflate_len as usize)?;
// The stream has to reach the last row inside the image (edge strips may stop there);
// it is laid out in nominal rows, so shorter is a truncated chunk, not a picture.
let last = packed_row_bytes(vw as u64 * cpp as u64, bits, packing, float);
let visible = row_bytes.saturating_mul(vh.saturating_sub(1) as u64).saturating_add(last);
if (raw.len() as u64) < visible {
return Err(RawError::Corrupt(format!(
"a deflated chunk holds {} bytes, fewer than the {visible} bytes its in-image samples span",
raw.len()
)));
}
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
}
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
@@ -228,19 +292,31 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp:
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
}
/// The most samples a tiled JPEG chunk or a white-balance preview may decode to: far more than
/// any real lossy tile or preview carries, and small enough that a JPEG header alone cannot size
/// a multi-gigabyte allocation.
pub(crate) const MAX_JPEG_SAMPLES: usize = 1 << 26;
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize, max_samples: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
use zune_core::bytestream::ZCursor;
use zune_core::colorspace::ColorSpace;
use zune_core::options::DecoderOptions;
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(cs);
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(src), opts);
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
// The header is read first: the JPEG's own dimensions, not the enclosing tile's, size every
// allocation `decode` makes, and they may claim billions of pixels.
d.decode_headers().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
let info = d.info().ok_or_else(|| RawError::Corrupt("lossy JPEG tile without a header".into()))?;
let (w, h) = (info.width as usize, info.height as usize);
let c = if cpp == 1 { 1 } else { 3 };
if w == 0 || h == 0 || px.len() < w * h * c {
let out = w as u64 * h as u64 * c as u64;
if w == 0 || h == 0 || out > max_samples as u64 {
return Err(RawError::Corrupt(format!("a lossy JPEG of {w} × {h} × {c} would exceed the {max_samples} output samples allowed")));
}
let px = d.decode().map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}")))?;
if px.len() < w * h * c {
return Err(RawError::Corrupt("lossy JPEG tile: short output".into()));
}
Ok((px, w, h, c))
@@ -471,4 +547,97 @@ mod lossy_tests {
// half of the visible rows are missing
assert!(decode(8, 4).is_err());
}
/// A lossy JPEG whose header alone claims more samples than any real tile or preview (here
/// 40000 × 40000 × 3) is refused from the header, before the decoder allocates its output
/// from the JPEG's own dimensions.
#[test]
fn an_oversized_lossy_jpeg_header_is_refused_before_decoding() {
// A real 8 × 8 baseline JPEG, whose frame header then claims other dimensions: the decoder
// parses the header, and the size check is what refuses it.
let px = vec![128u8; 8 * 8 * 3];
let mut j = Vec::new();
jpeg_encoder::Encoder::new(&mut j, 90).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
// SOF0: marker, length, precision, then the height and the width
let sof = j.windows(2).position(|w| w == [0xff, 0xc0]).expect("a baseline frame header");
let claim = |j: &mut Vec<u8>, size: u16| {
j[sof + 5..sof + 7].copy_from_slice(&size.to_be_bytes());
j[sof + 7..sof + 9].copy_from_slice(&size.to_be_bytes());
};
claim(&mut j, 40_000);
let RawError::Corrupt(refused) = lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES).unwrap_err() else { panic!("a corrupt-data error") };
assert!(refused.contains("would exceed"), "{refused}");
// a header inside the budget passes the size check (and then fails to decode only for lack of data)
claim(&mut j, 1024);
match lossy_jpeg(&j, 3, MAX_JPEG_SAMPLES) {
Ok(_) => {}
Err(RawError::Corrupt(m)) => assert!(!m.contains("would exceed"), "the smaller header was refused for its size: {m}"),
Err(other) => panic!("unexpected error: {other:?}"),
}
}
/// A lossy tile whose JPEG decodes far less than the nominal tile is fine: the rows the JPEG
/// covers hold its pixels, the rest of the output stays zero (and the tile's intermediate buffer
/// is sized by the JPEG, not by the nominal tile).
#[test]
fn a_small_lossy_jpeg_in_a_large_nominal_tile_decodes_with_zeros_below() {
let px: Vec<u8> = (0..8 * 8).flat_map(|_| [30u8, 90, 150]).collect();
let mut j = Vec::new();
jpeg_encoder::Encoder::new(&mut j, 100).encode(&px, 8, 8, jpeg_encoder::ColorType::Rgb).unwrap();
let info = ImageInfo {
width: 16,
height: 16,
bits_per_sample: vec![8, 8, 8],
samples_per_pixel: 3,
compression: comp::LOSSY_JPEG,
photometric: 34892,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: 16, tile_height: 16 },
offsets: vec![0],
byte_counts: vec![j.len() as u64],
};
let RawData::U16(v) = read_image(&j, &info, ByteOrder::Little, Packing::Msb).unwrap() else { panic!("integer samples") };
assert_eq!(v.len(), 16 * 16 * 3, "the output is the full nominal image");
let px = |x: usize, y: usize| &v[(y * 16 + x) * 3..(y * 16 + x) * 3 + 3];
assert!((px(3, 3)[0] as i32 - 30).abs() <= 3 && (px(3, 3)[2] as i32 - 150).abs() <= 3, "covered rows: {:?}", px(3, 3));
assert!(px(3, 12).iter().all(|&s| s == 0), "rows below the JPEG stay zero: {:?}", px(3, 12));
}
/// A deflated tile is refused when its packed bytes are too few for deflate to have produced
/// the nominal chunk, or when the stream stops short of the rows inside the image; a fully
/// covered flat tile still decodes.
#[test]
fn a_short_or_undercovering_deflated_tile_is_refused() {
let info = |byte_counts: u64| ImageInfo {
width: 512,
height: 512,
bits_per_sample: vec![16],
samples_per_pixel: 1,
compression: comp::ADOBE_DEFLATE,
photometric: 32803,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: 512, tile_height: 512 },
offsets: vec![0],
byte_counts: vec![byte_counts],
};
// a complete flat tile: 512 KiB of zeros compresses to a few hundred bytes (well within
// deflate's packing bound) and inflates to every nominal byte the tile claims
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 512 * 512 * 2], 6);
assert_eq!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Ok(RawData::U16(vec![0; 512 * 512])));
// the same 1 KiB packed, but the stream claims a whole tile: a few hundred bytes cannot
// reconstruct 512 KiB from a decoder that compresses no further than about 1:1032
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1024], 6);
assert!(matches!(read_image(&zlib, &info(512 * 512 * 2), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))), "{zlib:?}");
// 500 KiB of zeros compress to a stream within the packing bound and inflating to more
// bytes than the in-image samples span, but 24 KiB short of the whole nominal tile:
// refused as truncated rather than filled with the predictor's padding
let zlib = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 500 * 1000], 6);
assert!(matches!(read_image(&zlib, &info(zlib.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
}
}
+58 -1
View File
@@ -89,6 +89,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
// the white balance is measured from the samples, so the header probe reads them too
let chunks = info.chunks(bytes.len() as u64);
let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?;
// 12-bit MSB packing takes 3 bytes per 2 samples; a strip that cannot hold the declared
// samples is a truncated file, not a picture — unpacking it would run past the real bytes
// through every output sample (a short strip asks for the whole allocation and more).
let need = n.div_ceil(2).saturating_mul(3);
if src.len() < need {
return Err(RawError::Corrupt(format!("a Mef strip of {} bytes cannot hold the declared {n} samples", src.len())));
}
let mut d = vec![0u16; n];
crate::unpack::unpack_msb(src, 12, &mut d);
let white = super::white_from_data(&d, 12);
@@ -138,6 +145,10 @@ mod tests {
}
fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> {
sample_strip(w, h, &pack(px))
}
fn sample_strip(w: u32, h: u32, strip: &[u8]) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
@@ -148,7 +159,7 @@ mod tests {
raw.set(t::ORIENTATION, Value::Short(vec![6]));
raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] });
raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip.to_vec()] });
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into()));
ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into()));
@@ -156,6 +167,52 @@ mod tests {
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
/// A strip that cannot hold the declared samples is a truncated file, refused as corrupt
/// instead of unpacked: `unpack_msb` would otherwise run `n` samples through the few real
/// bytes, filling the rest from nothing.
#[test]
fn a_strip_too_short_for_the_declared_samples_is_refused() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
let f = sample(16, 6, &px);
assert!(crate::decode(&f).is_ok());
// A truncated file shaped like a real one: IFD tables first, strip last — only its first
// 20 packed bytes remain, while the IFD still declares the full 1.5 B/sample length. The
// probe accepts the file on the declared length; before the byte gate below, `unpack_msb`
// ran the dimension-sized allocation through those 20 bytes, filling it from nothing.
let u32be = |b: &[u8], a: usize| u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
let full = pack(&px);
let at = f.windows(full.len()).position(|w| w == full).expect("strip bytes in the file");
let after_strip = at + full.len();
let moved = after_strip - (at + 20);
let mut g = f[..8].to_vec();
g.extend(std::iter::repeat_n(0u8, at + 20 - 8));
g.extend_from_slice(&f[after_strip..]);
// repoint the first-IFD pointer and ifd0's MAKE / MODEL / SubIfds offsets (the tables now
// sit `moved` earlier); the raw sub-IFD's strip offset and byte count are patched below
let poke = |b: &mut [u8], a: usize| {
let v = u32::from_be_bytes([b[a], b[a + 1], b[a + 2], b[a + 3]]);
if v as usize >= after_strip {
b[a..a + 4].copy_from_slice(&(v - moved as u32).to_be_bytes());
}
};
let ifd0_at = u32be(&g, 4) as usize - moved;
g[4..8].copy_from_slice(&(ifd0_at as u32).to_be_bytes());
let n = u16::from_be_bytes([g[ifd0_at], g[ifd0_at + 1]]) as usize;
for i in 0..n {
poke(&mut g, ifd0_at + 2 + i * 12 + 8);
}
// the raw sub-IFD: entries follow its count; tag 273 (StripOffsets) is the seventh, held
// inline — repoint it at the strip's new home, and append the truncated strip for real
let sub_at = u32be(&g, ifd0_at + 2 + 2 * 12 + 8) as usize;
let e_offsets = sub_at + 2 + 6 * 12 + 8;
let strip_at = g.len() as u32;
g[e_offsets..e_offsets + 4].copy_from_slice(&strip_at.to_be_bytes());
g.extend_from_slice(&full[..20]);
assert_eq!(crate::probe(&g), Some(RawFormat::Mef));
let r = crate::decode(&g);
assert!(matches!(r, Err(RawError::Corrupt(_))), "{r:?}");
}
#[test]
fn decodes_packed_12_bit() {
let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect();
+1 -1
View File
@@ -66,7 +66,7 @@ fn median(v: &mut [f64]) -> Option<f64> {
fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> {
let jpeg = crate::preview::embedded_preview(bytes)?;
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?;
let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3, crate::tiffraw::MAX_JPEG_SAMPLES).ok()?;
if ch != 3 {
return None;
}
+36 -4
View File
@@ -118,14 +118,34 @@ impl ImageInfo {
}
}
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
/// using `data_len`.
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
/// The chunk count the image declares (offsets present, cut to the layout's plane capacity) with the
/// geometry `chunks` needs to place each one. No descriptors are built, so a pathological strip array
/// costs it a length check.
fn chunk_grid(&self) -> (usize, u32, usize) {
let (across, down) = self.grid();
let per_plane = across as usize * down as usize;
let planes = if self.planar == 2 { self.samples_per_pixel as usize } else { 1 };
(self.offsets.len().min(per_plane.saturating_mul(planes)), across, per_plane)
}
fn declared_chunk_count(&self) -> usize {
self.chunk_grid().0
}
/// The one chunk when the image has exactly one, else `None` — and no descriptor vector beyond it. Preview
/// extraction only serves single-chunk images, so it asks here before serving a hostile multi-strip layout.
pub fn single_chunk(&self, data_len: u64) -> Option<Chunk> {
if self.declared_chunk_count() != 1 {
return None;
}
self.chunks(data_len).into_iter().next()
}
/// All chunks that have an offset. Missing byte counts are inferred as "to the next offset / end of data"
/// using `data_len`.
pub fn chunks(&self, data_len: u64) -> Vec<Chunk> {
let (n, across, per_plane) = self.chunk_grid();
let (cw, ch) = self.chunk_size();
let n = self.offsets.len().min(per_plane.saturating_mul(planes));
let mut out = Vec::with_capacity(n);
// the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk)
let mut sorted: Vec<u64> = Vec::new();
@@ -192,6 +212,18 @@ mod tests {
}
}
/// One offset is one chunk, and `single_chunk` refuses to build descriptors when the image declares more.
#[test]
fn single_chunk_is_the_only_chunk() {
let one = strips(vec![50], vec![10]);
assert_eq!(one.single_chunk(100).map(|c| (c.index, c.offset, c.len)), one.chunks(100).first().map(|c| (c.index, c.offset, c.len)));
// the inferred length matches too, and never falls back past the data
let inferred = strips(vec![70], vec![]);
assert_eq!(inferred.single_chunk(100).map(|c| c.len), Some(30));
assert!(strips(vec![50, 10], vec![10, 5]).single_chunk(100).is_none(), "a multi-chunk image has no single chunk");
assert!(strips(Vec::new(), Vec::new()).single_chunk(100).is_none());
}
#[test]
fn missing_byte_counts_run_to_the_next_larger_offset() {
let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]);
+34 -1
View File
@@ -55,7 +55,11 @@ pub fn parse_makernote(data: &[u8], offset: u64, len: u64, order: ByteOrder, mak
Some(b"MM") => Some(ByteOrder::Big),
_ => None,
};
let opts = ParseOptions { max_ifds: 64, max_depth: 3, ..Default::default() };
// A fixed value budget: a maker note is at most tens of kilobytes of values in any honest file, and
// computing the budget from the buffer's length multiplies the file length (a hostile entry in a
// sparse header-read buffer, where the buffer holds the file's logical size but only a few
// kilobytes are loaded, would decode allocations no actual data backs).
let opts = ParseOptions { max_ifds: 64, max_depth: 3, value_budget: Some(1 << 20), ..Default::default() };
let try_parse = |kind: MakerNoteKind, ifd_at: u64, base: u64, ord: ByteOrder| -> Option<MakerNote> {
let (ifd, _) = parse_ifd_at(data, ifd_at, ord, base, false, &opts).ok()?;
(!ifd.entries.is_empty()).then_some(MakerNote { kind, order: ord, base, ifd })
@@ -192,6 +196,35 @@ mod tests {
assert_eq!(m.ifd.u64s(2).unwrap(), vec![9, 0]);
}
/// A maker note's value budget is fixed, not computed from the length of the buffer it sits in:
/// an entry claiming more than the note budget (here 2 MB) is dropped, a fitting one is read. In a
/// sparse header read the buffer holds the file's logical size while only kilobytes were loaded,
/// so a length-scaled budget would let an unrelated entry decode megabytes of zeros (see the focus
/// reader, try_read_exif_with).
#[test]
fn a_note_entry_past_the_fixed_value_budget_is_dropped() {
let order = ByteOrder::Little;
let mut note = b"SONY DSC \0\0\0".to_vec();
// the Sony IFD directly after the 12-byte header, one fitting BYTE entry and a decoy
// BYTE[2 MB] entry pointing into the file's own (zero-filled) body
order.put_u16(&mut note, 2);
order.put_u16(&mut note, 0x201b);
order.put_u16(&mut note, 1);
order.put_u32(&mut note, 1);
note.extend([3, 0, 0, 0]);
order.put_u16(&mut note, 0x9900);
order.put_u16(&mut note, 1);
order.put_u32(&mut note, 2 << 20);
order.put_u32(&mut note, 200);
order.put_u32(&mut note, 0);
let mut bytes = note.clone();
bytes.resize(200 + (2 << 20), 0); // the decoy's value range is inside the real bytes
let m = parse_makernote(&bytes, 0, note.len() as u64, order, "SONY").unwrap();
assert_eq!(m.kind, MakerNoteKind::Sony);
assert!(m.ifd.get(0x201b).is_some(), "the fitting entry is read");
assert!(m.ifd.get(0x9900).is_none(), "the decoy is dropped by the value budget");
}
#[test]
fn garbage_note_is_none() {
let (bytes, off, len) = with_note(ByteOrder::Little, vec![0xff; 40]);
+19
View File
@@ -338,6 +338,25 @@ pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use crate::Value;
use crate::tags as t;
use crate::writer::{IfdBuilder, TiffWriter};
/// A value budget the caller sets bounds what is decoded: an entry claiming more value bytes than
/// the budget allows is dropped, entries that fit are read (the sparse header-read path, where the
/// buffer holds the file's logical size but only a few kilobytes were ever loaded, sizes its
/// budget from what was fetched, not from whatever length the file claims).
#[test]
fn the_value_budget_drops_over_budget_entries_and_keeps_the_rest() {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("Keep".into()));
ifd0.set(0x9000, Value::Byte(vec![0; 2 << 20]));
let file = TiffWriter { order: ByteOrder::Little, bigtiff: false }.write(&[ifd0]).unwrap();
let opts = ParseOptions { value_budget: Some(1 << 20), ..Default::default() };
let tiff = Tiff::parse_with(&file, &opts).unwrap();
assert_eq!(tiff.ifds[0].string(t::MAKE), Some("Keep".to_string()), "a fitting entry is read");
assert!(tiff.ifds[0].get(0x9000).is_none(), "the over-budget entry is dropped");
}
/// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer
/// `next`; a valid one-entry IFD sits at 16.
+3
View File
@@ -44,3 +44,6 @@ lightcraft-merge = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-update = { workspace = true }
+7 -1
View File
@@ -50,6 +50,7 @@ pub mod tasks;
pub mod text_field;
pub mod theme;
pub mod titlebar;
pub mod updates_ui;
pub mod widgets;
pub mod window_place;
@@ -409,6 +410,7 @@ pub struct LightcraftApp {
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
pub notices: Vec<String>,
pub(crate) updates_rt: updates_ui::Runtime,
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
pub quit_prompt: Option<QuitPrompt>,
/// Keyboard/focus/input state for the prompt above, including its one-frame creation barrier.
@@ -615,6 +617,7 @@ impl LightcraftApp {
text_focus: false,
headless_host: false,
notices: vec![],
updates_rt: Default::default(),
quit_prompt: None,
notice_input: Default::default(),
quit_confirmed: false,
@@ -1966,6 +1969,7 @@ impl LightcraftApp {
lightroom_import::tick(self, ctx);
recheck::tick(self);
tasks::poll(self, ctx);
updates_ui::tick(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
self.ai_mask_detail(ctx, now);
@@ -2123,7 +2127,7 @@ impl LightcraftApp {
// Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI
// phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract).
panels::library_problem::logic(self, ctx);
if self.fonts_ready {
if self.fonts_ready && !updates_ui::blocks_input(self) {
shortcuts::handle(self, ctx);
}
}
@@ -2626,6 +2630,7 @@ impl LightcraftApp {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
@@ -2656,6 +2661,7 @@ impl LightcraftApp {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
export_task::poll(self, &ctx);
slideshow_export::poll(self, &ctx);
+1
View File
@@ -322,6 +322,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"app.systemInfo",
"app.openLogFolder",
"---",
"app.checkForUpdates",
"app.about",
],
),
+2
View File
@@ -263,6 +263,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
// Settings ▸ Display
("app.displayProfile", "Display Profile", None, ""),
("app.about", "About LightCraft", None, "Help"),
("app.checkForUpdates", "Check for Updates…", None, "Help"),
("app.systemInfo", "System Info…", None, "Help"),
("app.openLogFolder", "Open Log Folder", None, "Help"),
// the frame-rate display (`frame_rate`): a debugging aid, never saved
@@ -1589,6 +1590,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
app.ui.dialog = Some(Dialog::About);
Ok(Value::Null)
}
"app.checkForUpdates" => crate::updates_ui::check(app),
"photo.editInPhotoCraft" => edit_in(app, &ctx, p, true),
"photo.editInExternal" => edit_in(app, &ctx, p, false),
"photo.openOriginalInExternal" => open_original_in(app, &ctx, p),
+188
View File
@@ -0,0 +1,188 @@
//! Software updates, background check at startup, and startup prompt modal.
use serde_json::Value;
use crate::LightcraftApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater,
/// The desktop app turns the check at start on; tests and the web build leave it off.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
}
impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::LIGHTCRAFT, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
manual_window_open: false,
status_message: None,
}
}
}
impl LightcraftApp {
/// Whether the app asks for a newer release when it starts (off until the desktop app turns it on).
#[cfg(not(target_arch = "wasm32"))]
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
}
/// The update prompt owns the keyboard while it is open: shortcuts must not reach the library behind it.
pub(crate) fn blocks_input(app: &LightcraftApp) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.updater.prompt_open()
}
#[cfg(target_arch = "wasm32")]
{
let _ = app;
false
}
}
pub(crate) fn check(app: &mut LightcraftApp) -> Result<Value, String> {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
Ok(Value::Null)
}
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
Ok(Value::Null)
}
}
pub(crate) fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
let rt = &mut app.updates_rt;
rt.repaint = Some(ctx.clone());
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
rt.updater.tick(ctx);
}
#[cfg(target_arch = "wasm32")]
let _ = (app, ctx);
}
pub(crate) fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
let clean = app.session.unsaved().is_none();
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, Some(crate::i18n::tr("Checking for updates…").to_string())),
Status::UpToDate => (
true,
Some(
crate::i18n::tr("LightCraft {version} is currently the newest version available.")
.replace("{version}", env!("CARGO_PKG_VERSION")),
),
),
Status::Failed(e) => (true, Some(crate::i18n::tr("Could not check for updates:\n{error}").replace("{error}", e))),
};
}
if app.updates_rt.manual_window_open {
let mut open = true;
egui::Window::new(crate::i18n::tr("LightCraft Updates"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
}
ui.add_space(8.0);
if ui.button(crate::i18n::tr("OK")).clicked() {
app.updates_rt.manual_window_open = false;
}
});
if !open {
app.updates_rt.manual_window_open = false;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_engine::Session;
#[test]
fn default_runtime_is_idle() {
let rt = Runtime::default();
assert!(!rt.manual_window_open);
#[cfg(not(target_arch = "wasm32"))]
{
assert!(!rt.startup_check);
assert!(!rt.updater.prompt_open());
assert_eq!(rt.updater.status(), &Status::Idle);
}
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn startup_prompt_shows_and_closes() {
let mut app = LightcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let release = craft_update::Release {
tag: "v99.0.0".into(),
version: craft_update::parse_version("99.0.0"),
name: "LightCraft 99.0.0".into(),
html_url: "https://github.com/storytold/lightcraft/releases/tag/v99.0.0".into(),
..Default::default()
};
app.updates_rt.updater.offer(release);
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
show(&mut app, ui.ctx());
});
output.textures_delta.clear();
assert!(app.updates_rt.updater.prompt_open());
}
}
Binary file not shown.
+1 -2
View File
@@ -14,8 +14,7 @@ def _jsonl_path(arg):
if not path.is_file():
sys.exit(f"not a readable file: {arg}")
try:
with path.open("rb"):
pass
path.open("rb").close()
except OSError:
sys.exit(f"not a readable file: {arg}")
return path
+1 -1
View File
@@ -14,4 +14,4 @@ serde_json = { workspace = true }
sha2 = { workspace = true }
# precompressed web bundle (`cargo xtask web`); pure Rust
flate2 = "1"
brotli = { version = "8", default-features = false, features = ["std"] }
brotli = { version = "9", default-features = false, features = ["std"] }