openpencil/crates/op-figma/src/binary_e2e_tests.rs
Kayshen-X ac63c701e6 fix(figma): codex review round 1 — hardening + ZIP entrypoint
Addresses the first codex review of the binary `.fig` parser.

BLOCKs:
- kiwi: 64-bit varint now uses Kiwi's terminal-byte rule (eight
  7-bit groups then a final full-8-bit byte) — the old `& 127` mask
  on every byte corrupted u64 values above 2^56.
- kiwi: an invalid schema definition kind (> 2) is now rejected
  instead of silently treated as a message.
- kiwi: array decode rejects a length exceeding the buffer size —
  guards against a hostile zero-byte-element array spinning the
  decode loop billions of times.
- zip_reader: aggregate 2 GiB decompression budget + 10k entry cap
  on top of the existing per-entry limit (zip-bomb defence).

CONCERNs:
- detect_kind now recognises the `PK\x03\x04` ZIP magic as Binary —
  the common Figma export form (`canvas.fig` + `images/` in a ZIP)
  was being rejected before container.rs could unwrap it.
- resolve_style_references now also resolves style refs inside
  instance `symbolData.symbolOverrides` entries.
- kiwi: enum field type codes are no longer resolved (unused; kiwi
  writes 0) so a stray code can't reject a valid schema.

Plus a zip-wrapped end-to-end test + the misleading zstd test rename.
op-figma 84 tests green (+1); clean build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-17 16:37:31 +08:00

313 lines
9.5 KiB
Rust

