M8: every file a command reads by path is a regular file within a cap (#1114) (#1625)

This commit is contained in:
BFlat
2026-10-30 13:48:26 +07:00
committed by GitHub
parent b0717ba627
commit c47177498d
16 changed files with 180 additions and 29 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, 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<ExitCode> {
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.
+3 -3
View File
@@ -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)" },
]
+29 -8
View File
@@ -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<Path>) -> Result<Vec<u8>> {
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<Path>, max: u64) -> Result<Vec<u8>> {
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.
@@ -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()));
}
+2 -1
View File
@@ -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<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES)
}
#[cfg(target_arch = "wasm32")]
+1 -1
View File
@@ -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)]
+6 -1
View File
@@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
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<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, MAX_DOCUMENT_FILE)
}
#[cfg(target_arch = "wasm32")]
+1 -5
View File
@@ -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<Vec<u8>> {
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
+4 -1
View File
@@ -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}");
}
}
+2
View File
@@ -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;
+96
View File
@@ -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::<Vec<_>>()), s.active);
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), 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());
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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;
+2 -1
View File
@@ -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. |
+2 -1
View File
File diff suppressed because one or more lines are too long