From 92649ffdb0e36d04a08e7698da7841c8ea9a6d0e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 02:50:26 -0600 Subject: [PATCH] Security: validate engine inputs and package paths (#155) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 19 ++ Cargo.toml | 1 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/guides.rs | 179 ++++++++++--- crates/engine/src/cmd/object.rs | 155 ++++++++++- crates/engine/src/cmd/package.rs | 447 ++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 8 +- 7 files changed, 722 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d18de53d..9473c974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,6 +635,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + [[package]] name = "cast" version = "0.3.0" @@ -937,6 +946,7 @@ dependencies = [ name = "designcraft-engine" version = "0.5.0" dependencies = [ + "caseless", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -4507,6 +4517,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 34417be8..d69017d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ unicode-bidi = "0.3" unicode-script = "0.5" unicode-bidi-mirroring = "0.4" unicode-segmentation = "1" +caseless = "0.2" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } tempfile = "3" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 23350e7a..8b4604f1 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,6 +28,7 @@ thiserror = { workspace = true } log = { workspace = true } regex = { workspace = true } quick-xml = { workspace = true } +caseless = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 8a0fee5e..47aacdc8 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -7,9 +7,13 @@ use designcraft_doc::{Document, Guide, Orientation, SpreadRef}; use designcraft_geom::Rect; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, spread_param, str_param}; +use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; +const MAX_GUIDE_DIVISIONS: u64 = 1000; +const MAX_GUIDES_CREATED: usize = 10_000; +const MAX_GUIDE_GUTTER: f64 = 1_000_000.0; + pub fn specs() -> Vec { vec![ cmd!( @@ -24,7 +28,7 @@ pub fn specs() -> Vec { cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { - let r = spread_param(p, "spread"); + let r = guide_spread(p, "guide.deleteAll")?; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; let mut n = 0; @@ -37,7 +41,7 @@ pub fn specs() -> Vec { }), cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { let d = &s.doc()?.doc; - let sp = d.spread(spread_param(p, "spread")).ok_or_else(|| bad("guide.list", "no such spread"))?; + let sp = d.spread(guide_spread(p, "guide.list")?).ok_or_else(|| bad("guide.list", "no such spread"))?; let mut out = Vec::new(); for (pi, pg) in sp.pages.iter().enumerate() { for (i, g) in pg.guides.iter().enumerate() { @@ -51,13 +55,48 @@ pub fn specs() -> Vec { "Create Guides…", ["Layout"], None, - "{rows?: 0, columns?: 0 (at most 100 each), rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}", + "{rows?: 0..1000, columns?: 0..1000, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)} — at most 10000 guides per command", has_doc, create_guides ), ] } +fn checked_usize(value: &Value, key: &str, command: &str) -> Result { + let n = value.as_u64().ok_or_else(|| bad(command, format!("`{key}` must be a non-negative integer")))?; + usize::try_from(n).map_err(|_| bad(command, format!("`{key}` is too large for this platform"))) +} + +fn optional_usize(p: &Value, key: &str, command: &str) -> Result> { + p.get(key).map(|value| checked_usize(value, key, command)).transpose() +} + +/// Numeric spread shorthand or the serialized `SpreadRef` form, without lossy/defaulting casts. +fn guide_spread(p: &Value, command: &str) -> Result { + let Some(value) = p.get("spread") else { return Ok(SpreadRef::Doc(0)) }; + if value.is_number() { + return Ok(SpreadRef::Doc(checked_usize(value, "spread", command)?)); + } + let object = value.as_object().ok_or_else(|| bad(command, "`spread` must be a non-negative integer or a spread reference"))?; + let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| bad(command, "`spread.kind` must be `doc` or `parent`"))?; + let index = object.get("index").ok_or_else(|| bad(command, "missing `spread.index`"))?; + let index = checked_usize(index, "spread.index", command)?; + match kind { + "doc" => Ok(SpreadRef::Doc(index)), + "parent" => Ok(SpreadRef::Parent(index)), + _ => Err(bad(command, "`spread.kind` must be `doc` or `parent`")), + } +} + +fn finite_number(p: &Value, key: &str, command: &str) -> Result> { + let Some(value) = p.get(key) else { return Ok(None) }; + let number = value.as_f64().ok_or_else(|| bad(command, format!("`{key}` must be a finite number")))?; + if !number.is_finite() { + return Err(bad(command, format!("`{key}` must be a finite number"))); + } + Ok(Some(number)) +} + fn orientation(p: &Value) -> Result { match str_param(p, "orientation") { Some("horizontal") => Ok(Orientation::Horizontal), @@ -73,17 +112,17 @@ fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { fn add(s: &mut Session, p: &Value) -> Result { let o = orientation(p)?; - let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.add", "missing position"))?; - let r = spread_param(p, "spread"); + let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; + let r = guide_spread(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; - let pi = match p.get("page").and_then(Value::as_u64) { - Some(pi) => pi as usize, + let pi = match optional_usize(p, "page", "guide.add")? { + Some(pi) => pi, None if spread_guide => 0, None => { let x = match o { Orientation::Vertical => pos, - Orientation::Horizontal => p.get("at").and_then(Value::as_f64).unwrap_or(0.0), + Orientation::Horizontal => finite_number(p, "at", "guide.add")?.unwrap_or(0.0), }; page_at(d, r, x) } @@ -98,9 +137,9 @@ fn add(s: &mut Session, p: &Value) -> Result { } fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { - let pi = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))? as usize; - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize; - Ok((spread_param(p, "spread"), pi, i)) + let pi = optional_usize(p, "page", cmd)?.ok_or_else(|| bad(cmd, "missing page"))?; + let i = optional_usize(p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))?; + Ok((guide_spread(p, cmd)?, pi, i)) } /// Commands must use the same guide/layer protection as pointer editing. @@ -114,7 +153,7 @@ fn check_editable(d: &Document, r: SpreadRef, pi: usize, i: usize, command: &str fn move_guide(s: &mut Session, p: &Value) -> Result { let (r, pi, i) = guide_ref(p, "guide.move")?; - let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?; + let pos = finite_number(p, "position", "guide.move")?.ok_or_else(|| bad("guide.move", "missing position"))?; s.edit(|d, _| { check_editable(d, r, pi, i, "guide.move")?; let g = d @@ -140,40 +179,89 @@ fn delete(s: &mut Session, p: &Value) -> Result { }) } +fn guide_divisions(p: &Value, key: &str) -> Result { + let Some(value) = p.get(key) else { return Ok(0) }; + let count = value.as_u64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be an integer")))?; + if count > MAX_GUIDE_DIVISIONS { + return Err(bad("layout.createGuides", format!("`{key}` must not exceed {MAX_GUIDE_DIVISIONS}"))); + } + Ok(count) +} + +fn guide_gutter(p: &Value, key: &str) -> Result { + let Some(value) = p.get(key) else { return Ok(12.0) }; + let gutter = value.as_f64().ok_or_else(|| bad("layout.createGuides", format!("`{key}` must be a finite number")))?; + if !gutter.is_finite() || !(0.0..=MAX_GUIDE_GUTTER).contains(&gutter) { + return Err(bad("layout.createGuides", format!("`{key}` must be between 0 and {MAX_GUIDE_GUTTER}"))); + } + Ok(gutter) +} + +fn guide_count(divisions: u64, gutter: f64) -> Result { + let edges = divisions.saturating_sub(1); + let count = if gutter > 0.0 { edges.checked_mul(2) } else { Some(edges) } + .ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?; + usize::try_from(count).map_err(|_| bad("layout.createGuides", "the requested guide count is too large for this platform")) +} + /// Evenly spaced rows/columns with gutters inside `area`: the guide positions. -pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec { +fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result> { if n < 2 { - return vec![]; + return Ok(vec![]); + } + if !a.is_finite() || !b.is_finite() || !gutter.is_finite() || n > MAX_GUIDE_DIVISIONS { + return Err(bad("layout.createGuides", "guide geometry must be finite and within the supported range")); } let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64; - let mut v = Vec::new(); + if !cell.is_finite() { + return Err(bad("layout.createGuides", "guide geometry is outside the supported range")); + } + let mut v = Vec::with_capacity(guide_count(n, gutter)?); for k in 1..n { let edge = a + k as f64 * cell + (k - 1) as f64 * gutter; + if !edge.is_finite() || (gutter > 0.0 && !(edge + gutter).is_finite()) { + return Err(bad("layout.createGuides", "guide geometry is outside the supported range")); + } v.push(edge); if gutter > 0.0 { v.push(edge + gutter); } } - v + Ok(v) } -/// The most rows or columns Create Guides lays out. The count comes from the caller, and every -/// guide is stored in the document. -const MAX_GUIDE_GRID: u32 = 100; - fn create_guides(s: &mut Session, p: &Value) -> Result { - let count = |key: &str| p.get(key).and_then(Value::as_u64).unwrap_or(0).min(u64::from(MAX_GUIDE_GRID)) as u32; - let (rows, cols) = (count("rows"), count("columns")); - let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0); - let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0); + let rows = guide_divisions(p, "rows")?; + let cols = guide_divisions(p, "columns")?; + let rg = guide_gutter(p, "rowGutter")?; + let cg = guide_gutter(p, "columnGutter")?; let to_page = str_param(p, "fitTo") == Some("page"); let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); - let r = spread_param(p, "spread"); - let only = p.get("page").and_then(Value::as_u64).map(|v| v as usize); + let r = guide_spread(p, "layout.createGuides")?; + let only = optional_usize(p, "page", "layout.createGuides")?; + let page_count = { + let sp = s.doc()?.doc.spread(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; + match only { + Some(page) => { + if sp.pages.get(page).is_none() { + return Err(bad("layout.createGuides", "no such page")); + } + 1 + } + None => sp.pages.len(), + } + }; + let per_page = guide_count(rows, rg)? + .checked_add(guide_count(cols, cg)?) + .ok_or_else(|| bad("layout.createGuides", "the requested guide count is too large"))?; + per_page + .checked_mul(page_count) + .filter(|count| *count <= MAX_GUIDES_CREATED) + .ok_or_else(|| bad("layout.createGuides", format!("a single command may create at most {MAX_GUIDES_CREATED} guides")))?; let layer = s.doc()?.active_layer; s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; - let mut n = 0; + let mut n = 0usize; for (pi, pg) in sp.pages.iter_mut().enumerate() { if only.is_some_and(|o| o != pi) { continue; @@ -182,7 +270,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { pg.guides.clear(); } let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() }; - for y in grid_positions(area.y0, area.y1, rows, rg) { + for y in grid_positions(area.y0, area.y1, rows, rg)? { pg.guides.push(Guide { orientation: Orientation::Horizontal, position: y, @@ -193,7 +281,7 @@ fn create_guides(s: &mut Session, p: &Value) -> Result { }); n += 1; } - for x in grid_positions(area.x0, area.x1, cols, cg) { + for x in grid_positions(area.x0, area.x1, cols, cg)? { pg.guides.push(Guide { orientation: Orientation::Vertical, position: x, @@ -243,16 +331,33 @@ mod tests { assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5); } - /// A huge row or column count once made that many guides (2³² − 1 of them exhaust memory, which - /// no guard catches), and a count past 2³² wrapped to a small one. #[test] - fn create_guides_caps_the_row_and_column_counts() { + fn create_guides_validates_generation_limits() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); - let args = json!({"spread": 0, "rows": 4_294_967_298_u64, "columns": 3_000_000, "rowGutter": 0.0, "columnGutter": 0.0}); - let r = s.execute("layout.createGuides", &args).unwrap(); - let per_page = 2 * (u64::from(MAX_GUIDE_GRID) - 1); - assert_eq!(r["guides"], per_page * s.doc().unwrap().doc.spreads[0].pages.len() as u64); + assert!(s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS + 1})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"columns": 2, "columnGutter": -1})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"columns": "many"})).is_err()); + let r = s.execute("layout.createGuides", &json!({"columns": MAX_GUIDE_DIVISIONS, "columnGutter": 0})).unwrap(); + assert_eq!(r["guides"], MAX_GUIDE_DIVISIONS - 1); + } + + #[test] + fn mutating_guide_commands_reject_invalid_indices() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("guide.add", &json!({"orientation": "vertical", "position": 100})).unwrap(); + + assert!(s.execute("guide.add", &json!({"orientation": "vertical", "position": 100, "page": -1})).is_err()); + assert!(s.execute("guide.move", &json!({"page": 0.5, "index": 0, "position": 200})).is_err()); + assert!(s.execute("guide.delete", &json!({"page": 0, "index": -1})).is_err()); + assert!(s.execute("guide.deleteAll", &json!({"spread": "0"})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"spread": -1, "columns": 2})).is_err()); + assert!(s.execute("layout.createGuides", &json!({"page": u64::MAX, "columns": 2})).is_err()); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 1); + + s.execute("guide.add", &json!({"spread": {"kind": "doc", "index": 0}, "page": 0, "orientation": "horizontal", "position": 150})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({})).unwrap().as_array().unwrap().len(), 2); } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c03d9ffa..17c44c27 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1,7 +1,7 @@ //! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide, //! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use designcraft_color::BlendMode; @@ -565,7 +565,7 @@ pub fn specs() -> Vec { "Step and Repeat…", ["Edit"], Some("Cmd+Alt+U"), - "{count?: 1, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid", + "{count?: 1..1000, dx?, dy?, rows?, columns?, ids?} — copies of the selection (or ids) offset by (dx, dy); with rows/columns, a grid of at most 1000 cells, and at most 1000 item copies including group contents", has_selection, step_and_repeat ), @@ -1962,20 +1962,113 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { }) } +const MAX_STEP_REPEAT_COPIES: u64 = 1000; +const MAX_STEP_REPEAT_WORK: usize = 1000; +const MAX_STEP_REPEAT_OFFSET: f64 = 1_000_000_000.0; + +fn step_repeat_count(p: &Value, key: &str, default: u64) -> Result { + let Some(value) = p.get(key) else { return Ok(default) }; + let count = value.as_u64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be an integer")))?; + if !(1..=MAX_STEP_REPEAT_COPIES).contains(&count) { + return Err(bad("edit.stepAndRepeat", format!("`{key}` must be between 1 and {MAX_STEP_REPEAT_COPIES}"))); + } + Ok(count) +} + +fn step_repeat_delta(p: &Value, key: &str, default: f64) -> Result { + let Some(value) = p.get(key) else { return Ok(default) }; + let delta = value.as_f64().ok_or_else(|| bad("edit.stepAndRepeat", format!("`{key}` must be a finite number")))?; + if !delta.is_finite() { + return Err(bad("edit.stepAndRepeat", format!("`{key}` must be a finite number"))); + } + Ok(delta) +} + +fn checked_step_offset(dx: f64, dy: f64, x: u64, y: u64) -> Result { + let (x, y) = (x as f64 * dx, y as f64 * dy); + if !x.is_finite() || !y.is_finite() || x.abs() > MAX_STEP_REPEAT_OFFSET || y.abs() > MAX_STEP_REPEAT_OFFSET { + return Err(bad("edit.stepAndRepeat", format!("generated offsets must stay between -{MAX_STEP_REPEAT_OFFSET} and {MAX_STEP_REPEAT_OFFSET}"))); + } + Ok(Vec2::new(x, y)) +} + +fn step_repeat_offsets(p: &Value) -> Result> { + let (dx, dy) = (step_repeat_delta(p, "dx", 12.0)?, step_repeat_delta(p, "dy", 12.0)?); + match (p.get("rows"), p.get("columns")) { + (None, None) => { + let count = step_repeat_count(p, "count", 1)?; + (1..=count).map(|k| checked_step_offset(dx, dy, k, k)).collect() + } + (Some(_), Some(_)) => { + let rows = step_repeat_count(p, "rows", 1)?; + let columns = step_repeat_count(p, "columns", 1)?; + let cells = rows + .checked_mul(columns) + .filter(|count| *count <= MAX_STEP_REPEAT_COPIES) + .ok_or_else(|| bad("edit.stepAndRepeat", format!("`rows` × `columns` must not exceed {MAX_STEP_REPEAT_COPIES}")))?; + let capacity = usize::try_from(cells.saturating_sub(1)) + .map_err(|_| bad("edit.stepAndRepeat", "the requested grid is too large for this platform"))?; + let mut offsets = Vec::with_capacity(capacity); + for row in 0..rows { + for column in 0..columns { + if row != 0 || column != 0 { + offsets.push(checked_step_offset(dx, dy, column, row)?); + } + } + } + Ok(offsets) + } + _ => Err(bad("edit.stepAndRepeat", "`rows` and `columns` must be provided together")), + } +} + +/// Existing roots to duplicate, with repeated/ancestor-overlapping targets collapsed. Returns an +/// error before cloning the document when the copies (including group descendants) exceed the +/// command's work budget. +fn checked_step_repeat_targets(d: &Document, ids: &[ItemId], copies: usize, max_work: usize) -> Result> { + let mut seen = HashSet::new(); + let mut located = Vec::new(); + for id in ids { + if seen.insert(*id) + && let Some(loc) = d.find(*id) + { + located.push((*id, loc)); + } + } + if located.is_empty() { + return Err(bad("edit.stepAndRepeat", "nothing to repeat")); + } + + // A selected ancestor already deep-copies its descendants. Keeping a selected child as a + // second root would duplicate it twice and make the checked work disagree with the real work. + let roots: Vec = located + .iter() + .filter(|(_, loc)| { + !located.iter().any(|(_, parent)| parent.spread == loc.spread && parent.path.len() < loc.path.len() && loc.path.starts_with(&parent.path)) + }) + .map(|(id, _)| *id) + .collect(); + + let mut expanded = HashSet::new(); + for id in &roots { + if let Some(item) = d.item(*id) { + item.walk(&mut |child| { + expanded.insert(child.id); + }); + } + } + expanded + .len() + .checked_mul(copies) + .filter(|work| *work <= max_work) + .ok_or_else(|| bad("edit.stepAndRepeat", format!("a single command may create at most {max_work} items, including group contents")))?; + Ok(roots) +} + fn step_and_repeat(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; - let (dx, dy) = (f64_or(p, "dx", 12.0), f64_or(p, "dy", 12.0)); - let rows = p.get("rows").and_then(Value::as_u64).map(|v| v as usize); - let cols = p.get("columns").and_then(Value::as_u64).map(|v| v as usize); - let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize; - let offsets: Vec = match (rows, cols) { - (Some(r), Some(c)) if r.checked_mul(c).is_some_and(|n| n <= 1000) => (0..r) - .flat_map(|i| (0..c).map(move |j| (i, j))) - .filter(|&(i, j)| i + j > 0) - .map(|(i, j)| Vec2::new(j as f64 * dx, i as f64 * dy)) - .collect(), - _ => (1..=count).map(|k| Vec2::new(k as f64 * dx, k as f64 * dy)).collect(), - }; + let offsets = step_repeat_offsets(p)?; + let ids = checked_step_repeat_targets(&s.doc()?.doc, &ids, offsets.len(), MAX_STEP_REPEAT_WORK)?; s.edit(|d, sel| { let src = d.clone(); let mut all = ids.clone(); @@ -1990,6 +2083,40 @@ fn step_and_repeat(s: &mut Session, p: &Value) -> Result { }) } +#[cfg(test)] +mod step_repeat_tests { + use super::*; + + #[test] + fn step_repeat_validates_counts_and_offsets() { + assert_eq!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES})).unwrap().len(), MAX_STEP_REPEAT_COPIES as usize); + assert_eq!(step_repeat_offsets(&json!({"rows": 25, "columns": 40})).unwrap().len(), 999); + assert!(step_repeat_offsets(&json!({"count": MAX_STEP_REPEAT_COPIES + 1})).is_err()); + assert!(step_repeat_offsets(&json!({"rows": 500, "columns": 3})).is_err()); + assert!(step_repeat_offsets(&json!({"rows": 3})).is_err()); + assert!(step_repeat_offsets(&json!({"count": 2, "dx": f64::MAX})).is_err()); + } + + #[test] + fn step_repeat_counts_unique_items_and_group_contents() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = ItemId(s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap()); + let b = ItemId(s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap()); + let c = ItemId(s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(checked_step_repeat_targets(d, &[a, a], 2, 2).unwrap(), vec![a]); + assert!(checked_step_repeat_targets(d, &[a, b, c], 1, 2).is_err()); + assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [a.0, a.0], "count": 1})).unwrap()["created"], 1); + + let group = ItemId(s.execute("object.group", &json!({"ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_eq!(checked_step_repeat_targets(d, &[group, group, a], 1, 3).unwrap(), vec![group]); + assert!(checked_step_repeat_targets(d, &[group, a], 1, 2).is_err()); + assert_eq!(s.execute("edit.stepAndRepeat", &json!({"ids": [group.0, a.0], "count": 1})).unwrap()["created"], 1); + } +} + #[cfg(test)] mod polygon_tests { use super::*; diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 50556d97..46866751 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -1,15 +1,194 @@ //! File › Package and Links › Copy Links To: gather a document with the files it uses. +use std::fs::Metadata; use std::path::{Path, PathBuf}; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use std::fs::File; +#[cfg(not(target_arch = "wasm32"))] +use std::io::Write; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{EngineError, Result, Session}; use designcraft_compose::ComposedStory; use designcraft_doc::{AssetId, Document}; use designcraft_fonts::{FaceRef, FontFace, FontSource}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{EngineError, Result, Session}; +const MAX_OUTPUT_COMPONENT_BYTES: usize = 200; + +fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError { + EngineError::Other(format!("{}: {message}", path.display())) +} + +fn is_redirect(metadata: &Metadata) -> bool { + // Rust reports Windows name-surrogate reparse points (including directory junctions) as + // symlinks, while leaving non-redirecting reparse metadata such as cloud placeholders alone. + metadata.file_type().is_symlink() +} + +fn ensure_real_directory(path: &Path, ancestor: bool) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if is_redirect(&metadata) { + // An existing folder above the package may be reached through a link the user + // set up (`/tmp` on macOS is one); the package folder itself may not. + if ancestor && std::fs::metadata(path).is_ok_and(|m| m.is_dir()) { + return Ok(()); + } + return Err(path_error(path, "refusing a redirected output directory")); + } + if !metadata.is_dir() { + return Err(path_error(path, "output path is not a directory")); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) + && parent != path + { + ensure_real_directory(parent, true)?; + } + if let Err(e) = std::fs::create_dir(path) + && e.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(path_error(path, e)); + } + let metadata = std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?; + if is_redirect(&metadata) || !metadata.is_dir() { + return Err(path_error(path, "output directory was redirected while it was being created")); + } + } + Err(e) => return Err(path_error(path, e)), + } + Ok(()) +} + +/// A selected output directory and its resolved identity. Every package path is checked against +/// it before use. Outputs are staged in the same directory and atomically persisted over an +/// existing regular file, preserving overwrite behavior without following file links. +struct OutputRoot { + path: PathBuf, + canonical: PathBuf, +} + +impl OutputRoot { + fn new(path: PathBuf) -> Result { + ensure_real_directory(&path, false)?; + let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; + Ok(Self { path, canonical }) + } + + fn validate_directory(&self, path: &Path) -> Result<()> { + if !path.starts_with(&self.path) { + return Err(path_error(path, "output path is outside the selected directory")); + } + ensure_real_directory(path, false)?; + let canonical = std::fs::canonicalize(path).map_err(|e| path_error(path, e))?; + if !canonical.starts_with(&self.canonical) { + return Err(path_error(path, "output directory resolves outside the selected directory")); + } + Ok(()) + } + + fn child(&self, name: &str) -> Result { + let path = self.path.join(name); + if path.parent() != Some(self.path.as_path()) { + return Err(path_error(&path, "invalid output directory name")); + } + Ok(path) + } + + fn validate_file_entry(&self, path: &Path) -> Result { + let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; + self.validate_directory(parent)?; + if !path.starts_with(&self.path) { + return Err(path_error(path, "output file is outside the selected directory")); + } + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if is_redirect(&metadata) { + return Err(path_error(path, "refusing to replace a redirected output file")); + } + if !metadata.is_file() { + return Err(path_error(path, "output path is not a regular file")); + } + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(path_error(path, e)), + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn temporary_file(&self, path: &Path, permissions: Option) -> Result { + let parent = path.parent().ok_or_else(|| path_error(path, "output file has no parent directory"))?; + self.validate_directory(parent)?; + let mut builder = tempfile::Builder::new(); + builder.prefix(".designcraft-package-").suffix(".tmp"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + // Match fs::write for a new output: 0666 restricted by the caller's umask. + builder.permissions(std::fs::Permissions::from_mode(0o666)); + } + let file = builder.tempfile_in(parent).map_err(|e| path_error(path, e))?; + #[cfg(unix)] + if let Some(permissions) = permissions { + // Overwrites retain their prior mode; copied fonts retain their source mode. + file.as_file().set_permissions(permissions).map_err(|e| path_error(path, e))?; + } + #[cfg(not(unix))] + // The replacement inherits its directory's ACL. Rust's portable permission API cannot + // reproduce a destination file's Windows DACL without platform-specific code. + let _ = permissions; + Ok(file) + } + + fn write(&self, path: &Path, bytes: &[u8]) -> Result<()> { + #[cfg(target_arch = "wasm32")] + { + let _ = (path, bytes); + return Err(EngineError::Other("package files are unavailable on the web".into())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let existed = self.validate_file_entry(path)?; + let permissions = if existed { Some(std::fs::symlink_metadata(path).map_err(|e| path_error(path, e))?.permissions()) } else { None }; + let mut file = self.temporary_file(path, permissions)?; + file.write_all(bytes).map_err(|e| path_error(path, e))?; + // Recheck immediately before replacement. If a name is raced after this check, + // persist replaces that directory entry rather than following it. + self.validate_file_entry(path)?; + file.persist(path).map_err(|e| path_error(path, e.error))?; + Ok(()) + } + } + + fn copy(&self, from: &Path, to: &Path) -> Result<()> { + if self.validate_file_entry(to)? + && std::fs::canonicalize(to).is_ok_and(|target| std::fs::canonicalize(from).is_ok_and(|source| source == target)) + { + return Ok(()); + } + #[cfg(target_arch = "wasm32")] + { + let _ = from; + return Err(EngineError::Other("package files are unavailable on the web".into())); + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut source = File::open(from).map_err(|e| path_error(from, e))?; + let permissions = Some(source.metadata().map_err(|e| path_error(from, e))?.permissions()); + let mut target = self.temporary_file(to, permissions)?; + std::io::copy(&mut source, &mut target).map_err(|e| path_error(to, e))?; + self.validate_file_entry(to)?; + target.persist(to).map_err(|e| path_error(to, e.error))?; + Ok(()) + } + } +} pub fn specs() -> Vec { vec![ @@ -34,14 +213,57 @@ pub fn specs() -> Vec { ] } -/// A file name for asset `a` in a Links folder, unique among `taken`. -fn link_name(name: &str, id: AssetId, taken: &mut Vec) -> String { - let base = - Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("asset-{}", id.0)); - unique_name(base, taken) +fn is_reserved_windows_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).trim_end_matches([' ', '.']); + ["con", "prn", "aux", "nul"].into_iter().any(|reserved| caseless::compatibility_caseless_match_str(stem, reserved)) + || (1..=9).any(|n| { + caseless::compatibility_caseless_match_str(stem, &format!("com{n}")) + || caseless::compatibility_caseless_match_str(stem, &format!("lpt{n}")) + }) } -/// `base`, or `base` numbered ("logo 2.png"), unique among `taken` (case-insensitively). +fn portable_names_equal(a: &str, b: &str) -> bool { + caseless::canonical_caseless_match_str(a, b) +} + +/// A portable single path component made from document metadata. +fn safe_component(name: &str, fallback: &str) -> String { + let leaf = name.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(""); + let mut safe = String::new(); + for ch in leaf.trim().chars() { + let replacement = ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '/' | '\\'); + let ch = if replacement { '_' } else { ch }; + if safe.len() + ch.len_utf8() > MAX_OUTPUT_COMPONENT_BYTES { + break; + } + safe.push(ch); + } + safe.truncate(safe.trim_end_matches([' ', '.']).len()); + if safe.is_empty() || safe == "." || safe == ".." { + safe = fallback.to_string(); + } + if is_reserved_windows_name(&safe) { + safe.insert(0, '_'); + } + safe +} + +fn package_output_path(dir: &Path, title: &str, extension: &str) -> Result { + let path = dir.join(format!("{title}.{extension}")); + if path.parent() != Some(dir) { + return Err(bad("file.package", "an output filename would be outside the selected directory")); + } + Ok(path) +} + +/// A file name for asset `a` in a Links folder, unique among `taken`. +fn link_name(name: &str, id: AssetId, taken: &mut Vec) -> String { + let fallback = format!("asset-{}", id.0); + unique_name(safe_component(name, &fallback), taken) +} + +/// `base`, or `base` numbered ("logo 2.png"), unique under portable Unicode normalization and +/// case-insensitive comparison. fn unique_name(base: String, taken: &mut Vec) -> String { let (stem, ext) = match base.rsplit_once('.') { Some((s, e)) => (s.to_string(), format!(".{e}")), @@ -49,7 +271,7 @@ fn unique_name(base: String, taken: &mut Vec) -> String { }; let mut n = base; let mut k = 2; - while taken.iter().any(|t| t.eq_ignore_ascii_case(&n)) { + while taken.iter().any(|t| portable_names_equal(t, &n)) { n = format!("{stem} {k}{ext}"); k += 1; } @@ -58,8 +280,8 @@ fn unique_name(base: String, taken: &mut Vec) -> String { } /// Write each asset's bytes into `dir`; returns the relinked document and the paths written. -fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { - std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?; +fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> { + root.validate_directory(dir)?; let mut out = d.clone(); let mut taken = Vec::new(); let mut written = Vec::new(); @@ -72,7 +294,7 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do let a = &d.assets[&id]; let name = link_name(a.link.as_deref().unwrap_or(&a.name), id, &mut taken); let path = dir.join(&name); - std::fs::write(&path, a.data.as_slice()).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + root.write(&path, a.data.as_slice())?; let mut na = (**a).clone(); na.link = Some(path.to_string_lossy().to_string()); out.assets.insert(id, Arc::new(na)); @@ -84,7 +306,7 @@ fn write_links(d: &Document, dir: &Path, only: Option<&[AssetId]>) -> Result<(Do /// Copy the font file of `face` into `dir` (created when first needed) unless its licence /// restricts it or it is already there (`copied`: the source files copied) → what the package /// report adds after the font's name (nothing when copied). -fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut Vec, files: &mut Vec) -> String { +fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut Vec, files: &mut Vec) -> String { let from = match &face.source { FontSource::Bundled => return " — included with DesignCraft".into(), FontSource::Memory => return " — not copied: no font file".into(), @@ -96,13 +318,10 @@ fn copy_font(face: &FontFace, dir: &Path, copied: &mut Vec, taken: &mut if copied.contains(from) { return String::new(); } - let name = from.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "font".into()); + let name = safe_component(&from.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(), "font"); let to = dir.join(unique_name(name, taken)); - // Packaging into the folder the font is in: it is there already (and copying a file onto - // itself empties it on some systems). - let same = std::fs::canonicalize(&to).is_ok_and(|t| std::fs::canonicalize(from).is_ok_and(|f| f == t)); - match std::fs::create_dir_all(dir).and_then(|_| if same { Ok(0) } else { std::fs::copy(from, &to) }) { - Ok(_) => { + match root.validate_directory(dir).and_then(|_| root.copy(from, &to)) { + Ok(()) => { copied.push(from.clone()); files.push(to); String::new() @@ -136,18 +355,22 @@ fn drawn_faces(cs: &ComposedStory, depth: usize, out: &mut Vec) { fn package(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("file.package", "missing `dir`"))?); let st = s.doc()?; - let title = st.doc.title.clone(); + let root = OutputRoot::new(dir.clone())?; + let display_title = st.doc.title.clone(); + let title = safe_component(&display_title, "Untitled"); let fonts = s.execute("font.list", &json!({}))?; let links = s.execute("links.list", &json!({}))?; let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null); let d = s.doc()?.doc.clone(); - let (packed, mut files) = write_links(&d, &dir.join("Links"), None)?; - let doc_path = dir.join(format!("{title}.designcraft")); - std::fs::write(&doc_path, super::to_bytes(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", doc_path.display())))?; + let links_dir = root.child("Links")?; + let (packed, mut files) = write_links(&d, &root, &links_dir, None)?; + let doc_path = package_output_path(&dir, &title, "designcraft")?; + root.write(&doc_path, &super::to_bytes(&packed))?; files.insert(0, doc_path); // The font files, and what the report says about each font. let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let (fonts_dir, mut copied, mut taken) = (dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER), Vec::new(), Vec::new()); + let fonts_dir = root.child(designcraft_fonts::DOCUMENT_FONTS_FOLDER)?; + let (mut copied, mut taken) = (Vec::new(), Vec::new()); let (mut font_lines, mut named) = (Vec::new(), Vec::new()); for f in fonts.as_array().into_iter().flatten() { let (family, style) = (f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")); @@ -155,7 +378,7 @@ fn package(s: &mut Session, p: &Value) -> Result { // A missing font's text is drawn in its substitute, which isn't a fallback font. let face = db.face(family, style); named.push(face.id()); - let note = if missing { " — MISSING".to_string() } else { copy_font(&face, &fonts_dir, &mut copied, &mut taken, &mut files) }; + let note = if missing { " — MISSING".to_string() } else { copy_font(&root, &face, &fonts_dir, &mut copied, &mut taken, &mut files) }; font_lines.push(format!(" {family} {style}{note}\n")); } // Fallback fonts: the faces that draw characters the named fonts lack. @@ -165,21 +388,23 @@ fn package(s: &mut Session, p: &Value) -> Result { } drawn.retain(|f| !named.contains(&f.id())); drawn.sort_by(|a, b| (&a.family, &a.style).cmp(&(&b.family, &b.style))); - let fallback_lines: Vec = - drawn.iter().map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(f, &fonts_dir, &mut copied, &mut taken, &mut files))).collect(); + let fallback_lines: Vec = drawn + .iter() + .map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files))) + .collect(); if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { - let path = dir.join(format!("{title}.idml")); - std::fs::write(&path, designcraft_idml::export_idml(&packed)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + let path = package_output_path(&dir, &title, "idml")?; + root.write(&path, &designcraft_idml::export_idml(&packed))?; files.push(path); } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { - let path = dir.join(format!("{title}.pdf")); + let path = package_output_path(&dir, &title, "pdf")?; let bytes = designcraft_pdf::export_pdf(&packed, &s.cache, &Default::default()).map_err(|e| EngineError::Other(e.to_string()))?; - std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + root.write(&path, &bytes)?; files.push(path); } // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {title}\n\n"); + let mut r = format!("Package report: {display_title}\n\n"); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } @@ -200,7 +425,7 @@ fn package(s: &mut Session, p: &Value) -> Result { } } let report = dir.join("Instructions.txt"); - std::fs::write(&report, &r).map_err(|e| EngineError::Other(format!("{}: {e}", report.display())))?; + root.write(&report, r.as_bytes())?; files.push(report); Ok(json!({"dir": dir.to_string_lossy(), "files": files.iter().map(|f| f.to_string_lossy().to_string()).collect::>(), "report": r})) } @@ -209,7 +434,8 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect()); let d = s.doc()?.doc.clone(); - let (relinked, written) = write_links(&d, &dir, only.as_deref())?; + let root = OutputRoot::new(dir.clone())?; + let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?; s.edit(|doc, _| { doc.assets = relinked.assets; Ok(()) @@ -291,6 +517,12 @@ mod tests { std::fs::create_dir_all(&fonts).unwrap(); std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); + } // Text in both fonts, and in the bundled default font. let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); @@ -308,6 +540,15 @@ mod tests { let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); assert!(packed_fonts.join("free.ttf").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, + std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 + ); + } assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); let report = r["report"].as_str().unwrap(); @@ -369,4 +610,142 @@ mod tests { assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn package_uses_portable_names_inside_the_selected_directory() { + assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); + assert_eq!(safe_component(".", "Untitled"), "Untitled"); + assert_eq!(safe_component("CON", "Untitled"), "_CON"); + assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); + assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); + for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { + assert!(is_reserved_windows_name(name), "{name}"); + } + let mut taken = Vec::new(); + assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); + assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); + assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); + assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); + assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); + assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); + + let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); + assert!(dir.join("Annual_ Review.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn package_overwrites_existing_regular_outputs() { + let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let document = dir.join("Annual.designcraft"); + std::fs::write(&document, b"previous package").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); + } + + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); + assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn package_accepts_a_relative_destination() { + let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Relative"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(dir.join("Relative.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn package_refuses_redirected_destinations() { + use std::os::unix::fs::symlink; + + let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + + let redirected_root = base.join("redirected-package"); + symlink(&outside, &redirected_root).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(&redirected_root).unwrap(); + + // A linked folder above the package is followed, as `/tmp` is on macOS. + let linked_parent = base.join("linked-parent"); + symlink(&outside, &linked_parent).unwrap(); + let nested_package = linked_parent.join("nested").join("Package"); + s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); + assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); + std::fs::remove_file(&linked_parent).unwrap(); + + let package = base.join("Package"); + std::fs::create_dir_all(&package).unwrap(); + symlink(&outside, package.join("Links")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(package.join("Links")).unwrap(); + std::fs::create_dir(package.join("Links")).unwrap(); + + let kept = outside.join("kept.designcraft"); + std::fs::write(&kept, b"kept").unwrap(); + symlink(&kept, package.join("Annual.designcraft")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&base); + } + + #[cfg(windows)] + #[test] + fn package_refuses_windows_directory_redirects() { + use std::os::windows::fs::symlink_dir; + + let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let redirected = base.join("Package"); + match symlink_dir(&outside, &redirected) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + let _ = std::fs::remove_dir_all(&base); + return; + } + Err(e) => panic!("could not create directory redirect: {e}"), + } + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_dir(&redirected).unwrap(); + let _ = std::fs::remove_dir_all(&base); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1f5f7189..c1032bcc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -378,9 +378,11 @@ fn step_and_repeat_grid() { fn step_and_repeat_with_a_huge_grid_makes_no_grid() { let mut s = session(); s.execute("frame.create", &json!({"rect": [36, 36, 66, 66], "content": "unassigned"})).unwrap(); - let r = s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).unwrap(); - // As for any grid of more than 1000 copies: `count` (default 1) copies instead. - assert_eq!(r["created"], 1); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + let before = items(&s); + // Counts past the limit are an error, and nothing is copied. + assert!(s.execute("edit.stepAndRepeat", &json!({"rows": 4_294_967_296_u64, "columns": 4_294_967_296_u64})).is_err()); + assert_eq!(items(&s), before); } /// The column count is the caller's and sizes a list every time the page's columns are laid out.