297 lines
13 KiB
Rust
297 lines
13 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 reach = reachable(files.len(), &edges, acc);
|
|
for r in rows {
|
|
let cycle = r.to.and_then(|to| reach.get(to)).and_then(|reached| reached.get(r.from)).copied().unwrap_or(false);
|
|
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 files it includes directly or through other files (itself too),
|
|
/// with `edges` giving the files each one includes.
|
|
fn reachable(n: usize, edges: &BTreeSet<(usize, usize)>, acc: &mut Acc) -> Vec<Vec<bool>> {
|
|
let mut next: Vec<Vec<usize>> = vec![vec![]; n];
|
|
for &(from, to) in edges {
|
|
if let Some(v) = next.get_mut(from) {
|
|
v.push(to);
|
|
}
|
|
}
|
|
(0..n)
|
|
.map(|start| {
|
|
let mut seen = vec![false; n];
|
|
let mut stack = vec![start];
|
|
while let Some(f) = stack.pop() {
|
|
acc.steps = acc.steps.saturating_add(1);
|
|
match seen.get_mut(f) {
|
|
Some(s) if !*s => *s = true,
|
|
_ => continue,
|
|
}
|
|
stack.extend(next.get(f).into_iter().flatten().copied());
|
|
}
|
|
seen
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// 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"]);
|
|
}
|
|
}
|