Security: validate engine inputs and package paths (#155)

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 04:50:26 -04:00
1 parent fc2ce5556f
commit 92649ffdb0
7 files changed
+722 -88

No files matched your search

Generated
+19
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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 }
+142 -37
View File
@@ -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<CommandSpec> {
vec![
cmd!(
@@ -24,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
}),
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<CommandSpec> {
"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<usize> {
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<Option<usize>> {
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<SpreadRef> {
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<Option<f64>> {
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<Orientation> {
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<Value> {
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<Value> {
}
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<Value> {
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<Value> {
})
}
fn guide_divisions(p: &Value, key: &str) -> Result<u64> {
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<f64> {
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<usize> {
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<f64> {
fn grid_positions(a: f64, b: f64, n: u64, gutter: f64) -> Result<Vec<f64>> {
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<Value> {
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<Value> {
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<Value> {
});
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);
}
}
+141 -14
View File
@@ -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<CommandSpec> {
"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<Value> {
})
}
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<u64> {
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<f64> {
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<Vec2> {
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<Vec<Vec2>> {
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<Vec<ItemId>> {
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<ItemId> = 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<Value> {
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<Vec2> = 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<Value> {
})
}
#[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::*;
+413 -34
View File
@@ -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<Self> {
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<PathBuf> {
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<bool> {
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<std::fs::Permissions>) -> Result<tempfile::NamedTempFile> {
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<CommandSpec> {
vec![
@@ -34,14 +213,57 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// A file name for asset `a` in a Links folder, unique among `taken`.
fn link_name(name: &str, id: AssetId, taken: &mut Vec<String>) -> 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<PathBuf> {
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>) -> 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>) -> 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>) -> 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>) -> 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<PathBuf>)> {
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<PathBuf>)> {
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<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> String {
fn copy_font(root: &OutputRoot, face: &FontFace, dir: &Path, copied: &mut Vec<PathBuf>, taken: &mut Vec<String>, files: &mut Vec<PathBuf>) -> 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<PathBuf>, 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<FaceRef>) {
fn package(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
// 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<Value> {
}
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<String> =
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<String> = 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<Value> {
}
}
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::<Vec<_>>(), "report": r}))
}
@@ -209,7 +434,8 @@ fn copy_links(s: &mut Session, p: &Value) -> Result<Value> {
let dir = PathBuf::from(str_param(p, "dir").ok_or_else(|| bad("links.copyTo", "missing `dir`"))?);
let only: Option<Vec<AssetId>> = 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);
}
}
+5 -3
View File
@@ -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.