M8: an explicit variables file is refused past 256 MiB or when it isn't a regular file (part of #1114)

variable.import and the variables parameter of file.open and file.import read an explicit path
with the uncapped reader, while the sibling variables.json next to a REST file already went
through read_capped with MAX_VARIABLES_FILE. variables_param now uses that capped read for paths
on native builds; the browser keeps its no-file-system error. Inline objects and JSON text, and
access roots, are unchanged. The check reads the size before the file, so it does not bound a
file that grows or is replaced in between. Primary file reads and image.place stay uncapped.
variable.import's help and docs/mcp.md name the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
Mert Dede
2026-10-30 05:31:20 +03:00
co-authored by Claude Opus 5.5 GPT-6.1 Sol
parent 38a4feccc9
commit 9093afac10
8 changed files with 102 additions and 7 deletions
+4
View File
@@ -699,6 +699,10 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
None | Some(Value::Null) => return Ok(None),
Some(v @ Value::Object(_)) => v.clone(),
Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?,
// Capped like the sibling file; the browser has no file system, which `read` says.
#[cfg(not(target_arch = "wasm32"))]
Some(Value::String(path)) => parse(&read_capped(path, MAX_VARIABLES_FILE)?)?,
#[cfg(target_arch = "wasm32")]
Some(Value::String(path)) => parse(&read(path)?)?,
Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")),
};
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path to a regular file of at most 256 MiB; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+57
View File
@@ -94,6 +94,63 @@ fn open_reads_variables_from_param_or_next_to_the_file() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Every document, the active tab and the recent files, compared before and after a refused command.
fn state(s: &Session) -> (String, usize, Vec<String>) {
(format!("{:?}", s.docs), s.active, s.prefs.recent_files.clone())
}
#[test]
fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
let dir = temp_dir("restvars-cap");
let path = dir.join("proto.json");
std::fs::write(&path, serde_json::to_vec(&file()).unwrap()).unwrap();
let path = path.to_string_lossy().to_string();
let vars = dir.join("theme.json");
std::fs::write(&vars, serde_json::to_vec(&variables()).unwrap()).unwrap();
let vars = vars.to_string_lossy().to_string();
// A small file opens and imports as before.
let mut s = Session::new();
s.execute("file.open", &json!({"path": path, "variables": vars})).unwrap();
assert_eq!(named(&s, "Button").bound.get("fill").map(String::as_str), Some(BRAND));
// One byte over the limit (sparse, so nothing is written), a folder, and a device are refused
// before the document changes, by the same check the sibling file goes through.
let big = dir.join("big.json");
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
let mut refused = vec![big.to_string_lossy().to_string(), dir.to_string_lossy().to_string()];
if cfg!(unix) {
refused.push("/dev/null".to_string());
}
s.prefs.recent_files = vec![path.clone()];
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
for bad in &refused {
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_eq!(state(&s), before, "{cmd} {bad}");
}
}
// Access roots still apply: inside them the file reads, outside it is rejected.
let inside = temp_dir("restvars-cap-root");
std::fs::copy(&vars, inside.join("theme.json")).unwrap();
std::fs::copy(&path, inside.join("proto.json")).unwrap();
let roots = std::sync::Arc::new(crate::file_access::Roots::new(&[inside.to_string_lossy().to_string()], &[]).unwrap());
let inside_vars = inside.join("theme.json").to_string_lossy().to_string();
let inside_path = inside.join("proto.json").to_string_lossy().to_string();
crate::file_access::confined(Some(&roots), || {
s.execute("file.import", &json!({"path": inside_path, "variables": inside_vars})).unwrap();
let before = state(&s);
let e = s.execute("file.import", &json!({"path": inside_path, "variables": vars})).unwrap_err().to_string();
assert!(e.contains("automation path rejected"), "{e}");
assert_eq!(state(&s), before);
});
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&inside);
}
#[test]
fn import_appends_flows_interactions_and_variables() {
let mut s = Session::new();
+30
View File
@@ -270,6 +270,36 @@ fn variables_import_checks_what_it_reads() {
healthy(&s);
}
#[test]
fn variables_import_caps_the_file_it_reads() {
let dir = std::env::temp_dir().join(format!("figcraft-vars-io-cap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
theme(&mut s);
let small = dir.join("theme.json").to_string_lossy().into_owned();
ex(&mut s, "variable.export", json!({"path": small}));
let mut t = Session::new();
ex(&mut t, "variable.createCollection", json!({"name": "Other"}));
// One byte over the limit (sparse, so nothing is written), a folder, and a device: refused,
// nothing changes.
let big = dir.join("big.json");
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
let mut refused = vec![big.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()];
if cfg!(unix) {
refused.push("/dev/null".to_string());
}
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_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
}
// A file under the limit still imports.
ex(&mut t, "variable.import", json!({"variables": small}));
assert_eq!(t.doc().unwrap().variables.collections.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// A rectangle filled with a three-stop linear gradient, and colour variables `brand` and `ink`.
fn gradient_rect(s: &mut Session) -> NodeId {
let r = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})));