Files
vectorcraft/crates/scriptcheck/src/includes.rs
T
Dylan M. TaylorandClaude Opus 5.5 20bba9ebae Security: linear work for shortcut review, gradients, recovery names, include cycles, batch references, DOCX style ids, CAD layouts and long strokes
CodeRabbit findings #2, #16, #31, #103, #114, #124, #162, #179, #182, #183, #185:

- Shortcut import review: example names for each code in one pass over the rows.
- Gradients: a midpoint stop is mixed from its two stops; freeform recolor samples its
  increasing positions with one advancing search (`Gradient::sample_increasing_with`).
- Recovery: image entry names are looked up by address, and dead ones swept when the map
  doubles, not on every image.
- Script checker: include cycles from strongly connected components (iterative Tarjan), not
  a reachability row per file.
- Batch step references copy at most 256 MB of values for one step.
- DOCX: style ids over 256 bytes are ignored where defined and where used.
- DXF: layouts keep at most 4 MB of names; a hatch's loops count against the node limit.
- Freehand and Liquify: past 1000 samples a drag previews every n/1000 samples, strokes keep
  at most 100,000 samples, and the stroke kept is previewed in full before it is committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:19:59 +07:00

336 lines
15 KiB
Rust

//! Following `#include` lines: the file each one names, found inside the script's folder and read
//! through the caller's [`Loader`].
//!
//! The preprocessor page (https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)
//! says that a name is looked for as given and then in each `#includepath` folder in turn, that a
//! name starting with `/` is absolute, and that `JSINCLUDE` is a fallback; it doesn't say which
//! folder a relative name starts from. The checker takes a name, and an `#includepath` folder, from
//! the folder of the file the line is in. It never follows an absolute name, a name or folder that
//! leads out of the script's folder (`..`), a name or folder longer than [`MAX_PATH`] bytes, or
//! `JSINCLUDE`. Each file is read once. A queue holds the files still to follow, in the order they
//! were read, and nothing recurses: each file is reached first at its smallest depth. At most
//! [`crate::Limits::lookups`] paths are looked up. Once every file is read, an
//! `#include` line is a cycle when the file it names includes the file the line is in, directly or
//! through other files.
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::rc::Rc;
use crate::analyze::{self, IncludeRef, Known};
use crate::directives::MAX_PATH;
use crate::report::{Acc, IncludeStatus};
/// An included file that was read and is checked: its path inside the script's folder, its text
/// (and whether it held bytes that aren't UTF-8), and the `#includepath` folders in force at its
/// start.
pub(crate) struct ReadFile {
pub path: String,
pub text: String,
pub invalid: bool,
pub paths: Rc<[String]>,
}
/// What a [`Loader`] found at a path.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Loaded {
/// The file's first bytes (at most the `max` asked for).
Bytes(Vec<u8>),
/// No file there, or it can't be read.
Missing,
/// A file the caller may not read (outside the folders automation may read).
Refused,
}
/// Where the files a script includes come from: the folder the script is in.
pub trait Loader {
/// The first `max` bytes of the file at `path`, a path inside the script's folder with `/`
/// between its names (`lib/util.jsxinc`).
fn load(&mut self, path: &str, max: usize) -> Loaded;
}
/// Follow the script's `includes`: `script` is its file name and `bytes` its size. Each file read
/// is scanned here for its own includes and for what its variables hold, with its tallies left
/// out; it starts with what `known` holds, which then gains what the file adds → the files to
/// check, in the order they were read.
pub(crate) fn follow(
acc: &mut Acc,
includes: Vec<IncludeRef>,
loader: &mut dyn Loader,
script: &str,
bytes: usize,
known: &mut Known,
) -> Vec<ReadFile> {
/// The `#include` lines of a file that was read, still to follow.
struct Work {
/// The file the lines are in: its index in `files` (0: the script).
file: usize,
includes: Vec<IncludeRef>,
depth: usize,
}
/// An `#include` line as the report lists it.
struct Row {
value: String,
line: usize,
/// The file the line is in.
from: usize,
status: IncludeStatus,
path: Option<String>,
/// The file it names, when that file was read.
to: Option<usize>,
}
let limits = acc.limits;
// The files read, by their path inside the script's folder; the script first.
let mut files: Vec<String> = vec![script.to_string()];
// What became of each path found, and the file read there: a file is read once.
let mut read: BTreeMap<String, (IncludeStatus, Option<usize>)> = BTreeMap::from([(script.to_string(), (IncludeStatus::Checked, Some(0)))]);
// The files still to follow, in the order they were read: each file is reached first at its
// smallest depth.
let mut work = VecDeque::from([Work { file: 0, includes, depth: 0 }]);
let mut rows: Vec<Row> = vec![];
// Which file includes which.
let mut edges: BTreeSet<(usize, usize)> = BTreeSet::new();
// The bytes read and the included files read, those not checked too, and the paths looked up.
let (mut total, mut loaded, mut lookups) = (bytes, 0usize, 0usize);
// The files to check.
let mut checked: Vec<ReadFile> = vec![];
while let Some(w) = work.pop_front() {
let folder = if w.file == 0 { String::new() } else { files.get(w.file).map_or("", |f| parent(f)).to_string() };
for inc in w.includes {
acc.steps = acc.steps.saturating_add(1);
let mut status = IncludeStatus::NotFollowed;
let (mut found, mut to): (Option<String>, Option<usize>) = (None, None);
let mut refused = false;
for path in candidates(&folder, &inc.value, &inc.paths) {
acc.steps = acc.steps.saturating_add(1);
if let Some(&(before, file)) = read.get(&path) {
(status, found, to) = (before, Some(path), file);
break;
}
if w.depth + 1 > limits.include_depth || loaded >= limits.includes || total >= limits.total_bytes || lookups >= limits.lookups {
(status, found) = (IncludeStatus::Limit, None);
break;
}
lookups += 1;
status = IncludeStatus::Missing;
// One byte more than a file or the rest of the total may take shows that it is too large.
let max = limits.file_bytes.min(limits.total_bytes.saturating_sub(total)).saturating_add(1);
let b = match loader.load(&path, max) {
Loaded::Bytes(b) => b,
Loaded::Missing => continue,
Loaded::Refused => {
refused = true;
continue;
}
};
// Every file read counts, also one that is too large, compiled or not text.
loaded += 1;
total = total.saturating_add(b.len());
acc.steps = acc.steps.saturating_add(b.len());
status = if b.len() > limits.file_bytes || total > limits.total_bytes {
IncludeStatus::Limit
} else if crate::is_compiled(&path, &b) || !crate::is_text(&b) {
IncludeStatus::Unreadable
} else {
IncludeStatus::Checked
};
if status != IncludeStatus::Checked {
read.insert(path.clone(), (status, None));
found = Some(path);
break;
}
files.push(path.clone());
let file = files.len() - 1;
read.insert(path.clone(), (status, Some(file)));
let (text, invalid) = crate::decode(&b);
let mut tallies = Acc::new(limits);
let scan = analyze::scan(&text, Some(&path), Rc::clone(&inc.paths), known, &mut tallies);
acc.steps = acc.steps.saturating_add(tallies.steps);
known.add(scan.known, limits.entries);
checked.push(ReadFile { path: path.clone(), text: text.into_owned(), invalid, paths: Rc::clone(&inc.paths) });
work.push_back(Work { file, includes: scan.includes, depth: w.depth + 1 });
(found, to) = (Some(path), Some(file));
break;
}
if status == IncludeStatus::Missing && refused {
status = IncludeStatus::NotFollowed;
}
if let Some(to) = to {
edges.insert((w.file, to));
}
// One row more than the report keeps marks it truncated.
if rows.len() <= limits.entries {
rows.push(Row { value: acc.echo(&inc.value), line: inc.line, from: w.file, status, path: found, to });
}
}
}
let component = components(files.len(), &edges, acc);
for r in rows {
// An include closes a cycle when the file it includes leads back to the including one:
// both are then in one strongly connected component.
let cycle = r.to.and_then(|to| component.get(to)).is_some_and(|c| component.get(r.from) == Some(c));
let status = if cycle { IncludeStatus::Cycle } else { r.status };
let file = files.get(r.from).filter(|_| r.from > 0).map(String::as_str);
acc.include(&r.value, r.line, file, status, r.path.as_deref());
}
checked
}
/// For each of `n` files, the number of its strongly connected component (files that include each
/// other, directly or through others, share one), with `edges` giving the files each one includes.
/// Tarjan's algorithm without recursion: linear in files and edges, where a row of reached files
/// for every file took their square.
fn components(n: usize, edges: &BTreeSet<(usize, usize)>, acc: &mut Acc) -> Vec<usize> {
let mut next: Vec<Vec<usize>> = vec![vec![]; n];
for &(from, to) in edges {
if let (Some(v), true) = (next.get_mut(from), to < n) {
v.push(to);
}
}
const UNSEEN: usize = usize::MAX;
let (mut index, mut low, mut component) = (vec![UNSEEN; n], vec![0; n], vec![UNSEEN; n]);
let (mut on_stack, mut stack) = (vec![false; n], vec![]);
let (mut counter, mut components) = (0, 0);
for root in 0..n {
if index[root] != UNSEEN {
continue;
}
// Each frame: a file and how many of its includes have been followed.
let mut frames = vec![(root, 0)];
index[root] = counter;
low[root] = counter;
counter += 1;
stack.push(root);
on_stack[root] = true;
while let Some(&(f, i)) = frames.last() {
acc.steps = acc.steps.saturating_add(1);
if let Some(&to) = next[f].get(i) {
if let Some(top) = frames.last_mut() {
top.1 += 1;
}
if index[to] == UNSEEN {
index[to] = counter;
low[to] = counter;
counter += 1;
stack.push(to);
on_stack[to] = true;
frames.push((to, 0));
} else if on_stack[to] {
low[f] = low[f].min(index[to]);
}
continue;
}
frames.pop();
if let Some(&(parent, _)) = frames.last() {
low[parent] = low[parent].min(low[f]);
}
if low[f] == index[f] {
while let Some(g) = stack.pop() {
on_stack[g] = false;
component[g] = components;
if g == f {
break;
}
}
components += 1;
}
}
}
component
}
/// List the script's `includes` as not read.
pub(crate) fn not_read(acc: &mut Acc, includes: Vec<IncludeRef>) {
for inc in includes {
acc.include(&inc.value, inc.line, None, IncludeStatus::NotRead, None);
}
}
/// The folder part of `path` (`lib/a.jsxinc` → `lib`, `a.jsxinc` → ``).
fn parent(path: &str) -> &str {
path.rsplit_once('/').map_or("", |(folder, _)| folder)
}
/// A name the checker doesn't follow: absolute (`/x`, `\x`, `C:x`, `~/x`), naming an environment
/// variable (`%APPDATA%`, `$HOME`), or longer than [`MAX_PATH`].
fn unfollowed(name: &str) -> bool {
name.starts_with(['/', '\\', '~', '$']) || name.contains([':', '%']) || name.len() > MAX_PATH
}
/// `rel` taken from `folder`, both inside the script's folder, as a path from that folder (empty
/// for the folder itself): none when it leads out of it.
fn walk(folder: &str, rel: &str) -> Option<String> {
let mut parts: Vec<&str> = folder.split('/').filter(|s| !s.is_empty()).collect();
for part in rel.split(['/', '\\']) {
match part {
"" | "." => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
/// The file `rel` names from `folder`, both inside the script's folder: none when it leads out of
/// it, or names the folder itself.
fn join(folder: &str, rel: &str) -> Option<String> {
walk(folder, rel).filter(|path| !path.is_empty())
}
/// The paths an `#include` of `name` may mean, from `folder`, in the order they are tried and each
/// once: the name itself, then the name in each `#includepath` folder. Each is worked out when it
/// is tried. Names and folders the checker doesn't follow, and names or folders that lead out of
/// the script's folder, give none.
fn candidates<'a>(folder: &'a str, name: &'a str, paths: &'a [String]) -> impl Iterator<Item = String> + 'a {
let name = Some(name).filter(|n| !unfollowed(n));
let first = name.and_then(|n| join(folder, n));
let inside = name
.into_iter()
.flat_map(move |n| paths.iter().filter(|p| !unfollowed(p)).filter_map(move |p| walk(folder, p)).filter_map(move |p| join(&p, n)));
let mut tried: Vec<String> = vec![];
first.into_iter().chain(inside).filter(move |p| {
let new = !tried.contains(p);
if new {
tried.push(p.clone());
}
new
})
}
#[cfg(test)]
mod tests {
use super::*;
fn tried(folder: &str, name: &str, paths: &[String]) -> Vec<String> {
candidates(folder, name, paths).collect()
}
#[test]
fn names_are_taken_from_the_including_files_folder() {
assert_eq!(tried("", "a.jsxinc", &[]), ["a.jsxinc"]);
assert_eq!(tried("lib", "b.jsxinc", &[]), ["lib/b.jsxinc"]);
assert_eq!(tried("lib/deep", "../c.jsxinc", &[]), ["lib/c.jsxinc"]);
assert_eq!(tried("lib", "./x\\y.jsxinc", &[]), ["lib/x/y.jsxinc"]);
// Then each include path, inside the folder.
let paths = ["shared".to_string(), "/abs".to_string(), "../out".to_string(), "shared".to_string()];
assert_eq!(tried("", "u.jsxinc", &paths), ["u.jsxinc", "shared/u.jsxinc"]);
// An include path may lead back to the script's own folder.
assert_eq!(tried("lib", "shared.jsxinc", &["..".to_string()]), ["lib/shared.jsxinc", "shared.jsxinc"]);
}
#[test]
fn absolute_names_and_names_leading_out_are_not_followed() {
for name in
["/etc/x.jsx", "\\\\server\\x.jsx", "C:/x.jsx", "~/x.jsx", "%APPDATA%/x.jsx", "$HOME/x.jsx", "../x.jsx", "a/../../x.jsx", ".", "a/.."]
{
assert!(tried("", name, &[]).is_empty(), "{name}");
}
assert!(tried("lib", "../../x.jsx", &[]).is_empty());
// Names and folders longer than a path can be.
let long = "d".repeat(MAX_PATH + 1);
assert!(tried("", &long, &[]).is_empty());
assert_eq!(tried("", "a.jsxinc", &[long]), ["a.jsxinc"]);
}
}