Refuse incomplete SVG/PDF exports beyond the depth limit (#218)

SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes #208.

Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.

Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
This commit is contained in:
YTwsy
2026-10-14 05:23:28 +07:00
committed by GitHub
co-authored by bflatastic
parent e8d7dc24f2
commit 33dcb1aef6
20 changed files with 441 additions and 17 deletions
+14
View File
@@ -0,0 +1,14 @@
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
fn main() {
main_thread_stack();
}
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
/// to 256 deep in an export, and that recursion needs the same room everywhere.
fn main_thread_stack() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
}
+29 -2
View File
@@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
}
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
.map_err(|e| CliError::Failed(e.to_string()))?
.into_bytes(),
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
@@ -379,4 +379,31 @@ mod tests {
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
let page = doc.first_page().unwrap();
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for _ in 1..257 {
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
}
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
panic!("depth overflow must be an operation failure");
};
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
assert!(error.contains("select an inner layer"), "{ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
use std::process::Command;
#[test]
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
let mut doc = Document::new("export depth");
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
let mut parent = root;
for depth in 1..257 {
let name = if depth == 1 { "inner-root" } else { "group" };
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
}
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let input = dir.join("deep.figcraft");
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
for ext in ["svg", "pdf"] {
let path = dir.join(format!("previous.{ext}"));
std::fs::write(&path, b"previous export").unwrap();
for verb in ["convert", "export"] {
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
command.arg(verb).arg(&input);
if verb == "export" {
command.arg("-o");
}
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
}
let inner = dir.join(format!("inner.{ext}"));
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("export")
.arg(&input)
.arg("-o")
.arg(&inner)
.args(["--node", "inner-root", "--no-system-fonts"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let bytes = std::fs::read(inner).unwrap();
if ext == "svg" {
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
} else {
assert!(bytes.starts_with(b"%PDF"));
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
std::fs::remove_dir_all(dir).unwrap();
}