From 949d74f7ca37f9523b540440fc5bdc607ff4cab3 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Thu, 29 Oct 2026 08:50:00 +0800 Subject: [PATCH] fix(collab): image checks keep documents shareable and history replayable (#1086) - The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the cap the .fig, .sketch and REST importers already read images with (now one constant), instead of 32 MiB: an imported image larger than 32 MiB made the document unjoinable and its server snapshot unrestorable. - History written before the checks (a restored or joined snapshot, a replayed server log, a file published to a server) applies without the image entries they refuse (docprops::without_refused_images), instead of the file not opening or a restart dropping every edit after the image. - A key that already holds an image can't be given other bytes: producers key images by their bytes (the REST importer's placeholder keys always hold the same picture), and merge treats a key's image as never changing. - REST import: an unresolved imageRef shaped like a content hash shares the rest-image-missing placeholder (the placeholder's bytes don't hash to it); ImageData::claims_content_hash is shared with collab. - Tests: collab image_tests (4 new, size test uses a small cap), format image_tests (1 new). - Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md. --- crates/collab/src/docprops.rs | 58 +++++++++++++----------- crates/collab/src/image_tests.rs | 71 ++++++++++++++++++++++++++---- crates/collab/src/replica.rs | 4 +- crates/collab/src/server.rs | 5 ++- crates/doc/src/lib.rs | 10 +++++ crates/engine/src/cmd/file/load.rs | 2 +- crates/format/src/fig.rs | 2 +- crates/format/src/image_tests.rs | 15 +++++++ crates/format/src/rest.rs | 10 +++-- crates/format/src/sketch.rs | 2 +- docs/collab-protocol.md | 5 ++- docs/collaboration-parity.md | 4 +- docs/file-format-parity.md | 2 +- 13 files changed, 144 insertions(+), 46 deletions(-) diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 8e78deb0..285b4f95 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -165,33 +165,25 @@ struct ImageValue { bytes: String, } -/// Largest decoded image an entry may carry: 32 MiB, mirroring `MAX_IMAGE_FILE_BYTES` in -/// `figcraft_format::thumbnail` (collab sits below the format crate and can't name it). -pub(crate) const MAX_IMAGE_BYTES: usize = 32 << 20; - /// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no /// image this app realistically opens reaches it — an entry claiming a bigger side is /// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it /// couldn't probe (`image_data`). pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535; -/// Whether `key` has the shape of a content hash: 64 hex chars, what `image_hash` in -/// `figcraft_format` (sha256) produces — which writes it lowercase, so a match is exact: -/// an uppercase look-alike claims a content address it doesn't have, and storing bytes -/// there would file a second copy under a name no honest lookup reaches. Other key -/// shapes exist and stay valid (the svg importer's FNV-128 keys, the REST importer's -/// placeholder under the imageRef itself); they claim no content address, so only the -/// key-name rule and the size bounds apply to them. One untestable edge: a REST imageRef of exactly 64 hex -/// chars would be hash-shaped — Figma's imageRefs are 40 hex, so it doesn't occur. -fn is_content_hash(key: &str) -> bool { - key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) -} - /// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces. fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } +/// `changes` without the image entries [`image_of`] refuses. For history written before those +/// checks (a saved snapshot, a server's log): the rest of it still applies, where refusing the +/// whole would leave a document that can't open or drop every edit after the image. +pub(crate) fn without_refused_images(changes: &[Change]) -> Vec { + changes.iter().filter(|c| !matches!(c, Change::Put { entry: Entry::Image(k), value } if image_of(k, value).is_err())).cloned().collect() +} + +/// The JSON value an image entry carries: its size and its encoded bytes in base64. fn image_json(img: &ImageData) -> Result { Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) }) } @@ -199,11 +191,19 @@ fn image_json(img: &ImageData) -> Result { /// The image a change carries, validated as a collaborator's claim (an invalid entry /// rejects its batch, leaving every replica's store clean): a key that can name an archive /// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/`), dimensions within -/// [`MAX_IMAGE_SIDE`], decoded bytes within [`MAX_IMAGE_BYTES`] (the encoded length is -/// capped before decoding so hostile text can't force a large allocation), and a key with -/// the shape of a content hash must be that hash of these bytes — a poisoned -/// content-addressed store would show the wrong image everywhere at once (#1086). +/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read; +/// the encoded length is capped before decoding so hostile text can't force a large +/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must +/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash` +/// writes — a poisoned content-addressed store would show the wrong image everywhere at once +/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders) +/// claim no content address, so only the name and size rules apply to them. fn image_of(key: &str, value: &Json) -> Result { + image_within(key, value, ImageData::MAX_BYTES) +} + +/// [`image_of`] with `max_bytes` for the decoded size. +pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result { if !ImageData::is_valid_key(key) { return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)"))); } @@ -211,15 +211,15 @@ fn image_of(key: &str, value: &Json) -> Result { if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE { return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE))); } - let max_encoded = (MAX_IMAGE_BYTES / 3) * 4 + 4; - if v.bytes.len() > max_encoded { - return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {MAX_IMAGE_BYTES} of bytes"))); + let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4); + if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded { + return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes"))); } let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?; - if bytes.len() > MAX_IMAGE_BYTES { - return Err(Error::Invalid(format!("image: {} bytes pass the {MAX_IMAGE_BYTES} an image may be", bytes.len()))); + if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len()))); } - if is_content_hash(key) && key != sha256_hex(&bytes) { + if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) { return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to"))); } Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height }) @@ -338,6 +338,12 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result } Entry::Image(k) => { let img = image_of(k, value)?; + // A key always names the same bytes (producers key images by their bytes; the REST + // importer's placeholder keys always hold the same grey picture), so nobody can + // swap the picture under a key someone else added. + if doc.images.get(k).is_some_and(|old| old.bytes != img.bytes) { + return Err(Error::Invalid(format!("image: {k} already holds other bytes"))); + } doc.images.insert(k.clone(), img); } Entry::Ballot(k) => { diff --git a/crates/collab/src/image_tests.rs b/crates/collab/src/image_tests.rs index a57c6497..e2c65d35 100644 --- a/crates/collab/src/image_tests.rs +++ b/crates/collab/src/image_tests.rs @@ -11,18 +11,17 @@ use sha2::{Digest as _, Sha256}; use crate::change::{Change, Entry, Json}; use crate::docprops::apply as apply_doc; -use crate::docprops::{MAX_IMAGE_BYTES, MAX_IMAGE_SIDE}; +use crate::docprops::{MAX_IMAGE_SIDE, image_within}; /// The side bound the implementation enforces. const MAX_SIDE: u32 = MAX_IMAGE_SIDE; -/// The decoded-bytes cap the implementation enforces. -const MAX_BYTES: usize = MAX_IMAGE_BYTES; /// sha256 hex, computed here independently of the implementation under test. fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } +/// An image entry putting `bytes` (`width` × `height`) under `key`. fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change { Change::Put { entry: Entry::Image(key.into()), @@ -90,13 +89,19 @@ fn absurd_dimensions_are_refused() { } } +/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked +/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps +/// the test light. #[test] fn oversized_image_bytes_are_refused() { - let bytes = vec![0u8; MAX_BYTES + 1]; - let key = sha256_hex(&bytes); - let mut doc = Document::new("t"); - apply_doc(&mut doc, &put(&key, 100, 100, &bytes)).err().unwrap_or_else(|| panic!("a {}-byte image must be refused, but it applied", bytes.len())); - assert!(doc.images.is_empty()); + let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)}); + let at = vec![0u8; 1024]; + image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies"); + let past = vec![0u8; 1025]; + assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap"); + let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)}); + let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}"); } #[test] @@ -135,3 +140,53 @@ fn a_key_that_cannot_name_an_archive_member_is_refused() { assert!(doc.images.is_empty(), "key {key:?} reached the image store"); } } + +/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a +/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session. +#[test] +fn an_image_as_large_as_an_import_takes_still_applies() { + let bytes = vec![0u8; 40 << 20]; + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read"); + assert_eq!(doc.images.len(), 1); +} + +/// An image key is content-derived by every producer, so its bytes never change: a collaborator +/// can't swap the picture under a key another one added. +#[test] +fn an_image_cannot_be_replaced_under_its_key() { + let mut doc = Document::new("t"); + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap(); + assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key"); + assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice())); + apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)"); +} + +/// A snapshot written before these checks may hold an image they refuse: the session still +/// opens, without that image, instead of not opening at all. +#[test] +fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() { + let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap(); + let (key, bytes) = forged(); + snapshot.push(put(&key, 2, 1, bytes)); + let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens"); + assert!(!r.document().images.contains_key(&key)); +} + +/// A server's log written before these checks may hold an image they refuse: a restart replays +/// the edits after it instead of dropping them with the rest of the log. +#[test] +fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() { + let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default()); + let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap(); + let (key, bytes) = forged(); + let good = b"an honest image after it".as_slice(); + let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes }); + for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])] + { + r.replay(j).expect("the log replays"); + } + assert_eq!(r.seq(), 2, "both batches count"); + assert!(!r.document().images.contains_key(&key), "the refused image is left out"); + assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept"); +} diff --git a/crates/collab/src/replica.rs b/crates/collab/src/replica.rs index e14c5fec..7607ae56 100644 --- a/crates/collab/src/replica.rs +++ b/crates/collab/src/replica.rs @@ -73,7 +73,9 @@ impl Replica { /// Rebuild a replica from a [`snapshot`](Self::snapshot). pub fn from_snapshot(changes: &[Change]) -> Result { let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() }; - r.apply(changes, Mode::Strict)?; + // A snapshot saved before the image checks may hold an image they refuse: it opens + // without that image (`docprops::without_refused_images`). + r.apply(&docprops::without_refused_images(changes), Mode::Strict)?; if r.doc.pages.is_empty() { return Err(Error::Invalid("a snapshot needs a page".into())); } diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 52f112ad..077858ee 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -198,10 +198,13 @@ impl Server { Ok(()) } Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()), - Journal::Accepted(acc) => { + Journal::Accepted(mut acc) => { if acc.seq != self.seq + 1 { return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq))); } + // A log written before the image checks may hold an image they refuse: the batch + // replays without it, so the edits after it aren't dropped with the rest of the log. + acc.changes = docprops::without_refused_images(&acc.changes); self.accept(acc.author, self.seq, &acc.changes)?; self.issued.entry(acc.author).or_default().note(acc.batch_id, false); self.next_client = self.next_client.max(acc.author.saturating_add(1)); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1e94d08c..bc2cf78d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -162,6 +162,16 @@ pub struct ImageData { } impl ImageData { + /// Largest encoded image a document takes from outside: an image member of a `.fig` or + /// `.sketch` file, an image file a REST import reads, an image entry from a live session. + pub const MAX_BYTES: u64 = 256 * 1024 * 1024; + + /// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex + /// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it. + pub fn claims_content_hash(key: &str) -> bool { + key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit()) + } + /// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes, /// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key /// becomes an archive member name (`images/`) when the file is saved, so only these are diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index a033475e..8c15783c 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -351,7 +351,7 @@ fn link_instances(doc: &mut Document) { /// Largest image file read for a REST API file JSON import. #[cfg(not(target_arch = "wasm32"))] -const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; +const MAX_IMAGE_FILE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to /// the file. diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index d81a5856..042d0b3e 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -59,7 +59,7 @@ use kiwi::{Kv, Schema}; const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; /// Largest image member read. -const MAX_IMAGE: u64 = 256 * 1024 * 1024; +const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; /// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs index 9c6e4b10..23bbad0d 100644 --- a/crates/format/src/image_tests.rs +++ b/crates/format/src/image_tests.rs @@ -145,3 +145,18 @@ fn an_image_keyed_with_dashes_comes_back_after_a_save() { let loaded = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap(); assert!(loaded.doc.images.contains_key("rest-image-missing"), "{:?}", loaded.issues); } + +/// An unresolved `imageRef` shaped like a content hash would claim the placeholder's bytes hash +/// to it, which they don't: a live session would refuse that image. It shares the placeholder +/// instead. +#[test] +fn a_hash_shaped_rest_image_ref_never_names_its_placeholder() { + for image_ref in ["ab".repeat(32), "A".repeat(64)] { + let rect = json!({"id": "X", "name": "X", "type": "RECTANGLE", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 10, "y": 10}, + "fills": [{"type": "IMAGE", "imageRef": image_ref, "scaleMode": "FIT"}]}); + let file = json!({"name": "F", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", + "children": [{"id": "0:Page", "name": "Page", "type": "CANVAS", "backgroundColor": {"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}, "children": [rect]}]}}); + let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap(); + assert_eq!(doc.images.keys().collect::>(), ["rest-image-missing"], "{image_ref}"); + } +} diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 8c83b0af..f9b83691 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -948,7 +948,9 @@ impl<'a> Ctx<'a> { /// The document image key for an `imageRef`: its bytes when the resolver has them, else a /// grey placeholder stored under the `imageRef` itself when that name can legitimately be an - /// archive member (`is_valid_key`); any other name shares the `rest-image-missing` placeholder. + /// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the + /// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing` + /// placeholder. pub(crate) fn image_key(&mut self, image_ref: &str) -> String { if let Some(k) = self.image_keys.get(image_ref) { return k.clone(); @@ -959,9 +961,11 @@ impl<'a> Ctx<'a> { None => { self.missing_images += 1; // An `imageRef` arrives from an untrusted file, and keys become archive member - // names on save: a name that can't legitimately be one must not reach the map. + // names on save: a name that can't legitimately be one must not reach the map, + // nor one claiming a content hash the placeholder's bytes don't have. let candidate = if image_ref.is_empty() { MISSING_IMAGE } else { clip_str(image_ref) }; - let key = if ImageData::is_valid_key(candidate) { candidate } else { MISSING_IMAGE }; + let named = ImageData::is_valid_key(candidate) && !ImageData::claims_content_hash(candidate); + let key = if named { candidate } else { MISSING_IMAGE }; let key = key.to_string(); if !self.doc.images.contains_key(&key) && let Some(png) = placeholder_png() diff --git a/crates/format/src/sketch.rs b/crates/format/src/sketch.rs index 985541b1..8a35bb5e 100644 --- a/crates/format/src/sketch.rs +++ b/crates/format/src/sketch.rs @@ -66,7 +66,7 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; #[cfg(target_pointer_width = "32")] const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. -const MAX_IMAGE: u64 = 256 * 1024 * 1024; +const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; /// Most pages read from one file. const MAX_PAGES: usize = 1000; diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index a9d1b247..8023823a 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -155,7 +155,10 @@ As built: 1–128 ASCII letters, digits, `-` or `_` (a saved file names the image `images/`), a key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256 hex of those bytes (as the app writes them); a side is at most 65 535 px and the decoded bytes at - most 32 MiB, and base64 that can't decode is refused); a + most 256 MiB (`ImageData::MAX_BYTES`, what the importers read), base64 that can't decode is + refused, and a key that already holds an image can't be given other bytes; a snapshot or a + server log written before these checks opens without the images they refuse, rather than + failing, and a file published to a server is kept without them); a board's timer and vote (whole values, `DocProp::Timer` and `DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting at once write different entries). The timer travels as its end time (or, paused, the time left), diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 4fe058ae..66816b4b 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 32 MiB, so no collaborator can poison the shared image store or balloon memory (#1086). Left: server archives beyond Linux | 25–55 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), and a key's bytes can't be replaced, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | @@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 32 MiB, mirroring the format crate's thumbnail cap — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | +| 2026-10-29 | minor | Image entries are checked as they apply (#1086, from a security audit): a key must be 1–128 ASCII letters, digits, `-` or `_` — the same rule a file load reads `images/` members by (`figcraft_doc::ImageData::is_valid_key`, shared with the format crate, which now also reads `-` and `_` names back, so the REST importer's `rest-image-missing` placeholder survives a save) — so no collaborator can write `images/../../x` into everyone's saved file; bytes carried under a key with the shape of a content hash must be what that key hashes to (lowercase sha256 hex, exactly the form `figcraft_format::image_hash` produces — an uppercase look-alike is refused), a side is at most 65 535 px (JPEG and GIF size fields are 16-bit and no image this app realistically opens reaches beyond it), and the decoded bytes at most 256 MiB (`figcraft_doc::ImageData::MAX_BYTES`, the cap the `.fig`, `.sketch` and REST importers read images with, so an image a document imported still reaches everyone) — with the encoded text capped before decoding so a hostile payload can't force a large allocation. A key that already holds an image can't be given other bytes (producers key images by their bytes, and the REST importer's placeholder keys always hold the same picture). A refused entry refuses its batch like any invalid change, so every replica's image store stays clean. History written before these checks — a server's snapshot or log, a file published to a server, a snapshot a client joins with — applies without the image entries they refuse, instead of the file not opening or a restart dropping every edit after the image. Keys that aren't content-hash shaped keep working within the bounds (the svg importer's FNV-128 keys, the REST importer's placeholder under an imageRef), and 0×0 entries — what the format crate stores for sizes it can't probe — stay valid. Tests in `image_tests`. Numbers unchanged | | 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 01f41ff4..54c05a5a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -181,7 +181,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) now shares that placeholder instead of being keyed by its raw name. Numbers unchanged | +| 2026-10-29 | minor | Image key names (#1086): one rule now covers the archive reader, the collab entries and the REST importer's placeholders — 1 to 128 ASCII letters, digits, `-` or `_`. The reader accepts `-` and `_` from now on, so the REST importer's `rest-image-missing` placeholder survives a save and load instead of being dropped as damaged; a placeholder whose `imageRef` can't name an archive member (path text, an over-long ref) or is shaped like a content hash (64 hex digits, which the placeholder's bytes don't hash to) now shares that placeholder instead of being keyed by its raw name. The importers' largest image (256 MiB) is one constant, `figcraft_doc::ImageData::MAX_BYTES`, which live sessions use too. Numbers unchanged | | 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |