mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
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:
co-authored by
Claude Opus 5.5
GPT-6.1 Sol
parent
38a4feccc9
commit
9093afac10
@@ -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)")),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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})));
|
||||
|
||||
Reference in New Issue
Block a user