//! 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), /// 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, loader: &mut dyn Loader, script: &str, bytes: usize, known: &mut Known, ) -> Vec { /// 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, 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, /// The file it names, when that file was read. to: Option, } let limits = acc.limits; // The files read, by their path inside the script's folder; the script first. let mut files: Vec = vec![script.to_string()]; // What became of each path found, and the file read there: a file is read once. let mut read: BTreeMap)> = 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 = 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 = 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, Option) = (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> { let mut next: Vec> = 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) { 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 { 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 { 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 + '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 = 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 { 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"]); } }