From 35684eaa028e636d264073a666ef11881620fa0e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 06:09:08 -0500 Subject: [PATCH 01/42] fix(format): index kiwi field lookups and bound schema and empty-array growth (#1896) A .fig message looked up each tag's field with a linear scan of its definition, so a wide schema and many tags cost their product. Schema decoding capped fields per definition but not in total, and inflated the schema chunk under the 512 MiB message cap. Empty non-byte arrays spent nothing from the decoded-value budget while still growing the output. Field ids (and enum values) are now found through a per-definition map built on first use, keeping first-match semantics. A schema holds at most 200 000 fields together and its chunk may inflate to 16 MiB. Every array field spends one value for itself before its elements. --- crates/format/src/fig.rs | 26 ++++++++++------ crates/format/src/fig/kiwi.rs | 34 +++++++++++++++++---- crates/format/src/fig/tests.rs | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index 55a5d7c34..7c81d0141 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -72,6 +72,9 @@ use kiwi::{Kv, Schema}; /// Largest `canvas.fig` member read, and largest decompressed chunk. const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; +/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that +/// inflates past this is refused before its definitions are decoded. +const MAX_SCHEMA: u64 = 16 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on @@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec), FormatError> { let (Some(schema), Some(message)) = (it.next(), it.next()) else { return Err(corrupt("canvas.fig needs a schema and a message chunk")); }; - let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?; + let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?; Ok((schema, inflate(message)?)) } @@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result, FormatError> { /// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib. fn inflate(data: &[u8]) -> Result, FormatError> { - fn capped(r: impl Read) -> Option> { + inflate_within(data, MAX_CHUNK) +} + +/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes. +fn inflate_within(data: &[u8], max: u64) -> Result, FormatError> { + let capped = |r: &mut dyn Read| -> Option> { let mut out = Vec::new(); - r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?; - (out.len() as u64 <= MAX_CHUNK).then_some(out) - } + r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?; + (out.len() as u64 <= max).then_some(out) + }; if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) { let mut src = data; - let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?; - return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk")); + let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?; + return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk")); } - capped(flate2::read::DeflateDecoder::new(data)) - .or_else(|| capped(flate2::read::ZlibDecoder::new(data))) + capped(&mut flate2::read::DeflateDecoder::new(data)) + .or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data))) .ok_or_else(|| corrupt("damaged or oversized deflate chunk")) } diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index d34b3774d..909dad87c 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -6,6 +6,7 @@ //! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array //! counts can't exceed the bytes left, and the total number of decoded values is capped. +use std::collections::HashMap; use std::fmt; /// Deepest struct/message nesting decoded. @@ -32,6 +33,9 @@ const MAX_VALUES: usize = 32 << 20; /// Most definitions in a schema, and fields in one definition. const MAX_DEFS: usize = 10_000; const MAX_FIELDS: usize = 10_000; +/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and +/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records. +const MAX_SCHEMA_FIELDS: usize = 200_000; #[derive(Debug, PartialEq, Eq)] pub struct KiwiError(pub String); @@ -252,6 +256,7 @@ impl Schema { } let mut defs = Vec::with_capacity(count); let mut raw_types = Vec::new(); + let mut total_fields = 0usize; for _ in 0..count { let name = r.string()?; let kind = match r.byte()? { @@ -261,7 +266,8 @@ impl Schema { k => return err(format!("schema: unknown definition kind {k}")), }; let n = r.var_uint()? as usize; - if n > MAX_FIELDS || n > r.left() { + total_fields = total_fields.saturating_add(n); + if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS { return err("schema: too many fields"); } let mut fields = Vec::with_capacity(n); @@ -308,7 +314,7 @@ impl Schema { /// [`Self::decode_message`] within `budget` values (see [`value_budget`]). pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result, KiwiError> { - let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget }; + let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] }; d.def(root, 0) } } @@ -322,6 +328,10 @@ struct Decoder<'s, 'b> { schema: &'s Schema, r: Reader<'b>, budget: usize, + /// Per definition, the index of the first field with each id (message field ids, enum + /// values), built the first time the definition is decoded: a message can carry many tags + /// against a wide definition, and a scan per tag would multiply the two. + ids: Vec>>, } impl<'s> Decoder<'s, '_> { @@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> { Ok(()) } + /// The first field of definition `i` with id (or enum value) `id`. + fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> { + let fields = &self.schema.defs.get(i)?.fields; + let ids = self.ids.get_mut(i)?.get_or_insert_with(|| { + let mut m = HashMap::with_capacity(fields.len()); + for (j, f) in fields.iter().enumerate() { + m.entry(f.value).or_insert(j); + } + m + }); + fields.get(*ids.get(&id)?) + } + fn def(&mut self, i: usize, depth: usize) -> Result, KiwiError> { if depth > MAX_DEPTH { return err("message: nested too deeply"); @@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> { match d.kind { Kind::Enum => { let v = self.r.var_uint()?; - Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str()))) + Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str()))) } Kind::Struct => { let mut out = Vec::with_capacity(d.fields.len()); @@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> { out.shrink_to_fit(); return Ok(Kv::Object(out)); } - let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; + let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?; out.push((f.name.as_str(), self.field(f, depth)?)); } } @@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> { if n > self.r.left() { return err("message: array longer than the data"); } + // The field itself counts, so empty arrays can't pile up for free. + self.spend(1)?; if ty == Type::Byte { - self.spend(1)?; return Ok(Kv::Bytes(self.r.take(n)?.to_vec())); } self.spend(n)?; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 657f2f81d..2d4a1f7d6 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() { assert!(sc.decode_message_within(0, &data, 1001).is_ok()); } +#[test] +fn empty_arrays_count_against_the_value_budget() { + // 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget + // can't be outgrown by piling up empty containers. + let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 0].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + +#[test] +fn message_tags_find_their_field_by_id_in_a_wide_definition() { + // A wide definition: every tag is looked up by id, and the first field with an id wins, as + // before the lookup was indexed. + let mut fields: Vec = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect(); + fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 }); + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut w = encode::Writer::default(); + for _ in 0..20_000 { + w.var_uint(5000); + w.byte(1); + } + w.var_uint(7); + w.byte(0); + w.var_uint(0); + let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() }; + assert_eq!(out.len(), 20_001); + assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true))); + assert_eq!(out.last().map(|(n, _)| *n), Some("f7")); +} + +#[test] +fn a_schema_is_refused_past_its_total_field_count() { + // 25 definitions of 10 000 fields each: every one is within the per-definition cap, but + // together they pass the schema's total. + let mut w = encode::Writer::default(); + w.var_uint(25); + for d in 0..25 { + w.string(&format!("D{d}")); + w.byte(0); + w.var_uint(10_000); + for i in 0..10_000u32 { + w.string(""); + w.var_int(0); + w.byte(0); + w.var_uint(i); + } + } + assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields"); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. From 53ebf1f39c51361282ca55e18c83fbffed2f9a34 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:40:54 -0500 Subject: [PATCH 02/42] fix(pathops): sanitize a brush's width profile once before sampling (#1948) --- crates/pathops/src/brush.rs | 4 +++- crates/pathops/src/brush_tests.rs | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/brush.rs b/crates/pathops/src/brush.rs index bfd7f9212..b5875d4a2 100644 --- a/crates/pathops/src/brush.rs +++ b/crates/pathops/src/brush.rs @@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) - let p = preset(&brush.preset)?; let unit = shape(p.id)?; let lines = polylines(path)?; - let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t)); + // Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap. + let profile = props.profile().map(|pr| pr.sanitized()); + let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)); let mut out = BezPath::new(); match p.kind { BrushKind::Stretch => { diff --git a/crates/pathops/src/brush_tests.rs b/crates/pathops/src/brush_tests.rs index 403935ddd..81cc55e10 100644 --- a/crates/pathops/src/brush_tests.rs +++ b/crates/pathops/src/brush_tests.rs @@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() { let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box()); assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}"); } + +#[test] +fn an_unsanitized_profile_is_bounded_before_sampling() { + // 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile. + let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() }; + let b = brush("taper"); + let mut p = props(1.0, &b); + p.width_profile = Some(raw); + let out = outline(&line(50.0), 1.0, &p, &b).unwrap(); + assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE); +} From cfb29a4a9617b75f9072188c15ad1e24307850f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:09 -0500 Subject: [PATCH 03/42] perf(text): compute each glyph's cluster end once per run (#1963) --- crates/text/src/layout.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f2c587206..dd728916b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1698,11 +1698,22 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe } let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript }); let n = raw.len(); + // Cluster end: the next larger cluster value, else the segment end. A glyph in the same + // cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned. + let mut cends = vec![0usize; n]; + for gi in (0..n).rev() { + let Some(&(_, cl, ..)) = raw.get(gi) else { continue }; + let cl = cl as usize; + let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied()); + let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1)); + if let Some(slot) = cends.get_mut(gi) { + *slot = cend; + } + } for gi in 0..n { let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue }; let cl = cl as usize; - // Cluster end: the next larger cluster value, else the segment end. - let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1); + let cend = cends.get(gi).copied().unwrap_or(cl + 1); let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl); let ch = chars.get(cl).copied().unwrap_or(' '); let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale; From fb4d0b50352e97debc98b212ba6e2795a6c3d4b6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:27 -0500 Subject: [PATCH 04/42] perf(doc,engine): rebuild a widget by removing its children in one pass (#1967) --- crates/doc/src/lib.rs | 13 +++++++++++++ crates/doc/src/tests.rs | 14 ++++++++++++++ crates/engine/src/extensions/widgets.rs | 4 +--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..0181ffd21 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -574,6 +574,19 @@ impl Document { Ok(()) } + /// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing + /// them one by one would search that list once per child). + pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> { + let kids = std::mem::take(&mut self.get_mut(id)?.children); + for c in kids { + for d in self.subtree(c) { + self.nodes.remove(&d); + self.dirty.touch(d); + } + } + Ok(()) + } + /// Whether sections may go in `parent`: a page or another section (never a frame or a group). pub fn takes_sections(&self, parent: NodeId) -> bool { self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a0e3e17b0..a5c8e8e7a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -846,3 +846,17 @@ fn the_colour_walker_visits_calculation_literals() { let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); } + +#[test] +fn remove_children_removes_every_subtree_and_keeps_the_parent() { + let mut doc = Document::new("T"); + let page = doc.first_page().unwrap(); + let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap(); + let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap(); + let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap(); + doc.remove_children(frame).unwrap(); + assert!(doc.contains(frame) && doc.contains(other)); + assert!(!doc.contains(inner) && !doc.contains(leaf)); + assert!(doc.get(frame).unwrap().children.is_empty()); +} diff --git a/crates/engine/src/extensions/widgets.rs b/crates/engine/src/extensions/widgets.rs index 6386ceab4..498fbeb8a 100644 --- a/crates/engine/src/extensions/widgets.rs +++ b/crates/engine/src/extensions/widgets.rs @@ -243,9 +243,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) { return Err(bad(cmd, "the layer is no longer this plugin's widget")); } - for c in n.children.clone() { - doc.remove(c)?; - } + doc.remove_children(w)?; crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?; let n = doc.get_mut(w)?; n.locked = false; From 3265cbca404644c70fb08035536882960d9f15c3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:39 -0500 Subject: [PATCH 05/42] perf(engine): look up a component's asset root once per usage report, and skip mode migration without instances (#1966) --- crates/engine/src/assets/components.rs | 4 ++++ crates/engine/src/assets/library_server.rs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index cee9b0bb2..7ddb22427 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { /// main layer's, for files from before `modes` was an override group. Copies follow their main /// layer's modes, so a layer on other modes chose them itself. pub fn mark_layer_modes(doc: &mut Document) { + // Without an instance nothing can be inside one: skip the per-layer ancestry lookups. + if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) { + return; + } let chose: Vec = doc .nodes() .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index 47d15a954..81889be5d 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage { let records: std::collections::BTreeMap = doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect(); if !records.is_empty() { + // One component's asset root is looked up once, however many instances use it. + let mut roots = std::collections::BTreeMap::>::new(); for n in doc.nodes() { if let NodeKind::Instance { component, .. } = &n.kind && store.is_none_or(|st| doc.page_of(n.id) != Some(st)) - && let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r)) + && let Some(key) = + roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r)) { let c = counts.entry(*key).or_default(); *c = c.saturating_add(1); From 4bb35d5266aae4dfb156cd85561c4d81198cddd9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 17:58:51 -0500 Subject: [PATCH 06/42] perf(collab): check a replaced list's entries against sets, not a scan per entry (#1962) --- crates/collab/src/change.rs | 11 +++++++++++ crates/collab/src/server.rs | 14 ++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index bcc09ad86..442376f37 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -333,6 +333,17 @@ impl Change { } impl Entry { + /// The synced list this entry belongs to (`None` for entries kept in maps). + pub(crate) fn family(&self) -> Option { + match self { + Entry::Style(_) => Some(Family::Styles), + Entry::Comment(_) => Some(Family::Comments), + Entry::Collection(_) => Some(Family::Collections), + Entry::Variable(_) => Some(Family::Variables), + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, + } + } + /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in /// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index b94cb5726..d5af681b2 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; -use crate::change::{Change, Entry, Prop, Target}; +use crate::change::{Change, Entry, Family, Prop, Target}; use crate::message::{ Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals, }; @@ -557,6 +557,7 @@ impl Server { fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { let before = self.state.document(); let mut touched: BTreeSet = BTreeSet::new(); + let mut listed: BTreeMap, BTreeSet)> = BTreeMap::new(); for c in changes { match c { Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => { @@ -565,8 +566,10 @@ impl Server { // A whole list replaced (the fallback for lists whose ids aren't unique). Change::SetDoc { prop, .. } => { if let Some(Ok(family)) = prop.list() { - touched.extend(docprops::entries(before, family)); - touched.extend(docprops::entries(after, family)); + let (b, a) = (docprops::entries(before, family), docprops::entries(after, family)); + touched.extend(b.iter().chain(&a).cloned()); + // Looked up in sets below: a linear search per entry would be quadratic. + listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect())); } } _ => {} @@ -574,7 +577,10 @@ impl Server { } let mut out = Vec::new(); for entry in touched { - let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry)); + let (was, now) = match entry.family().and_then(|f| listed.get(&f)) { + Some((b, a)) => (b.contains(&entry), a.contains(&entry)), + None => (docprops::has(before, &entry), docprops::has(after, &entry)), + }; if was == now { continue; } From 88851996a0932c2bc436550b2416524289044c40 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:22:03 -0500 Subject: [PATCH 07/42] fix(format): a saved file replaces its predecessor with the same permissions (#1957) --- crates/format/src/lib.rs | 5 +++++ crates/format/src/staging_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 3a69ca848..e2880a3ca 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1028,6 +1028,11 @@ pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Resul let tmp = std::path::PathBuf::from(name); match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { Ok(mut file) => { + // The replacement keeps the destination's permissions from the start, so a private + // file's content is never readable under default ones (the open handle still writes). + if let Ok(old) = std::fs::metadata(path) { + let _ = file.set_permissions(old.permissions()); + } if let Err(e) = file.write_all(bytes) { let _ = std::fs::remove_file(&tmp); return Err(e); diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs index 1bc199e20..abf8eacdc 100644 --- a/crates/format/src/staging_tests.rs +++ b/crates/format/src/staging_tests.rs @@ -123,3 +123,17 @@ fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { } let _ = std::fs::remove_dir_all(&d); } + +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt; + let d = dir("mode"); + let path = d.join("a.figcraft"); + std::fs::write(&path, b"old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let _ = std::fs::remove_dir_all(&d); +} From df07ac2d09f0e29d40170a58f70e4bf88a659790 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:23:03 -0500 Subject: [PATCH 08/42] fix(engine): never let document text close the generated HTML style element (#1955) --- crates/engine/src/dev/codegen/html.rs | 5 ++++- crates/engine/src/html_codegen_tests.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index d1b144cae..ac23e6a5b 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -90,7 +90,10 @@ pub(super) fn html(doc: &Document, id: NodeId) -> String { let ctx = Ctx::new(doc, id); let mut out = Markup::new(&ctx, CodeLang::Html); out.node(id, None, 0, ""); - format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) + // Document text can reach the stylesheet through any property: `<\/` reads back as `\n{css}\n", out.markup) } /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index cd1318ee0..ab7115554 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -163,3 +163,16 @@ fn rem_preserves_text_property_default() { } } } + +#[test] +fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a"}})).unwrap(); + s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap(); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + let (_, css) = code.split_once("").count(), 1, "{code}"); + assert!(!code.contains("