Plug-ins: oversized TIFFs stay out of a filter's decoder, and read-only ids compare by value

A TIFF with more strips and planes than the decoder can count comes to a filter as "unknown"
with no pixels, instead of panicking in the metadata read. An output that writes a read-only
object's id as 17.0 now names that object, as other numbers in the object compare by value; a
fractional id still names no object.
This commit is contained in:
dotCooCoo
2026-11-08 16:01:40 -08:00
parent 3f484c4f0d
commit 2af6f54eab
2 changed files with 54 additions and 13 deletions
+21 -13
View File
@@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, CmykLut, lab};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::cmyk::oversized_tiff;
use vectorcraft_doc::{
Appearance, AppearanceItem, Document, Effect, FillLayer, ImageBlob, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, SymbolBoxes,
TextKind, TextObject,
@@ -437,8 +438,12 @@ fn text_json(t: &TextObject, left: Option<&mut usize>) -> (Value, bool) {
}
/// How image `b` stores its colors, read from its header: `"cmyk"`, `"gray"`, `"rgb"`, or
/// `"unknown"` when it can't be read or only a linked image's preview is there.
/// `"unknown"` when it can't be read, is a TIFF too large for the decoder to count, or only a
/// linked image's preview is there.
fn color_mode(b: &ImageBlob) -> &'static str {
if oversized_tiff(&b.bytes).is_some() {
return "unknown";
}
if b.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &b.bytes) || **p == *b.bytes) {
return "unknown";
}
@@ -457,7 +462,7 @@ fn color_mode(b: &ImageBlob) -> &'static str {
/// characters of base64; the size is checked, and read from the header, before anything is decoded.
fn rgba_pixels(b: &ImageBlob, w: u32, h: u32, max: usize) -> Option<Vec<u8>> {
let bytes = u64::from(w).checked_mul(u64::from(h))?.checked_mul(4)?;
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 {
if bytes == 0 || bytes.div_ceil(3).saturating_mul(4) > max as u64 || oversized_tiff(&b.bytes).is_some() {
return None;
}
if b.is_cmyk() {
@@ -802,19 +807,16 @@ fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<O
let out = objects
.iter()
.enumerate()
.map(|(i, o)| {
let id = o.get("id").and_then(Value::as_u64).map(NodeId);
match id.and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
let at = format!("objects[{i}] (id {})", id.0);
if !returned.insert(id) {
return Err(out_err(&at, "is listed twice"));
}
check_unchanged(o, given, &at)?;
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
.map(|(i, o)| match output_id(o).and_then(|id| Some((id, read_only.get(&id)?))) {
Some((id, given)) => {
let at = format!("objects[{i}] (id {})", id.0);
if !returned.insert(id) {
return Err(out_err(&at, "is listed twice"));
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
check_unchanged(o, given, &at)?;
Ok(OutObject { id: Some(id), kept: true, ..OutObject::default() })
}
None => decode_object(o, &format!("objects[{i}]"), &mut anchors),
})
.collect::<Result<Vec<_>>>()?;
let mut missing: Vec<NodeId> = read_only.keys().filter(|id| !returned.contains(id)).copied().collect();
@@ -828,6 +830,12 @@ fn decode_objects(v: &Value, read_only: &HashMap<NodeId, Value>) -> Result<Vec<O
Ok(out)
}
/// The id entry `o` gives, a whole number written either way (`17` or `17.0`).
fn output_id(o: &Value) -> Option<NodeId> {
let id = o.get("id")?;
id.as_u64().or_else(|| id.as_f64().filter(|f| f.fract() == 0.0 && (0.0..=9_007_199_254_740_992.0).contains(f)).map(|f| f as u64)).map(NodeId)
}
/// Checks entry `o` of a filter's output for a read-only object it was `given`: `{"id": …}` alone,
/// or `given` itself, numbers compared by value. Otherwise the error names the first field that
/// differs (`text.runs[0].text`): a field the entry gives before one it leaves out, shown as
+33
View File
@@ -653,6 +653,31 @@ fn objects_that_clip_a_clip_group_are_not_inputs() {
assert_eq!(filter_targets(&d, &[by_paths]), [clipped_by_paths.id], "a filter without inputs");
}
/// A TIFF with more strips and planes than the decoder can count comes as `"unknown"`, without its
/// pixels, and never reaches the decoder.
#[test]
fn a_tiff_too_large_to_count_is_unknown_and_has_no_pixels() {
let entries: [(u16, u16, u32); 9] =
[(256, 4, 1), (257, 4, 1 << 31 | 1), (258, 3, 8), (262, 3, 1), (273, 4, 0), (277, 3, 2), (278, 4, 1), (279, 4, 0), (284, 3, 2)];
let mut tiff = b"II*\0\x08\0\0\0".to_vec();
tiff.extend(u16::try_from(entries.len()).unwrap().to_le_bytes());
for (tag, ty, v) in entries {
tiff.extend(tag.to_le_bytes());
tiff.extend(ty.to_le_bytes());
tiff.extend(1u32.to_le_bytes());
tiff.extend(v.to_le_bytes());
}
tiff.extend([0; 4]);
let (mut d, _, layer, _) = mixed();
d.images.insert("big".into(), ImageBlob::new("image/tiff", tiff));
let big = image_node(&mut d, "big", (1, 1), None);
let big = d.insert(Some(layer), usize::MAX, big).unwrap();
let (bytes, _) = encode_input_with(&d, &[big], &[InputKind::Image], usize::MAX).unwrap();
let input: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(input["objects"][0]["colorMode"], "unknown");
assert_eq!(input["objects"][0]["pixelsOmitted"], true);
}
/// A field a plug-in adds to a read-only object is named in the refusal with its control and
/// direction characters escaped, and cut short.
#[test]
@@ -692,6 +717,14 @@ fn read_only_kind_field_and_omission_refuse() {
let mut echo = input.clone();
echo["objects"][3]["transform"] = json!([1, 0, 0, 1, 5, 50]);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_ok());
// So is an id written as a float; a fractional one names no object.
let id = input["objects"][3]["id"].as_u64().unwrap();
let mut echo = input.clone();
echo["objects"][3]["id"] = json!(id as f64);
let out = decode_output_with(echo.to_string().as_bytes(), &read_only).unwrap();
assert_eq!(out.objects.iter().filter(|o| o.kept).count(), 5);
echo["objects"][3]["id"] = json!(id as f64 + 0.5);
assert!(decode_output_with(echo.to_string().as_bytes(), &read_only).is_err());
// `{"id"}` alone keeps it; the paths left out are deleted and a new path goes above the type.
let ro = |id: NodeId| json!({"id": id.0});
let out = json!({"objects": [ro(text), {"name": "new", "path": square(0.0)}, ro(embedded), ro(linked), ro(star), ro(ghost)]});