//! End-to-end binary `.fig` test — assembles a real Kiwi-encoded
//! `.fig` container from scratch (schema chunk + data chunk, both
//! deflate-compressed) and drives it through the full
//! [`parse_fig_binary`] pipeline.
use crate::{parse_fig_binary, FigLayoutMode};
use jian_ops_schema::node::PenNode;
use jian_ops_schema::sizing::SizingBehavior;
use std::io::Write;
/// Minimal Kiwi wire writer (mirrors the in-crate decoder).
#[derive(Default)]
struct W {
out: Vec<u8>,
}
impl W {
fn byte(&mut self, b: u8) {
self.out.push(b);
}
fn var_uint(&mut self, mut v: u32) {
loop {
let mut byte = (v & 127) as u8;
v >>= 7;
if v != 0 {
byte |= 128;
}
self.out.push(byte);
if v == 0 {
break;
}
}
}
fn var_int(&mut self, v: i32) {
self.var_uint(((v << 1) ^ (v >> 31)) as u32);
}
fn var_float(&mut self, v: f32) {
let stored = v.to_bits().rotate_left(9);
if stored & 0xff == 0 {
self.out.push(0);
} else {
self.out.extend_from_slice(&stored.to_le_bytes());
}
}
fn string(&mut self, s: &str) {
self.out.extend_from_slice(s.as_bytes());
self.out.push(0);
}
/// Schema field row: name, type code, array flag, value.
fn field(&mut self, name: &str, type_code: i32, is_array: bool, value: u32) {
self.string(name);
self.var_int(type_code);
self.byte(if is_array { 1 } else { 0 });
self.var_uint(value);
}
}
/// Build the fixture schema chunk. Definition indices: GUID=0, Vec=1,
/// Matrix=2, ParentIndex=3, NodeType=4, NodeChange=5, Message=6.
fn build_schema() -> Vec<u8> {
let mut w = W::default();
w.var_uint(7); // definition count
// 0: struct GUID { sessionID: uint, localID: uint }
w.string("GUID");
w.byte(1);
w.var_uint(2);
w.field("sessionID", -4, false, 0);
w.field("localID", -4, false, 0);
// 1: struct Vec { x: float, y: float }
w.string("Vec");
w.byte(1);
w.var_uint(2);
w.field("x", -5, false, 0);
w.field("y", -5, false, 0);
// 2: struct Matrix { m00..m12: float }
w.string("Matrix");
w.byte(1);
w.var_uint(6);
for m in ["m00", "m01", "m02", "m10", "m11", "m12"] {
w.field(m, -5, false, 0);
}
// 3: struct ParentIndex { guid: GUID, position: string }
w.string("ParentIndex");
w.byte(1);
w.var_uint(2);
w.field("guid", 0, false, 0);
w.field("position", -6, false, 0);
// 4: enum NodeType { DOCUMENT=1, CANVAS=2, FRAME=3, RECTANGLE=4 }
w.string("NodeType");
w.byte(0);
w.var_uint(4);
w.field("DOCUMENT", 0, false, 1);
w.field("CANVAS", 0, false, 2);
w.field("FRAME", 0, false, 3);
w.field("RECTANGLE", 0, false, 4);
// 5: message NodeChange
w.string("NodeChange");
w.byte(2);
w.var_uint(6);
w.field("guid", 0, false, 1);
w.field("parentIndex", 3, false, 2);
w.field("type", 4, false, 3);
w.field("name", -6, false, 4);
w.field("size", 1, false, 5);
w.field("transform", 2, false, 6);
// 6: message Message { nodeChanges: NodeChange[] }
w.string("Message");
w.byte(2);
w.var_uint(1);
w.field("nodeChanges", 5, true, 1);
w.out
}
/// One node-change descriptor for the data chunk.
struct NodeSpec {
local_id: u32,
parent: Option<(u32, u32, &'static str)>, // (session, local, position)
type_value: u32,
name: &'static str,
size: Option<(f32, f32)>,
transform: Option<[f32; 6]>,
}
fn encode_guid(w: &mut W, session: u32, local: u32) {
w.var_uint(session);
w.var_uint(local);
}
fn encode_node(w: &mut W, n: &NodeSpec) {
// guid — field id 1.
w.var_uint(1);
encode_guid(w, 0, n.local_id);
// parentIndex — field id 2.
if let Some((s, l, pos)) = n.parent {
w.var_uint(2);
encode_guid(w, s, l);
w.string(pos);
}
// type — field id 3.
w.var_uint(3);
w.var_uint(n.type_value);
// name — field id 4.
w.var_uint(4);
w.string(n.name);
// size — field id 5.
if let Some((x, y)) = n.size {
w.var_uint(5);
w.var_float(x);
w.var_float(y);
}
// transform — field id 6.
if let Some(m) = n.transform {
w.var_uint(6);
for v in m {
w.var_float(v);
}
}
w.var_uint(0); // end NodeChange
}
/// Build the data chunk: DOCUMENT → CANVAS → RECTANGLE.
fn build_data() -> Vec<u8> {
let nodes = [
NodeSpec {
local_id: 1,
parent: None,
type_value: 1, // DOCUMENT
name: "Doc",
size: None,
transform: None,
},
NodeSpec {
local_id: 2,
parent: Some((0, 1, "a")),
type_value: 2, // CANVAS
name: "Page 1",
size: None,
transform: None,
},
NodeSpec {
local_id: 3,
parent: Some((0, 2, "a")),
type_value: 4, // RECTANGLE
name: "Box",
size: Some((100.0, 50.0)),
transform: Some([1.0, 0.0, 10.0, 0.0, 1.0, 20.0]),
},
];
let mut w = W::default();
w.var_uint(1); // Message field id 1 = nodeChanges
w.var_uint(nodes.len() as u32);
for n in &nodes {
encode_node(&mut w, n);
}
w.var_uint(0); // end Message
w.out
}
fn deflate(data: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
/// Assemble a bare `fig-kiwi` container around the two chunks.
fn build_fig(schema: &[u8], data: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"fig-kiwi");
buf.extend_from_slice(&[0, 0, 0, 0]); // delimiter
for chunk in [schema, data] {
let comp = deflate(chunk);
buf.extend_from_slice(&(comp.len() as u32).to_le_bytes());
buf.extend_from_slice(&comp);
}
buf
}
#[test]
fn parses_a_full_binary_fig_into_a_document() {
let fig = build_fig(&build_schema(), &build_data());
let import = parse_fig_binary(&fig, "Test", FigLayoutMode::OpenPencil)
.expect("binary .fig parses end-to-end");
let pages = import.document.pages.expect("document has pages");
assert_eq!(pages.len(), 1, "one user page");
assert_eq!(pages[0].name, "Page 1");
assert_eq!(pages[0].children.len(), 1, "one rectangle on the page");
match &pages[0].children[0] {
PenNode::Rectangle(rect) => {
assert_eq!(rect.base.name.as_deref(), Some("Box"));
// transform translation (m02, m12) → top-left.
assert_eq!(rect.base.x, Some(10.0));
assert_eq!(rect.base.y, Some(20.0));
assert!(matches!(
rect.container.width,
Some(SizingBehavior::Number(w)) if w == 100.0
));
assert!(matches!(
rect.container.height,
Some(SizingBehavior::Number(h)) if h == 50.0
));
}
other => panic!("expected a Rectangle, got {other:?}"),
}
}
/// Wrap a payload as `canvas.fig` in a minimal stored (uncompressed)
/// ZIP archive — the common Figma export form.
fn wrap_in_zip(canvas: &[u8]) -> Vec<u8> {
const LFH: u32 = 0x0403_4b50;
const CDFH: u32 = 0x0201_4b50;
const EOCD: u32 = 0x0605_4b50;
let name = b"canvas.fig";
let mut z = Vec::new();
let local_off = 0u32;
z.extend_from_slice(&LFH.to_le_bytes());
z.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
z.extend_from_slice(&(canvas.len() as u32).to_le_bytes());
z.extend_from_slice(&(canvas.len() as u32).to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&[0, 0]);
z.extend_from_slice(name);
z.extend_from_slice(canvas);
let cd_off = z.len() as u32;
z.extend_from_slice(&CDFH.to_le_bytes());
z.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
z.extend_from_slice(&(canvas.len() as u32).to_le_bytes());
z.extend_from_slice(&(canvas.len() as u32).to_le_bytes());
z.extend_from_slice(&(name.len() as u16).to_le_bytes());
z.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
z.extend_from_slice(&local_off.to_le_bytes());
z.extend_from_slice(name);
let cd_size = z.len() as u32 - cd_off;
z.extend_from_slice(&EOCD.to_le_bytes());
z.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0]);
z.extend_from_slice(&cd_size.to_le_bytes());
z.extend_from_slice(&cd_off.to_le_bytes());
z.extend_from_slice(&[0, 0]);
z
}
#[test]
fn parses_a_zip_wrapped_fig() {
let bare = build_fig(&build_schema(), &build_data());
let zipped = wrap_in_zip(&bare);
// The ZIP form must be recognised + routed through the parser.
let import = parse_fig_binary(&zipped, "Zipped", FigLayoutMode::OpenPencil)
.expect("zip-wrapped .fig parses");
let pages = import.document.pages.expect("has pages");
assert_eq!(pages[0].name, "Page 1");
assert_eq!(pages[0].children.len(), 1);
}
#[test]
fn document_serializes_to_canonical_json() {
let fig = build_fig(&build_schema(), &build_data());
let import = parse_fig_binary(&fig, "Test", FigLayoutMode::OpenPencil).unwrap();
// The converted document must round-trip through the canonical
// schema's serde representation.
let json = serde_json::to_string(&import.document).expect("serializes");
assert!(json.contains("\"Page 1\""));
assert!(json.contains("\"Box\""));
}