diff --git a/ROADMAP.md b/ROADMAP.md index a8dea615..5edda53a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | | 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 7b822646..8614e2cd 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result, String> { .collect() } +/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger +/// file is refused before it is read (#1114). +pub const MAX_SCRIPT: u64 = 1 << 30; + pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?; let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let (mut s, input_images_left_out) = match input { @@ -538,6 +542,21 @@ mod tests { dir } + /// A script path that is a folder or a device is refused before anything is read (#1114). + #[test] + fn a_script_that_isnt_a_regular_file_is_refused() { + let dir = temp_dir("script-caps"); + let mut bad = vec![dir.to_string_lossy().into_owned()]; + if cfg!(unix) { + bad.push("/dev/zero".into()); + } + for path in bad { + let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") }; + assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it /// as "no parent" and the layer landed on the page — with every step reporting success. diff --git a/crates/engine/clippy.toml b/crates/engine/clippy.toml index 3ceac899..4cf321bd 100644 --- a/crates/engine/clippy.toml +++ b/crates/engine/clippy.toml @@ -6,8 +6,8 @@ allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 disallowed-methods = [ - { path = "std::fs::read", reason = "use file_access::read (automation roots)" }, - { path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" }, + { path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" }, { path = "std::fs::write", reason = "use file_access::write (automation roots)" }, { path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" }, { path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" }, @@ -35,5 +35,5 @@ disallowed-methods = [ { path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" }, { path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" }, { path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" }, - { path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" }, + { path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" }, ] diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index f741e21c..aeaa3c13 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ { move |e| EngineError::Other(format!("{}: {e}", path.display())) } -/// The bytes of file `path`. -pub fn read(path: impl AsRef) -> Result> { - let path = path.as_ref(); - std::fs::read(allow(path, Access::Read)?).map_err(io(path)) +/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB". +fn size(bytes: u64) -> String { + match bytes { + b if b < 1 << 20 => format!("{b} bytes"), + b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)), + b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)), + } } -/// The bytes of file `path`, refused when it isn't a file or is larger than `max`. +/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger +/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or +/// wait forever (#1114). Every whole-file read of a path goes through here. pub fn read_capped(path: impl AsRef, max: u64) -> Result> { + use std::io::Read as _; let path = path.as_ref(); let p = allow(path, Access::Read)?; + let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display())); + // Looked at before opening: opening a pipe waits for whoever writes it. let meta = std::fs::metadata(&p).map_err(io(path))?; - if !meta.is_file() || meta.len() > max { - return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display()))); + if meta.is_dir() { + return Err(refuse("is a folder, not a file".into())); } - std::fs::read(p).map_err(io(path)) + if !meta.is_file() { + return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into())); + } + if meta.len() > max { + return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max)))); + } + let f = std::fs::File::open(p).map_err(io(path))?; + let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); + f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?; + // It grew after it was looked at. + if bytes.len() as u64 > max { + return Err(refuse(format!("grew past the {} allowed here while it was read", size(max)))); + } + Ok(bytes) } /// File `path` opened for reading. diff --git a/crates/engine/src/automation/file_access/tests.rs b/crates/engine/src/automation/file_access/tests.rs index ba55e134..72f471f9 100644 --- a/crates/engine/src/automation/file_access/tests.rs +++ b/crates/engine/src/automation/file_access/tests.rs @@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() { assert!(current().is_none()); confined(Some(&roots), || { assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err()); - assert!(super::read(f.p("outside/secret.txt")).is_err()); - assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in"); + assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err()); + assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in"); // Not confined: the app's own files. - assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret"); + assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret"); assert!(current().is_some(), "back in the scope after app_owned"); // Another thread isn't confined by this one's scope. let p = f.p("outside/secret.txt"); @@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() { assert!(!super::within_app_folder(&install.join("missing.wasm"), &install)); assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); } + +#[test] +fn sizes_read_as_a_person_says_them() { + assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into())); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 83f7a13e..1e1f8f3b 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } +/// An image file to place: no larger than a document takes an image from outside (#1114). #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c99914f4..4939bc2c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,9 +30,9 @@ pub use export::{ }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; use load::font_report; +pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path}; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; -pub use load::{load, load_bytes, load_path}; pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 8234ad09..1ed6dafe 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result { Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } +/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as +/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that +/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114). +pub const MAX_DOCUMENT_FILE: u64 = 16 << 30; + #[cfg(not(target_arch = "wasm32"))] pub(super) fn read(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, MAX_DOCUMENT_FILE) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index cd9d35ec..dbc08336 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -324,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I /// Read a file of at most `max` bytes. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result> { - let meta = crate::file_access::metadata(path)?; - if !meta.is_file() || meta.len() > max as u64 { - return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024))); - } - crate::file_access::read(path) + crate::file_access::read_capped(path, max as u64) } /// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index b6897b2a..4acc8621 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() { for cmd in ["file.open", "file.import"] { let before = state(&s); let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}"); + assert!( + ["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), + "{cmd} {bad}: {e}" + ); assert_eq!(state(&s), before, "{cmd} {bad}"); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e0ef1ff7..cedc61be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -419,6 +419,8 @@ mod paint_tests; #[cfg(test)] mod panels_behaviour_tests; #[cfg(test)] +mod path_read_caps_tests; +#[cfg(test)] mod pdf_print_tests; #[cfg(test)] mod pen_end_tests; diff --git a/crates/engine/src/path_read_caps_tests.rs b/crates/engine/src/path_read_caps_tests.rs new file mode 100644 index 00000000..2805bf51 --- /dev/null +++ b/crates/engine/src/path_read_caps_tests.rs @@ -0,0 +1,96 @@ +//! Paths callers give are read only when they are regular files within a cap that fits their +//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before +//! anything is read, and the session is left as it was. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::file_access::read_capped; + +struct Dir(PathBuf); + +impl Dir { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("folder")).unwrap(); + Self(d) + } + fn p(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + /// A sparse file of `len` bytes (nothing is written). + fn sized(&self, rel: &str, len: u64) -> String { + std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap(); + self.p(rel) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// `cmd` with `params` fails with a message containing `why`, and changes no document. +fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) { + let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{cmd} {params}: {e}"); + assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active), before, "{cmd} {params}"); +} + +const FOLDER: &str = "is a folder, not a file"; +const DEVICE: &str = "isn't a regular file"; + +#[test] +fn the_capped_read_refuses_folders_devices_and_oversized_files() { + let d = Dir::new("read"); + std::fs::write(d.0.join("four.txt"), b"four").unwrap(); + assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four"); + let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string(); + assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}"); + assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER)); + if cfg!(unix) { + // An endless source: refused, not read until memory runs out. + assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE)); + } +} + +#[test] +fn documents_are_refused_as_folders_and_devices() { + let d = Dir::new("docs"); + let mut s = Session::new(); + for cmd in ["file.open", "file.import"] { + refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE); + } + } + const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") }; +} + +#[test] +fn placed_images_are_capped_at_what_a_document_takes() { + let d = Dir::new("images"); + let mut s = Session::new(); + let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1); + refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here"); + refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE); + } +} + +#[test] +fn fonts_and_plugins_are_refused_as_folders() { + let d = Dir::new("plugins"); + let mut s = Session::new(); + refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER); + // A plugin folder whose manifest is a folder. + std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap(); + refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER); + assert!(s.prefs.plugins.is_empty()); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index b34294a8..e9eca6a1 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -291,7 +291,7 @@ fn variables_import_caps_the_file_it_reads() { for bad in &refused { let before = (format!("{:?}", t.docs), t.active); let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}"); + assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}"); assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}"); } // A file under the limit still imports. diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 99af5f0f..6f296479 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -584,7 +584,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: & image_pending = true; let target = target.clone(); app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { - let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return }; + let Ok(bytes) = figcraft_engine::file_access::read_capped(&p, figcraft_doc::ImageData::MAX_BYTES) else { return }; let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; if let Some(mut paint) = current_paint(app, &target) { paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0a5a475a..b9cdd36e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -185,6 +185,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | | 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. | | 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d208e83..81f1392b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114 (the rest): documents, placed images and CLI scripts read by path are regular files within caps (16 GiB, 256 MiB, 1 GiB); folders, devices and pipes are refused before anything is read. Tests: `path_read_caps_tests` | | 2026-10-30 | minor | M8: a component set's property rows fit the 240 px right panel (values wrap; + and − stay in view). Tests: `component_set_narrow_tests` | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: team libraries are checked in the background only when you added or checked them here (`library.check`); opening a file that names another server contacts nothing. Tests: `library_beacon_tests` |