Files
photocraft/crates/psd/src/slices.rs
T
Dylan M. Taylor 42d8aad56f security(psd): index slice IDs for trailing descriptor lookup
The version-6 slices parser matched each trailing descriptor ID against
the slice list with iter_mut().find(), an O(n*m) scan over two
file-controlled collections. A crafted resource with many slices and
many unmatched descriptor IDs could therefore cause quadratic CPU work
during import.

Build an ID-to-first-index map once and use it for descriptor lookups.
or_insert preserves the existing first-match behavior for duplicate IDs.

Finding: cf3f9ceb-d6df-4087-a2d6-dbe0f12657c3 (CWE-407)
2026-10-21 21:44:52 -04:00

532 lines
21 KiB
Rust

//! Slices image resource (id 1050), from the Adobe Photoshop File Format Specification.
//!
//! Version 6 is a fixed binary layout (bounding rect, group name, then per slice: ids, origin,
//! optional layer id, name, type, rect, URL / target / message / alt strings, cell text and
//! alignment, background colour), optionally followed by a versioned descriptor carrying extra
//! per-slice data (layer-slice outsets). Versions 7 and 8 store everything as one versioned
//! descriptor (`baseName`, `bounds`, `slices` list).
//!
//! [`SlicesResource::to_bytes`] writes version 6, the documented binary layout every Photoshop
//! version reads. Unknown fields are not kept here; callers that need a byte-exact round trip
//! keep the raw resource while the slices are unchanged.
use std::collections::HashMap;
use crate::descriptor::{Descriptor, Id, UnicodeString, Value, VersionedDescriptor};
use crate::error::{PsdError, Result};
use crate::io::{Reader, WriteExt};
/// Resource id of the slices resource.
pub const SLICES: u16 = 1050;
/// One slice record.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SliceRecord {
/// Slice id.
pub id: u32,
/// Group id.
pub group_id: u32,
/// Origin: 0 auto-generated, 1 layer-based, 2 user-drawn.
pub origin: u32,
/// Layer id of a layer-based slice (the layer record's `lyid`).
pub layer_id: Option<u32>,
/// Slice name.
pub name: String,
/// Type: 0 no image, 1 image, 2 table.
pub kind: u32,
/// Left, top, right, bottom in document pixels.
pub rect: [i32; 4],
/// URL.
pub url: String,
/// Target frame.
pub target: String,
/// Status-bar message.
pub message: String,
/// Alt tag.
pub alt: String,
/// Cell text is HTML.
pub cell_text_is_html: bool,
/// Cell text.
pub cell_text: String,
/// Horizontal alignment.
pub horizontal_align: u32,
/// Vertical alignment.
pub vertical_align: u32,
/// Background colour as alpha, red, green, blue.
pub color: [u8; 4],
/// Layer-based slice outsets: top, left, bottom, right.
pub outsets: [i32; 4],
}
/// The whole slices resource.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SlicesResource {
/// Version read (6, 7 or 8); writing always produces 6.
pub version: u32,
/// Bounding rectangle of all slices: top, left, bottom, right.
pub bounds: [i32; 4],
/// Name of the slice group (the base name).
pub group_name: String,
/// Slices, including auto-generated ones as Photoshop saves them.
pub slices: Vec<SliceRecord>,
}
fn ustr(r: &mut Reader<'_>) -> Result<String> {
Ok(UnicodeString::read(r)?.to_string_lossy())
}
fn put_ustr(out: &mut Vec<u8>, s: &str) {
UnicodeString::new(s).write(out);
}
// ---------- descriptor helpers (versions 7/8 and the v6 tail) ----------
/// A number as `i64`; float casts saturate (NaN reads as 0).
fn d_int(d: &Descriptor, key: &str) -> Option<i64> {
match d.get(key)? {
Value::Integer(v) => Some(i64::from(*v)),
Value::LargeInteger(v) => Some(*v),
Value::Double(v) => Some(*v as i64),
Value::UnitFloat { value, .. } => Some(*value as i64),
_ => None,
}
}
/// A coordinate, clamped to the `i32` range instead of wrapping.
fn d_i32(d: &Descriptor, key: &str) -> Option<i32> {
d_int(d, key).map(|v| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
}
/// An id. A 32-bit `long` holds the id's bits (ids past `i32::MAX` are written negative); any
/// other number outside the `u32` range is no id at all rather than a wrapped one.
fn d_u32(d: &Descriptor, key: &str) -> Option<u32> {
match d.get(key)? {
Value::Integer(v) => Some(*v as u32),
_ => u32::try_from(d_int(d, key)?).ok(),
}
}
fn d_text(d: &Descriptor, key: &str) -> String {
match d.get(key) {
Some(Value::Text(t)) => t.to_string_lossy(),
_ => String::new(),
}
}
fn d_enum(d: &Descriptor, key: &str) -> Option<String> {
match d.get(key)? {
Value::Enumerated { value, .. } => Some(String::from_utf8_lossy(value.as_bytes()).into_owned()),
_ => None,
}
}
fn d_bool(d: &Descriptor, key: &str) -> bool {
matches!(d.get(key), Some(Value::Boolean(true)))
}
fn d_bounds(d: &Descriptor, key: &str) -> Option<[i32; 4]> {
let Value::Descriptor(b) = d.get(key)? else { return None };
let g = |k: &str| d_i32(b, k).unwrap_or(0);
// top, left, bottom, right
Some([g("Top "), g("Left"), g("Btom"), g("Rght")])
}
fn origin_from(s: &str) -> u32 {
match s {
"autoGenerated" => 0,
"layerGenerated" | "layer" => 1,
_ => 2,
}
}
fn kind_from(s: &str) -> u32 {
match s {
"noImage" => 0,
"Tbl " | "table" => 2,
_ => 1,
}
}
fn slice_from_descriptor(d: &Descriptor) -> SliceRecord {
let b = d_bounds(d, "bounds").unwrap_or_default();
let color = match (d_enum(d, "bgColorType").as_deref(), d.get("bgColor")) {
(Some("Clr "), Some(Value::Descriptor(c))) => {
let g = |k: &str| d_int(c, k).unwrap_or(0).clamp(0, 255) as u8;
let a = if c.get("alpha").is_some() { g("alpha") } else { 255 };
[a, g("Rd "), g("Grn "), g("Bl ")]
}
_ => [0; 4],
};
SliceRecord {
id: d_u32(d, "sliceID").unwrap_or(0),
group_id: d_u32(d, "groupID").unwrap_or(0),
origin: d_enum(d, "origin").map_or(2, |s| origin_from(&s)),
layer_id: d_u32(d, "layerID"),
name: d_text(d, "Nm "),
kind: d_enum(d, "Type").map_or(1, |s| kind_from(&s)),
// Descriptor bounds are top/left/bottom/right; records use left/top/right/bottom.
rect: [b[1], b[0], b[3], b[2]],
url: d_text(d, "url"),
target: d_text(d, "null"),
message: d_text(d, "Msge"),
alt: d_text(d, "altTag"),
cell_text_is_html: d_bool(d, "cellTextIsHTML"),
cell_text: d_text(d, "cellText"),
horizontal_align: 0,
vertical_align: 0,
color,
outsets: [
d_i32(d, "topOutset").unwrap_or(0),
d_i32(d, "leftOutset").unwrap_or(0),
d_i32(d, "bottomOutset").unwrap_or(0),
d_i32(d, "rightOutset").unwrap_or(0),
],
}
}
fn bounds_descriptor(tlbr: [i32; 4]) -> Value {
Value::Descriptor(
Descriptor::new("Rct1")
.with("Top ", Value::Integer(tlbr[0]))
.with("Left", Value::Integer(tlbr[1]))
.with("Btom", Value::Integer(tlbr[2]))
.with("Rght", Value::Integer(tlbr[3])),
)
}
fn enumerated(ty: &str, v: &str) -> Value {
Value::Enumerated { type_id: Id::new(ty), value: Id::new(v) }
}
impl SlicesResource {
/// Parses resource data (versions 6, 7 and 8).
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let mut r = Reader::new(data);
let version = r.u32()?;
match version {
6 => Self::read_v6(&mut r),
7 | 8 => {
let vd = VersionedDescriptor::read(&mut r)?;
let d = &vd.descriptor;
let slices = match d.get("slices") {
Some(Value::List(items)) => items
.iter()
.filter_map(|v| match v {
Value::Descriptor(s) => Some(slice_from_descriptor(s)),
_ => None,
})
.collect(),
_ => Vec::new(),
};
Ok(SlicesResource { version, bounds: d_bounds(d, "bounds").unwrap_or_default(), group_name: d_text(d, "baseName"), slices })
}
v => Err(PsdError::invalid(format!("unsupported slices resource version {v}"))),
}
}
fn read_v6(r: &mut Reader<'_>) -> Result<Self> {
let bounds = [r.i32()?, r.i32()?, r.i32()?, r.i32()?];
let group_name = ustr(r)?;
let n = r.u32()? as usize;
if n > r.remaining() / 16 {
return Err(PsdError::invalid("slice count exceeds the resource size"));
}
let mut slices = Vec::with_capacity(n);
for _ in 0..n {
let id = r.u32()?;
let group_id = r.u32()?;
let origin = r.u32()?;
let layer_id = if origin == 1 { Some(r.u32()?) } else { None };
let name = ustr(r)?;
let kind = r.u32()?;
let rect = [r.i32()?, r.i32()?, r.i32()?, r.i32()?];
let url = ustr(r)?;
let target = ustr(r)?;
let message = ustr(r)?;
let alt = ustr(r)?;
let cell_text_is_html = r.u8()? != 0;
let cell_text = ustr(r)?;
let horizontal_align = r.u32()?;
let vertical_align = r.u32()?;
let color = [r.u8()?, r.u8()?, r.u8()?, r.u8()?];
slices.push(SliceRecord {
id,
group_id,
origin,
layer_id,
name,
kind,
rect,
url,
target,
message,
alt,
cell_text_is_html,
cell_text,
horizontal_align,
vertical_align,
color,
outsets: [0; 4],
});
}
// Optional trailing descriptor (Photoshop 7+): per-slice extras such as outsets.
if r.remaining() >= 4
&& let Ok(vd) = VersionedDescriptor::read(r)
&& let Some(Value::List(items)) = vd.descriptor.get("slices")
{
// Index each slice ID to its first occurrence once, so matching trailing
// descriptors against slices is linear rather than quadratic. Both the slice
// count and the descriptor-ID list are file-controlled, so a plain
// `find()` per descriptor is O(n*m) and lets a crafted resource burn CPU.
// `or_insert` preserves the original first-match behavior for duplicate IDs.
let mut id_to_index: HashMap<u32, usize> = HashMap::with_capacity(slices.len());
for (i, s) in slices.iter().enumerate() {
id_to_index.entry(s.id).or_insert(i);
}
for v in items {
let Value::Descriptor(d) = v else { continue };
// An entry without a usable id belongs to no slice (not to slice 0).
let Some(id) = d_u32(d, "sliceID") else { continue };
let extra = slice_from_descriptor(d);
if let Some(&i) = id_to_index.get(&id) {
slices[i].outsets = extra.outsets;
}
}
}
Ok(SlicesResource { version: 6, bounds, group_name, slices })
}
/// Serializes as version 6. A trailing descriptor with the outsets is added when any slice
/// has them, the way Photoshop extends the version 6 layout.
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
out.put_u32(6);
for v in self.bounds {
out.put_i32(v);
}
put_ustr(&mut out, &self.group_name);
out.put_u32(self.slices.len() as u32);
for s in &self.slices {
out.put_u32(s.id);
out.put_u32(s.group_id);
out.put_u32(s.origin);
if s.origin == 1 {
out.put_u32(s.layer_id.unwrap_or(0));
}
put_ustr(&mut out, &s.name);
out.put_u32(s.kind);
for v in s.rect {
out.put_i32(v);
}
put_ustr(&mut out, &s.url);
put_ustr(&mut out, &s.target);
put_ustr(&mut out, &s.message);
put_ustr(&mut out, &s.alt);
out.put_u8(u8::from(s.cell_text_is_html));
put_ustr(&mut out, &s.cell_text);
out.put_u32(s.horizontal_align);
out.put_u32(s.vertical_align);
out.put(&s.color);
}
if self.slices.iter().any(|s| s.outsets != [0; 4]) {
let list = self
.slices
.iter()
.map(|s| {
Value::Descriptor(
Descriptor::new("slice")
.with("sliceID", Value::Integer(s.id as i32))
.with("topOutset", Value::Integer(s.outsets[0]))
.with("leftOutset", Value::Integer(s.outsets[1]))
.with("bottomOutset", Value::Integer(s.outsets[2]))
.with("rightOutset", Value::Integer(s.outsets[3])),
)
})
.collect();
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(list))).write(&mut out);
}
out
}
/// Serializes in the version 7 descriptor form (what newer Photoshop versions write).
pub fn to_descriptor_bytes(&self) -> Vec<u8> {
let list = self
.slices
.iter()
.map(|s| {
let origin = match s.origin {
0 => "autoGenerated",
1 => "layerGenerated",
_ => "userGenerated",
};
let kind = match s.kind {
0 => "noImage",
2 => "Tbl ",
_ => "Img ",
};
let mut d = Descriptor::new("slice")
.with("sliceID", Value::Integer(s.id as i32))
.with("groupID", Value::Integer(s.group_id as i32))
.with("origin", enumerated("ESliceOrigin", origin));
if let Some(l) = s.layer_id.filter(|_| s.origin == 1) {
d = d.with("layerID", Value::Integer(l as i32));
}
d.with("Nm ", Value::Text(UnicodeString::new(&s.name)))
.with("Type", enumerated("ESliceType", kind))
.with("bounds", bounds_descriptor([s.rect[1], s.rect[0], s.rect[3], s.rect[2]]))
.with("url", Value::Text(UnicodeString::new(&s.url)))
.with("null", Value::Text(UnicodeString::new(&s.target)))
.with("Msge", Value::Text(UnicodeString::new(&s.message)))
.with("altTag", Value::Text(UnicodeString::new(&s.alt)))
.with("cellTextIsHTML", Value::Boolean(s.cell_text_is_html))
.with("cellText", Value::Text(UnicodeString::new(&s.cell_text)))
.with("horzAlign", enumerated("ESliceHorzAlign", "default"))
.with("vertAlign", enumerated("ESliceVertAlign", "default"))
.with("bgColorType", enumerated("ESliceBGColorType", "None"))
.with("topOutset", Value::Integer(s.outsets[0]))
.with("leftOutset", Value::Integer(s.outsets[1]))
.with("bottomOutset", Value::Integer(s.outsets[2]))
.with("rightOutset", Value::Integer(s.outsets[3]))
})
.map(Value::Descriptor)
.collect();
let d = Descriptor::new("null")
.with("bounds", bounds_descriptor(self.bounds))
.with("slices", Value::List(list))
.with("baseName", Value::Text(UnicodeString::new(&self.group_name)));
let mut out = Vec::new();
out.put_u32(7);
VersionedDescriptor::new(d).write(&mut out);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> SlicesResource {
SlicesResource {
version: 6,
bounds: [0, 0, 80, 100],
group_name: "site".into(),
slices: vec![
SliceRecord { id: 0, origin: 0, kind: 1, rect: [0, 0, 100, 10], ..Default::default() },
SliceRecord {
id: 1,
group_id: 0,
origin: 2,
name: "logo".into(),
kind: 1,
rect: [20, 10, 60, 30],
url: "https://example.org/".into(),
target: "_blank".into(),
message: "hi".into(),
alt: "Logo".into(),
cell_text_is_html: true,
cell_text: "<b>x</b>".into(),
horizontal_align: 1,
vertical_align: 2,
color: [255, 10, 20, 30],
..Default::default()
},
SliceRecord { id: 2, origin: 1, layer_id: Some(7), kind: 0, rect: [5, 40, 25, 70], outsets: [1, 2, 3, 4], ..Default::default() },
],
}
}
#[test]
fn v6_roundtrips() {
let s = sample();
let bytes = s.to_bytes();
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert_eq!(back, s);
assert_eq!(back.to_bytes(), bytes, "byte-stable");
}
#[test]
fn v7_descriptor_parses() {
let s = sample();
let back = SlicesResource::from_bytes(&s.to_descriptor_bytes()).unwrap();
assert_eq!(back.version, 7);
assert_eq!(back.group_name, "site");
assert_eq!(back.bounds, s.bounds);
assert_eq!(back.slices.len(), 3);
assert_eq!(back.slices[1].rect, [20, 10, 60, 30]);
assert_eq!(back.slices[1].alt, "Logo");
assert_eq!(back.slices[1].target, "_blank");
assert_eq!(back.slices[2].origin, 1);
assert_eq!(back.slices[2].layer_id, Some(7));
assert_eq!(back.slices[2].kind, 0);
assert_eq!(back.slices[2].outsets, [1, 2, 3, 4]);
}
#[test]
fn out_of_range_descriptor_numbers_do_not_wrap() {
// sliceID 2^32+1 used to read as slice 1 and take that slice's outsets.
let wide = |key: &str| Descriptor::new("slice").with("sliceID", Value::LargeInteger((1 << 32) + 1)).with(key, Value::Integer(9));
let mut plain = sample();
for s in &mut plain.slices {
s.outsets = [0; 4];
}
let mut bytes = plain.to_bytes();
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(wide("topOutset"))]))).write(&mut bytes);
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert!(back.slices.iter().all(|s| s.outsets == [0; 4]), "{:?}", back.slices);
// Descriptor form: ids out of the u32 range fall back to 0, coordinates clamp.
let d = wide("leftOutset")
.with("layerID", Value::LargeInteger(-1))
.with("bottomOutset", Value::LargeInteger(1 << 40))
.with("rightOutset", Value::Double(-1e12))
.with("bounds", Value::Descriptor(Descriptor::new("Rct1").with("Top ", Value::LargeInteger((1 << 32) + 5))));
let s = slice_from_descriptor(&d);
assert_eq!((s.id, s.layer_id), (0, None));
assert_eq!(s.outsets, [0, 9, i32::MAX, i32::MIN]);
assert_eq!(s.rect[1], i32::MAX);
// A 32-bit long keeps the id's bits, as `to_descriptor_bytes` writes ids past i32::MAX.
assert_eq!(slice_from_descriptor(&Descriptor::new("slice").with("sliceID", Value::Integer(-1))).id, u32::MAX);
}
#[test]
fn malformed_input_is_an_error() {
let bytes = sample().to_bytes();
// Cuts inside the records; a damaged trailing descriptor is ignored on purpose.
for cut in [0, 3, 10, 30, 120] {
assert!(SlicesResource::from_bytes(&bytes[..cut]).is_err(), "cut at {cut}");
}
let mut huge = 6u32.to_be_bytes().to_vec();
huge.extend([0u8; 16]);
huge.extend(0u32.to_be_bytes());
huge.extend(u32::MAX.to_be_bytes());
assert!(SlicesResource::from_bytes(&huge).is_err());
assert!(SlicesResource::from_bytes(&9u32.to_be_bytes()).is_err());
}
#[test]
fn trailing_descriptor_applies_outsets_to_first_matching_slice() {
// Two slices share id 1; the trailing descriptor must update the first one only,
// matching the pre-index behavior of `iter_mut().find()`.
let mut plain = SlicesResource {
version: 6,
bounds: [0, 0, 80, 100],
group_name: "site".into(),
slices: vec![
SliceRecord { id: 1, origin: 0, kind: 1, rect: [0, 0, 10, 10], ..Default::default() },
SliceRecord { id: 1, origin: 0, kind: 1, rect: [10, 10, 20, 20], ..Default::default() },
],
};
for s in &mut plain.slices {
s.outsets = [0; 4];
}
let mut bytes = plain.to_bytes();
let extra = Descriptor::new("slice").with("sliceID", Value::Integer(1)).with("topOutset", Value::Integer(7));
VersionedDescriptor::new(Descriptor::new("null").with("slices", Value::List(vec![Value::Descriptor(extra)]))).write(&mut bytes);
let back = SlicesResource::from_bytes(&bytes).unwrap();
assert_eq!(back.slices[0].outsets, [7, 0, 0, 0], "first slice receives the outset");
assert_eq!(back.slices[1].outsets, [0, 0, 0, 0], "later duplicate is untouched");
}
}