MCP: save, export and package replies leave out paths outside a confined client's read roots

A client confined with --automation-read-root could learn paths it was
not given and may not read from the replies of several commands. Each
one now names such a path only when no read roots are in force or the
path is inside them:

- document.save and file.save, file.saveAs, file.saveCopy and
  file.saveAsTemplate (app.save and engine.execute) without a path:
  the document's own file, the files of separate artboards and linked
  files are left out or named by file name, and the folder a save
  suggests is left out.
- document.exportForScreens with the folder of an exportPreset: the
  ZIP path is left out and files are relative to that folder.
- document.exportSettings, document.exportPresets.list and
  document.exportPresets.export: a kept folder outside the read roots
  is left out of the reply and of the presets file the export writes.
- file.package: warnings, missingLinks and the report name a file
  outside the read roots by its file name. A link's stored path, read
  from the document's folder when it is relative, decides, also when
  its file was found under another path.
- document.info: an image linked to a file outside the read roots has
  link null, and its Linked Images row names the file alone.

docs/mcp.md and the params text describe the rule.
This commit is contained in:
dotCooCoo
2026-11-11 20:42:57 -08:00
parent fd6b8f6c97
commit cd262fa6e8
15 changed files with 435 additions and 37 deletions
+17 -4
View File
@@ -1,7 +1,7 @@
//! Document Info panel data, its categories and the text report (Document Info › Save…, File →
//! Package), and Object → Make Pixel Perfect.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Document Info",
["Window", "Document Info"],
None,
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}",
"{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone",
has_doc,
info
),
@@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
let mut fonts = BTreeSet::new();
let mut images: BTreeMap<String, Value> = BTreeMap::new();
let mut image_rows: Vec<ImageRow> = vec![];
// Whether the request being served may read each linked file (a relative path read from the
// document's folder), checked once per file.
let mut readable: HashMap<String, bool> = HashMap::new();
let link_doc = super::links::LinkBase::of(st).doc;
let mut may_read = |stored: &str| {
*readable.entry(stored.to_string()).or_insert_with(|| {
let resolved = super::fileio::document_path(stored, link_doc).ok();
crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok()
})
};
let mut patterns = BTreeSet::new();
let mut symbols = BTreeSet::new();
let mut spot = BTreeSet::new();
@@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
Some("textObjects")
}
NodeKind::Image(im) => {
// A linked file outside a confined request's read roots: `link` is null, and the row
// names the file alone.
let link = im.link.as_ref().map(|l| (l, may_read(&l.path)));
images.insert(
im.key.clone(),
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }),
);
let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string());
image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone())));
image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() })));
Some("images")
}
NodeKind::SymbolInstance { symbol, .. } => {
+6 -4
View File
@@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document};
pub(crate) use save::job_for;
pub(crate) use save::save as save_document;
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option,
remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder,
templates_folder,
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths,
reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates,
template_filters, templates_dialog_folder, templates_folder,
};
pub(crate) use screens::{
PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS,
@@ -124,7 +124,9 @@ pub(crate) use access::list_dir;
pub use access::write_file_with;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use access::{Placed, copy_new, file_metadata};
pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file};
pub(crate) use access::{
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
};
#[cfg(all(test, windows))]
pub(crate) use access::{document_path_on, document_path_with};
#[cfg(test)]
+23
View File
@@ -42,6 +42,29 @@ pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
(out, seen)
}
/// May a reply to the request being served name `path`, a file or folder the client did not give?
/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]).
pub(crate) fn shown(path: &str) -> bool {
crate::file_access::check_read(path).is_ok()
}
/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name,
/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such
/// as `..` or a folder ending in a separator).
pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> {
shown_link(path, None)
}
/// A link's `stored` path as a reply to the request being served names it: whole when the file it
/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it.
pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> {
if shown(resolved.unwrap_or(stored)) {
return std::borrow::Cow::Borrowed(stored);
}
let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default();
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
}
/// `path` checked for writing against the automation roots in force.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn may_write(path: &str) -> Result<()> {
+4 -4
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens",
["File", "Export"],
None,
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused",
has_doc,
screens::export_for_screens
),
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Presets",
[],
None,
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset",
"{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset",
always,
screens::presets_list
),
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Export Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}",
always,
screens::presets_export
),
@@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export for Screens Settings",
[],
None,
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)",
"{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)",
has_doc,
screens::export_settings
),
+38 -2
View File
@@ -1032,11 +1032,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
let plan = save_plan(s, mode, p)?;
let job = save_job(s, plan)?;
let uid = s.doc()?.uid;
let out = job.write_streamed(&mut super::write_file_with)?;
let mut out = job.write_streamed(&mut super::write_file_with)?;
job.complete(s, uid);
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
Ok(out)
}
/// The reply `out` of a save with the `path` param `given`, as the request being served may see it
/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is
/// left out, and the `files` and `linked` written beside it are named by their file names, when they
/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it
/// is outside them. Without roots in force `out` is unchanged.
pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) {
use super::access::{shown, shown_path};
if crate::file_access::current().is_none() {
return;
}
let Some(o) = out.as_object_mut() else { return };
if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) {
o.remove("folder");
}
// Every path the reply names then is the one the client gave, or beside it.
if given.is_some_and(|g| !g.is_empty()) {
return;
}
if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) {
o.remove("path");
}
for key in ["files", "linked"] {
if let Some(Value::Array(rows)) = o.get_mut(key) {
for row in rows {
if let Value::String(p) = row
&& !shown(p)
{
*row = json!(shown_path(p));
}
}
}
}
}
fn can_revert(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
match (&st.path, st.is_dirty()) {
@@ -1104,7 +1140,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+46 -3
View File
@@ -644,12 +644,47 @@ impl Output {
/// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with
/// the params remembered in the document (`document.exportSettings`).
pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
// A folder the client gave, as against the one its `exportPreset` keeps.
let given = p.get("folder").is_some();
let p = with_preset(s, p)?;
let r = export(s, &p)?;
let mut r = export(s, &p)?;
remember(s, &p);
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
leave_out_unreadable_folder(&mut r, folder);
}
Ok(r)
}
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
/// as the reply of an export to a ZIP names them.
fn leave_out_unreadable_folder(r: &mut Value, folder: &str) {
if super::access::shown(folder) {
return;
}
let Some(o) = r.as_object_mut() else { return };
o.remove("path");
let inside = format!("{folder}/");
if let Some(Value::Array(files)) = o.get_mut("files") {
for file in files {
// A ZIP's files are named relative to it already.
if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) {
*file = json!(name);
}
}
}
}
/// Export settings kept in the document or a preset, as the request being served may see them: a
/// `folder` outside the read roots ([`super::access::shown`]) is left out.
fn leave_out_unreadable_settings_folder(mut settings: Map<String, Value>) -> Map<String, Value> {
if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) {
settings.remove("folder");
}
settings
}
// ---------- export presets ----------
/// A saved batch export: what to write and how to name and sort it, to use again
@@ -727,7 +762,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value {
}
pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) }))
let saved: Vec<ScreenExportPreset> = s
.prefs
.screen_export_presets
.iter()
.map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) })
.collect();
Ok(json!({ "presets": preset_list(&saved) }))
}
/// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would
@@ -831,6 +872,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
return Err(too_many());
}
let count = presets.len();
let presets: Vec<ScreenExportPreset> =
presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?;
if text.len() > MAX_PRESET_FILE_BYTES {
return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes")));
@@ -1388,7 +1431,7 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map<String, Value>)
/// `document.exportSettings`: the Export for Screens settings the document last exported with.
pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "settings": s.doc()?.doc.export_settings }))
Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) }))
}
#[cfg(test)]
+13
View File
@@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject {
}
}
/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\`
/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole.
#[test]
fn a_hidden_path_shows_as_its_file_name_on_every_platform() {
let windows = "C:\\Users\\someone\\art\\red.png";
crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || {
for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] {
assert_eq!(shown_path(path), name, "{path}");
}
});
assert_eq!(shown_path(windows), windows);
}
#[test]
fn opens_every_readable_format() {
let mut s = session(200.0, 100.0, 1);
@@ -667,6 +667,83 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() {
assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder");
}
/// An export into the folder an export preset keeps, for a client whose read roots don't hold that
/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder
/// inside the read roots, and one the client gives, stay whole.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() {
use crate::file_access::{AutomationRoots, confine};
let mut s = session(1);
let tmp = Folder::new("preset-roots");
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
for f in [&inside, &out] {
std::fs::create_dir_all(f).unwrap();
}
let folder = out.to_string_lossy().replace('\\', "/");
let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder});
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap();
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap();
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, p: Value| confine(roots.as_ref(), || export(s, p));
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"}));
assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}");
assert!(out.join("1x/Artboard-1.png").is_file());
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true}));
assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}");
assert!(!r.to_string().contains(&folder), "{r}");
// Inside the read roots, or given by the client: whole.
assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")]));
let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true}));
assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}");
}
/// The export settings the document keeps and the saved presets, read by a client whose read roots
/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and
/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest.
/// Inside the read roots the folder stays.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn kept_settings_leave_out_a_folder_outside_the_read_root() {
use crate::file_access::{AutomationRoots, confine};
let mut s = session(1);
let tmp = Folder::new("settings-roots");
let (inside, out) = (tmp.0.join("in"), tmp.0.join("out"));
for f in [&inside, &out] {
std::fs::create_dir_all(f).unwrap();
}
let folder = out.to_string_lossy().replace('\\', "/");
let settings = json!({"formats": [{"format": "svg"}], "folder": folder});
s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap();
export(&mut s, json!({"exportPreset": "Kept"}));
let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap();
let ask = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, cmd: &str, p: Value| {
confine(roots.as_ref(), || s.execute(cmd, &p).unwrap())
};
let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone();
let r = ask(&mut s, &confined, "document.exportSettings", json!({}));
assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}");
let r = ask(&mut s, &confined, "document.exportPresets.list", json!({}));
assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}");
let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]}));
let data = r["data"].as_str().unwrap();
assert!(!data.contains(&folder) && data.contains("svg"), "{data}");
let file = inside.join("kept.vcexportpresets");
let path = file.to_string_lossy().replace('\\', "/");
ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path}));
assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder));
// Inside the read roots: whole.
assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder));
assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder));
assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder));
}
#[test]
fn format_settings_apply_to_their_rows_and_rows_win() {
let mut s = session(1);
+23 -16
View File
@@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo};
use serde_json::{Value, json};
use super::fileio::{create_dir, write_file};
use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file};
use super::links::LinkBase;
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name};
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Package",
[],
None,
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder",
"{folder?, name?: (default: \"<document> Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true (<document> Report.txt)} copy the saved document as it is now into folder/name as <document>.vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \"<name>.zip\", dataBase64} (a zip of the files in <name>/) instead of folder. The warnings and the report name a file outside the automation read roots by its file name",
has_doc,
package
)]
@@ -178,8 +178,10 @@ impl<'a> Collector<'a> {
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
/// copy of `source` finds it only by its name, beside it.
/// `path` is the link's path as the warning names it.
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) {
let source = shown_path(source);
let message = format!(
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
);
@@ -207,11 +209,13 @@ impl<'a> Collector<'a> {
let section = self.sections.len();
self.sections.push(vec![]);
let mut lines = vec![];
// The packaged document's sections read as before; a placed document's name its file.
// The packaged document's sections read as before; a placed document's name its file. The
// report and the warnings name a file outside a confined request's read roots by its name.
let shown_source = shown_path(source);
let heading = |lines: &mut Vec<String>, title: &str| {
lines.push(title.into());
if depth > 0 {
lines.push(format!("Document: {source}"));
lines.push(format!("Document: {shown_source}"));
}
};
// A file's saved path is only meaningful inside its own document.
@@ -243,17 +247,21 @@ impl<'a> Collector<'a> {
heading(&mut lines, "LINKED FILES");
for file in linked_files(doc) {
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
// The stored path, whole when the place it names (read from this document's folder) is
// inside the read roots. A file found elsewhere does not show a stored path outside them.
let resolved = document_path(&file.path, Some(source)).ok();
let shown = shown_link(&file.path, resolved.as_deref()).into_owned();
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
// The ancestor is already included (possibly the root document). A
// cyclic reference cannot have a final content hash until both files
// have been written, so leave its size/hash unspecified, not stale.
self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path));
self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle"));
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
continue;
}
if let Some(existing) = self.copies.get(found_path).cloned() {
if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
self.renamed(&mut lines, source, &shown, &file.name, &existing.name);
}
// Another document may place other pages of a PDF copied already.
if self.opts.copy_fonts
@@ -265,8 +273,8 @@ impl<'a> Collector<'a> {
continue;
}
let Some(mut bytes) = file.bytes else {
lines.push(format!("{}: not found ({}), not copied", file.name, file.path));
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) });
lines.push(format!("{}: not found ({shown}), not copied", file.name));
self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) });
continue;
};
if self.copies.len() >= 10_000 {
@@ -277,7 +285,7 @@ impl<'a> Collector<'a> {
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
let destination_file = format!("{dir}{unique}");
if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
self.renamed(&mut lines, source, &shown, &file.name, &destination_file);
}
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
// read from its editing data links are copied beside it.
@@ -285,7 +293,7 @@ impl<'a> Collector<'a> {
let placed_file = pages.is_some();
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
} else {
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
}
@@ -296,7 +304,7 @@ impl<'a> Collector<'a> {
}
if placed_file && super::place::document::is_native(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links"));
} else {
match vectorcraft_format::load_file(&bytes) {
Ok(saved) => {
@@ -320,8 +328,7 @@ impl<'a> Collector<'a> {
Err(e) => self.warn(
&mut lines,
format!(
"{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked",
file.path
"{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked"
),
),
}
@@ -329,7 +336,7 @@ impl<'a> Collector<'a> {
}
Err(e) => self.warn(
&mut lines,
format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path),
format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"),
),
}
}
@@ -337,7 +344,7 @@ impl<'a> Collector<'a> {
let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) };
self.copies.insert(found_path.to_string(), copy.clone());
local.insert(file.path.clone(), copy);
lines.push(format!("{} → {destination_file}", file.path));
lines.push(format!("{shown} → {destination_file}"));
self.entries.push((destination_file, bytes));
}
lines.push(String::new());
@@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
// The report.
if let Some(info) = info {
let report_name = free_name(&format!("{stem} Report.txt"), &mut taken);
let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n");
let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path));
lines.iter().for_each(|l| text.push_str(&format!("{l}\n")));
text.push_str(&info);
entries.push((report_name, text.into_bytes()));
+97
View File
@@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() {
assert!(report.contains("blue.png: not found"), "{report}");
}
/// Packaged for a client whose read root holds neither the document's file nor its links: the report,
/// its Document Info part included, names them by their file names, and `document.info` gives no
/// link. The person's report names them whole.
#[test]
fn a_confined_package_names_files_outside_the_read_root_by_name() {
let dir = Folder::new("package-roots");
let mut s = poster(&dir);
std::fs::create_dir_all(dir.file("in")).unwrap();
std::fs::remove_file(dir.file("art/blue.png")).unwrap();
open(&mut s, &dir.file("poster.vectorcraft"));
let report = |r: &Value| {
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap()
};
let person = report(&run(&mut s, "file.package", json!({})));
assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}");
assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}");
assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}");
let in_root = dir.0.join("in");
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
let text = report(&client);
assert!(text.contains("From: poster.vectorcraft\n"), "{text}");
assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}");
let base = dir.0.to_string_lossy().into_owned();
assert!(!text.contains(&base), "{text}");
for key in ["missingLinks", "warnings", "files"] {
assert!(!client[key].to_string().contains(&base), "{key}: {client}");
}
// Document Info, whose report the package holds, gives no link either.
for p in [json!({}), json!({"format": "text"})] {
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone()));
assert!(!info.to_string().contains(&base), "{p}: {info}");
}
let info = run(&mut s, "document.info", json!({"format": "text"}));
assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}");
}
/// A missing link stored as a path relative to the document names a file inside a confined
/// request's read root: the package report and Document Info give its stored path.
#[test]
fn a_relative_link_inside_the_read_root_keeps_its_path() {
let dir = Folder::new("package-roots-relative");
write(&dir.file("art/near.png"), &png(8, 8, RED));
let mut s = session();
let id = place(&mut s, &dir.file("art/near.png"));
let mut doc = (*s.doc().unwrap().doc).clone();
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png"));
write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false));
// Missing, the link keeps its stored path when the document opens (the package copies the
// image the document holds).
std::fs::remove_file(dir.file("art/near.png")).unwrap();
open(&mut s, &dir.file("near.vectorcraft"));
let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap();
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}");
let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({})));
let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect();
assert_eq!(links, [&json!("art/near.png")], "{info}");
let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"})));
assert!(text.to_string().contains("art/near.png"), "{text}");
}
/// A link whose stored path names a place outside a confined request's read root, with its file
/// found by name beside the document inside the root: the package report and reply give the file
/// name, not the stored path.
#[test]
fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() {
let dir = Folder::new("package-roots-stale");
write(&dir.file("in/near.png"), &png(8, 8, RED));
let mut s = session();
let id = place(&mut s, &dir.file("in/near.png"));
let mut doc = (*s.doc().unwrap().doc).clone();
let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") };
let stale = dir.file("out/near.png");
im.link = Some(vectorcraft_doc::LinkInfo::new(&stale));
write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false));
// Missing when the document opens, the link keeps its stored path; the file then appears beside it.
std::fs::remove_file(dir.file("in/near.png")).unwrap();
open(&mut s, &dir.file("in/near.vectorcraft"));
write(&dir.file("in/near.png"), &png(8, 8, RED));
let in_root = dir.0.join("in");
let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap();
let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({})));
let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap());
let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap();
assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}");
assert!(!text.contains(&stale), "{text}");
for key in ["missingLinks", "warnings", "files"] {
assert!(!r[key].to_string().contains(&stale), "{key}: {r}");
}
}
/// A file missing from a document that moved keeps the place its relative path names from the
/// document's folder: the packaged document writes its relative path from there.
#[test]
+44
View File
@@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.node_count()
}
/// A client confined by automation roots: a save without a path writes the document's own file, and
/// when that file is outside the read roots the reply leaves out its `path` and names the files
/// written beside it by their file names. A reply without a path leaves out a suggested `folder`
/// outside them. A path inside the read roots, and one the client gives, stay whole.
#[test]
fn a_confined_save_leaves_out_paths_outside_the_read_root() {
use crate::file_access::{AutomationRoots, confine};
let d = dir("roots");
let (inside, outside) = (d.join("in"), d.join("out"));
for f in [&inside, &outside] {
std::fs::create_dir_all(f).unwrap();
}
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
rect(&mut s);
let own = path(&outside, "doc.vectorcraft");
s.execute("document.save", &json!({"path": own})).unwrap();
let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap();
let files: Vec<String> = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect();
assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}");
let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap();
let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap();
let run = |s: &mut Session, roots: &Option<std::sync::Arc<AutomationRoots>>, id: &str, p: Value| {
confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
};
let hidden = outside.to_string_lossy().into_owned();
let r = run(&mut s, &write_only, "document.save", json!({}));
assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}");
assert!(!r.to_string().contains(&hidden), "{r}");
let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true}));
let names: Vec<Value> = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect();
assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}");
// Inside the read roots, or given by the client: whole.
assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own));
assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own));
// A save without a path known suggests the document's folder.
let r = run(&mut s, &write_only, "file.saveCopy", json!({}));
assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}");
assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn an_svg_path_writes_svg_and_save_writes_it_back() {
let d = dir("svg");
+14 -4
View File
@@ -122,10 +122,20 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
"file.openWith" => io::open_with(app, p),
// Saves write through the app (save panel, download); with no path known the panel and the
// format's options dialog ask first.
"file.save" => io::save(app, SaveMode::Save, p, true),
"file.saveAs" => io::save(app, SaveMode::SaveAs, p, true),
"file.saveCopy" => io::save(app, SaveMode::Copy, p, true),
"file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true),
// A confined request sees the document's own file, and the folder a save suggests, only
// inside its read roots.
"file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => {
let mode = match id {
"file.save" => SaveMode::Save,
"file.saveAs" => SaveMode::SaveAs,
"file.saveCopy" => SaveMode::Copy,
_ => SaveMode::Template,
};
io::save(app, mode, p, true).map(|mut r| {
vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str));
r
})
}
"document.exportSelection" => {
io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p}))
}
+21
View File
@@ -107,6 +107,27 @@ fn control_saves_take_the_native_save_options() {
let _ = std::fs::remove_dir_all(base);
}
/// A save without a path writes the document's own file in the write root: `app.save` and
/// `file.save` leave its path out of the reply, since it is outside the read root, and keep a path
/// the client gives.
#[test]
fn control_saves_leave_out_the_documents_file_outside_the_read_root() {
let (mut app, tx, base) = confined("save-own");
app.run("file.new", json!({"width": 100, "height": 50})).unwrap();
let own = s(base.join("out/doc.vectorcraft"));
app.run("file.saveAs", json!({"path": own})).unwrap();
for (method, params) in [("app.save", json!({})), ("engine.execute", json!({"command": "file.save", "params": {}}))] {
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let r = request(&mut app, &tx, method, params);
assert_eq!(r["ok"], true, "{method}: {r}");
assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{method}: {r}");
assert_eq!(app.session.active().map(|d| d.is_dirty()), Some(false), "{method}: saved");
}
let r = request(&mut app, &tx, "app.save", json!({"path": own}));
assert_eq!(r["result"]["path"], json!(own), "{r}");
let _ = std::fs::remove_dir_all(base);
}
/// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true`
/// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it
/// before they reply. Without `wait` the refusal reaches only the status bar.
+11
View File
@@ -123,6 +123,17 @@ the suite) keep the agent to the folders you give it:
- Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and
export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`,
library and preset saves to a path, the copies `links.relink {copy}` makes.
- A path the agent gives stays in a save or export reply, with the files written beside it. Paths it did not
give are left out when they are outside the read roots. `save_file`, `document.save` and `file.save` without
a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files
written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without
one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder`
of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder.
`document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as
a file) leave out a kept `folder` outside the read roots. `file.package`
names the document's file and the linked files outside the read roots by their file names in `warnings`, in
`missingLinks` and in its report. `document.info` gives `link: null` for an image linked to a file outside them,
and its Linked Images rows and text report name that file alone. `action.batch` returns the replies of its steps as these commands give them.
- `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163).
- `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The
other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and
+1
View File
@@ -749,6 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`).
- Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`.
- Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`.
- Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`.
- `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`).
- Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`.
- Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`.