Batch: check existing data set outputs only in a folder the batch may read and write

The `-2`, `-3` suffix for a data set output that already exists is now
looked up only when the destination folder and the output path pass the
read and write access checks, so a call cannot learn which files exist in
a folder it may not use.

The lookup uses only extensions of formats the app knows, without path
separators or `..`, and only paths whose parent is the destination folder.
A step whose `format` is unknown keeps its own path instead of being
pointed into the destination folder. A data set or document named `.` or
`..` gets the `Untitled` stem with its three-digit number.
This commit is contained in:
dotCooCoo
2026-11-01 01:22:06 +07:00
committed by bflatastic
parent f8bba3388e
commit 1b4c050bd3
4 changed files with 154 additions and 13 deletions
+147 -8
View File
@@ -221,7 +221,7 @@ fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, sou
"folder" => {
let folder = run.dest_folder.ok_or_else(|| bad("action.batch", "missing destination folder"))?;
let out_ext = if ext.is_empty() { "vectorcraft" } else { ext };
let out = std::path::Path::new(folder).join(format!("{stem}.{out_ext}"));
let out = output_path(folder, stem, out_ext).ok_or_else(|| bad("action.batch", "unsafe destination file name"))?;
let out = out.to_string_lossy().into_owned();
// When `close` is false (data sets), write a copy so the template keeps its path.
write_result(s, &out, out_ext, !close)?;
@@ -235,7 +235,8 @@ fn finish_destination(s: &mut Session, run: &Run<'_>, stem: &str, ext: &str, sou
}
/// Builds the three data set naming forms, replaces unsafe name characters, and adds `-2`, `-3`
/// suffixes when a stem was used earlier or its output already exists in the destination folder.
/// suffixes when a stem was used earlier or its output already exists in a folder the batch may
/// read and write.
#[cfg(not(target_arch = "wasm32"))]
fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>) -> Result<Vec<String>> {
use super::screens::{file_part, safe_file_name};
@@ -243,13 +244,16 @@ fn dataset_stems(names: &[String], doc_stem: &str, naming: &str, run: &Run<'_>)
if !matches!(naming, "fileNumber" | "fileDataSetName" | "dataSetName") {
return Err(bad("action.batch", "`datasetFileNaming` must be `fileNumber`, `fileDataSetName` or `dataSetName`"));
}
let doc_stem = safe_file_name(doc_stem);
let doc_stem = if doc_stem.is_empty() || matches!(doc_stem.as_str(), "." | "..") { "Untitled" } else { &doc_stem };
let bases = names.iter().enumerate().map(|(i, name)| {
let safe = file_part(name);
let safe_data_set = safe_file_name(&safe);
let ordinal = format!("{doc_stem}{:03}", i + 1);
match naming {
"fileNumber" => format!("{doc_stem}{:03}", i + 1),
"fileNumber" => ordinal,
"dataSetName" if !safe_data_set.is_empty() => safe_data_set,
"dataSetName" => format!("{doc_stem}{:03}", i + 1),
"dataSetName" => ordinal,
_ => format!("{doc_stem}_{safe}"),
}
});
@@ -283,8 +287,15 @@ fn dataset_output_extensions(run: &Run<'_>) -> Vec<String> {
let writes = (run.override_save && matches!(id, "document.save" | "file.save" | "file.saveAs"))
|| (run.override_export && matches!(id, "document.export" | "document.exportForScreens" | "document.exportSelection"));
if writes {
let ext = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty());
extensions.push(ext.map_or_else(|| "vectorcraft".to_string(), format_extension));
let format = step.get("params").and_then(|p| p.get("format")).and_then(Value::as_str).filter(|f| !f.is_empty());
let ext = match format {
Some(format) if super::format(format).is_some() => format_extension(format),
Some(_) => continue,
None => "vectorcraft".to_string(),
};
if safe_extension(&ext) {
extensions.push(ext);
}
}
}
extensions
@@ -293,7 +304,36 @@ fn dataset_output_extensions(run: &Run<'_>) -> Vec<String> {
#[cfg(not(target_arch = "wasm32"))]
fn dataset_output_exists(folder: Option<&str>, stem: &str, extensions: &[String]) -> bool {
let Some(folder) = folder else { return false };
extensions.iter().any(|ext| std::path::Path::new(folder).join(format!("{stem}.{ext}")).exists())
if super::may_read(folder).is_err() || super::may_write(folder).is_err() {
return false;
}
extensions.iter().any(|ext| {
output_path(folder, stem, ext).is_some_and(|path| {
let path_text = path.to_string_lossy();
super::may_read(&path_text).is_ok() && super::may_write(&path_text).is_ok() && path.exists()
})
})
}
/// Returns whether an extension is safe to use in an output file name.
#[cfg(not(target_arch = "wasm32"))]
fn safe_extension(ext: &str) -> bool {
!ext.is_empty() && !ext.contains(['/', '\\', ':']) && !ext.contains("..")
}
/// Returns a path inside the destination folder for a safe stem and extension.
#[cfg(not(target_arch = "wasm32"))]
fn output_path(folder: &str, stem: &str, ext: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
if stem.is_empty() || matches!(stem, "." | "..") || stem.contains(['/', '\\']) || !safe_extension(ext) {
return None;
}
let name = format!("{stem}.{ext}");
if name.contains(['/', '\\']) {
return None;
}
let path = Path::new(folder).join(name);
(path.parent() == Some(Path::new(folder))).then_some(path)
}
/// Save when the extension is a Save format; otherwise export (PNG, JPEG…).
@@ -341,7 +381,10 @@ fn rewrite_paths(
Some(f) => format_extension(f),
None => default_ext.to_string(),
};
let file = std::path::Path::new(folder).join(format!("{stem}.{ext}"));
if params.get("format").and_then(Value::as_str).filter(|f| !f.is_empty()).is_some_and(|f| super::format(f).is_none()) {
return c;
}
let Some(file) = output_path(folder, stem, &ext) else { return c };
params.insert("path".into(), json!(file.to_string_lossy()));
if let Some(o) = c.as_object_mut() {
o.insert("params".into(), Value::Object(params));
@@ -721,6 +764,102 @@ mod tests {
assert_eq!(file_names(&dest), ["Alpha-2.svg", "Alpha.svg"]);
}
#[test]
fn batch_does_not_probe_a_denied_destination() {
let allowed = tmp("probe-allowed");
let denied = tmp("probe-denied");
std::fs::write(denied.join("Alpha.svg"), b"private").unwrap();
let roots = crate::file_access::AutomationRoots::new(&[allowed.as_path()], &[allowed.as_path()]).unwrap().unwrap();
let steps = [json!({"command": "document.export", "params": {"format": "svg"}})];
let folder = denied.to_string_lossy().into_owned();
let run = Run {
label: "Test",
steps: &steps,
stop_on_error: true,
override_save: true,
override_export: true,
destination: "none",
dest_folder: Some(&folder),
};
crate::file_access::confine(Some(&roots), || {
assert_eq!(dataset_stems(&["Alpha".into()], "source", "dataSetName", &run).unwrap(), ["Alpha"]);
let mut s = with_data_sets(&["Alpha"]);
let result = s
.execute(
"action.batch",
&json!({
"source": "datasets", "datasetFileNaming": "dataSetName", "destination": "none",
"destinationFolder": folder, "overrideExport": true,
"commands": steps,
}),
)
.unwrap();
assert!(!result.to_string().contains("Alpha-2"), "{result}");
});
}
#[test]
fn batch_does_not_probe_unknown_format_paths() {
let root = tmp("probe-format");
let folder = root.join("out");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(root.join("outside"), b"private").unwrap();
for format in ["../outside", "x/../../outside", "..\\..\\outside"] {
let steps = [json!({"command": "document.export", "params": {"format": format}})];
let folder = folder.to_string_lossy().into_owned();
let run = Run {
label: "Test",
steps: &steps,
stop_on_error: true,
override_save: true,
override_export: true,
destination: "none",
dest_folder: Some(&folder),
};
assert_eq!(
dataset_stems(&["Alpha".into()], "source", "dataSetName", &run).unwrap_or_else(|e| panic!("{format}: {e}")),
["Alpha"],
"{format}"
);
assert_eq!(rewrite_paths(&steps, false, true, Some(&folder), "Alpha", "vectorcraft"), steps, "{format}");
}
}
#[test]
fn batch_probe_and_dot_names_stay_in_destination() {
let root = tmp("probe-parent");
let folder = root.join("out");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(root.join("outside.svg"), b"private").unwrap();
let folder = folder.to_string_lossy().into_owned();
let run = Run {
label: "Test",
steps: &[],
stop_on_error: true,
override_save: true,
override_export: true,
destination: "none",
dest_folder: Some(&folder),
};
assert_eq!(dataset_stems(&[".".into(), "..".into()], ".", "dataSetName", &run).unwrap(), ["Untitled001", "Untitled002"]);
assert_eq!(dataset_stems(&["Alpha".into()], "..", "fileNumber", &run).unwrap(), ["Untitled001"]);
let output = root.join("named");
let mut s = with_data_sets(&[".", ".."]);
let r = named_batch(&mut s, &output, Some("dataSetName"));
assert_eq!(r["failed"], 0, "{r}");
assert_eq!(file_names(&output), ["Untitled001.vectorcraft", "Untitled002.vectorcraft"]);
}
#[test]
fn batch_probe_rejects_parent_escape() {
let root = tmp("probe-parent-only");
let folder = root.join("out");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(root.join("outside.svg"), b"private").unwrap();
let folder = folder.to_string_lossy().into_owned();
assert!(!dataset_output_exists(Some(&folder), "../outside", &["svg".into()]));
}
#[test]
fn batch_rejects_unknown_data_set_naming_but_ignores_it_for_folders() {
let mut s = with_data_sets(&["Alpha"]);
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Batch…",
[],
None,
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or present in the destination folder gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
"{commands: [{command, params}] (the action's steps), source?: folder (default) | datasets, folder? (required for folder: the source folder), includeSubdirs?: false, destination?: none (default: leave each file open) | saveAndClose (save over the source and close) | folder (save or export into destinationFolder and close), destinationFolder? (required when destination is folder; also the folder Override Save/Export write into), datasetFileNaming?: fileNumber | fileDataSetName (default) | dataSetName (used only for source datasets; ignored for folder), overrideOpen?: true (skip document.open / file.open steps: the batch opens each source file), overrideSave?: true (point document.save / file.save / file.saveAs at destinationFolder/{stem}.{ext}), overrideExport?: true (point document.export / document.exportForScreens / document.exportSelection the same way), stopOnError?: true, label?} play the commands on every readable file in folder (and its subfolders when includeSubdirs), or on every data set of the open document. Data set names use the document stem plus a three-digit ordinal, the document stem and an underscore plus the data set name, or the data set name alone. Invalid file name characters become `-`; a name already used in the batch or whose output already exists in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix before the format-derived extension. Caps: 500 files or data sets, 8 folder levels, 256 steps. Batches cannot nest. → {processed: [{path|dataset, result|error}…], count, failed, stopped}",
always,
action_batch::action_batch
),
+5 -3
View File
@@ -1629,6 +1629,8 @@ already walks Image Trace. Short agent entry points:
when it is one of the format's extensions (`.svg` and `SVG` give `.svg`, `.JPEG` gives `.jpeg`),
and otherwise in the format's first extension (`png8` gives `.png`, `template` gives `.vctemplate`).
A step without `format` keeps the source file's extension (`.vectorcraft` over data sets).
An output already in a destination folder gets the next free `-2`, `-3` suffix only when the batch
may read and write that folder.
- Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`),
`imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace
settings, such as `colours`, is an error that names it and the nearest setting (`colors`), as for a command's own keys.
@@ -2972,9 +2974,9 @@ chosen stem with the output format's extension. An empty data set name under Dat
document stem plus its three-digit ordinal.
Batch writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and
adds the next free `-2`, `-3` suffix to a stem already used in the batch or whose generated output path exists in
the destination folder before the batch starts. Under Data Set Name, VectorCraft also makes Windows device names
safe (`CON` becomes `CON-`). This is VectorCraft's naming behavior. Caps: 500 files or data sets, 8
folder levels, 256 steps. An action recorded in the Actions panel leaves the artboard out,
a destination folder the batch may read and write before it starts. Under Data Set Name, VectorCraft also makes
Windows device names safe (`CON` becomes `CON-`). This is VectorCraft's naming behavior. Caps: 500
files or data sets, 8 folder levels, 256 steps. An action recorded in the Actions panel leaves the artboard out,
and its steps of these commands get the artboard that is active when the action plays. A press with the Selection,
Direct Selection or Group Selection tool inside an artboard makes that artboard the active one; with the Selection
tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. Choosing the Artboard
+1 -1
View File
@@ -548,7 +548,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`.
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- Batch… over data sets names each file after the document and the data set (`action.batch`). It writes `/`, `\`, `:`, `<`, `>`, `"`, `|`, `?`, `*` and control characters in the data set's name as `-`, and adds `-2`, `-3`… to a name an earlier data set used.
- Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output name already in the destination folder gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html).
- Batch… offers File + Number, File + Data Set Name, and Data Set Name for a Data Sets source. The dialog defaults to File + Number; `action.batch` without `datasetFileNaming` keeps File + Data Set Name. An output already in a destination folder the batch may read and write gets the next free `-2`, `-3` suffix. [Batch options](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/batch-options.html).
- Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`).
- Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`.
- Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`).