mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
VectorCraft does not run scripts written for the reference app, and File › Scripts held only the disabled Other Script…. File › Scripts › Check Script… picks a .jsx, .jsxinc or .jsxbin file, reads it without running any of it, and shows a report: what the script needs (the script language's own objects, the document model, files, menu commands, dialogs, or features only the reference app has), what it would do outside the document, the menu-command strings it passes, the document-model member chains and type names it uses, its directives, the files it includes, and the problems that would stop it loading. Copy Report copies the report as JSON. File › Open and drag-and-drop show the report for those files too. - vectorcraft-scriptcheck, a new Standalone crate with no dependencies, holds the checker: a lexer for ECMAScript 3 with E4X literals and the #name and //@name directive lines, and an analysis of the tokens that follows variables, function scopes and the files a script includes. It never recurses: open brackets, the levels of an XML literal and the function bodies being read are kept on stacks capped at 4,096. It reads at most 8 MiB of a file and 32 MiB with the included files, follows 64 included files 16 deep, looks up at most 1,024 paths for them, keeps 1,000 entries in a list, tracks 1,000 variables holding member chains and 1,000 holding File objects, and cuts echoed text to 120 characters. A compiled script is reported as compiled and isn't read. - script.check (engine, a query) takes path, data or dataBase64. With a path and includes: true, it reads the files the #include lines name from inside the script's folder, through the automation read root, and when the script or one of those files assigns a member chain or a File object to a variable, the check treats the variable as holding it in every file. It doesn't follow absolute names, names, folders or links that lead out of the folder, names or folders longer than 4,096 bytes, or JSINCLUDE, and it reads only regular files. - ui.checkScript shows the report (dialog scriptCheck). File › Open lists Scripts (.jsx, .jsxinc, .jsxbin) and sends a script's path to script.check, which reads at most 8 MiB of it. The language rules come from ECMA-262 3rd edition and ECMA-357 2nd edition. The directive, platform and document-model root names come from the public JavaScript Tools Guide and scripting guide pages that catalog.rs cites by URL, and executeMenuCommand, doScript, loadAction, unloadAction and selectTool come from the public forum posts cited there; only names are used. Every script in the tests is written from scratch with invented names. Docs: docs/mcp.md, docs/target-app-parity.md.
160 lines
7.9 KiB
Rust
160 lines
7.9 KiB
Rust
//! `script.check`: a script written for the reference app (`.jsx`, `.jsxinc`, `.js`, or a compiled
|
||
//! `.jsxbin`) checked without running any of it, by `vectorcraft-scriptcheck`: what kind of file it
|
||
//! is, what it needs, its directives and includes, the platform features and document-model member
|
||
//! chains it uses, the menu commands it runs, what it does outside the document, and its problems.
|
||
//! With a path and `includes`, the files its `#include` lines name are read from inside its folder,
|
||
//! each through the same checks as every other file a command reads.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_scriptcheck::{Limits, Loaded, Loader, Report};
|
||
|
||
/// The ids a reply can hold, for the windows that show it.
|
||
pub use vectorcraft_scriptcheck::{IncludeStatus, Need, OUTSIDE, PROBLEMS};
|
||
|
||
use super::*;
|
||
|
||
/// The extensions of the scripts File › Open shows a check for.
|
||
pub const EXTS: &[&str] = &["jsx", "jsxinc", "jsxbin"];
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![cmd!(
|
||
query "script.check",
|
||
"Check Script",
|
||
[],
|
||
None,
|
||
"{path? | data?: the script's text | dataBase64?, name?: the file's name (default: the path's; with a path, only the reply's name), includes?: false (with a path: also check the files its #include lines name, inside the script's folder; not on the web)} check a script written for the reference app (.jsx, .jsxinc, .js, or a compiled .jsxbin) without running any of it → {name, kind: script|compiled|module|notText|tooLarge, needs: [language|dom|files|menuCommands|dialogs|hostOnly], lines, targets: [{value, app, instance?, version?, locale?, line, file?}], directives: [{name, value, line, form: \"#\"|\"//@\", file?}], includes: [{value, line, status: checked|missing|notFollowed|cycle|notRead|limit|unreadable, path?, file?}], features: [{id, count, lines}], dom: {members: [{path, count}], types: [{name, count}]}, menuCommands: {strings: [{value, count, lines}], dynamic}, outside: [{id: filesWritten|filesOpened|network|nativeCode|otherApps|environment|unseenCode|preferences, count, lines, modes?}], problems: [{code, line?, file?, detail?}], truncated} (file: the included file it is in; lines: at most 20 of the script's own, while counts include the included files; at most 8 MiB a file and 32 MiB in all, 64 included files 16 deep, 1,024 paths looked up for them, 1,000 entries a list, text echoed cut to 120 characters)",
|
||
always,
|
||
check
|
||
)]
|
||
}
|
||
|
||
/// The folder a script is in, which its included files are read from.
|
||
struct Folder {
|
||
dir: std::path::PathBuf,
|
||
/// The folder with its links followed (none where the file system can't tell).
|
||
real: Option<std::path::PathBuf>,
|
||
}
|
||
|
||
impl Folder {
|
||
/// The folder of the script at `path`.
|
||
fn of(path: &str) -> Self {
|
||
let dir = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
|
||
Folder { real: std::fs::canonicalize(dir).ok(), dir: dir.to_path_buf() }
|
||
}
|
||
}
|
||
|
||
impl Loader for Folder {
|
||
fn load(&mut self, path: &str, max: usize) -> Loaded {
|
||
let file = self.dir.join(path);
|
||
// A link that leads out of the script's folder isn't followed.
|
||
if let (Some(real), Ok(target)) = (&self.real, std::fs::canonicalize(&file))
|
||
&& !target.starts_with(real)
|
||
{
|
||
return Loaded::Refused;
|
||
}
|
||
let file = file.to_string_lossy();
|
||
if crate::file_access::check_read(&file).is_err() {
|
||
return Loaded::Refused;
|
||
}
|
||
super::fileio::read_file_head(&file, max).map_or(Loaded::Missing, Loaded::Bytes)
|
||
}
|
||
}
|
||
|
||
/// The bytes the base64 text `b64` holds, up to `max` and a few more: only the text they take is
|
||
/// decoded.
|
||
fn decode_head(b64: &str, max: usize) -> Option<Vec<u8>> {
|
||
let chars = max.div_ceil(3).saturating_mul(4);
|
||
let mut kept = 0usize;
|
||
let end = b64
|
||
.bytes()
|
||
.position(|b| {
|
||
kept += usize::from(!b.is_ascii_whitespace());
|
||
kept > chars
|
||
})
|
||
.unwrap_or(b64.len());
|
||
vectorcraft_format::base64_decode(b64.get(..end)?)
|
||
}
|
||
|
||
fn check(_: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "script.check";
|
||
let limits = Limits::default();
|
||
// One byte more than a file may have shows that the file is too large.
|
||
let max = limits.file_bytes.saturating_add(1);
|
||
let path = str_param(p, "path");
|
||
let bytes: std::borrow::Cow<[u8]> = match (path, str_param(p, "data"), str_param(p, "dataBase64")) {
|
||
(Some(path), ..) => super::fileio::read_file_head(path, max)?.into(),
|
||
(None, Some(text), _) => text.as_bytes().get(..max).unwrap_or(text.as_bytes()).into(),
|
||
(None, None, Some(b64)) => decode_head(b64, max).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||
};
|
||
let name = super::fileio::file_name(str_param(p, "name").or(path).unwrap_or_default());
|
||
// With a path, the file is checked under its own name, which its includes are followed from;
|
||
// `name` names the reply.
|
||
let file = path.map(super::fileio::file_name).unwrap_or_else(|| name.clone());
|
||
let mut folder = path.filter(|_| bool_or(p, "includes", false)).map(Folder::of);
|
||
let report = vectorcraft_scriptcheck::check(&file, &bytes, folder.as_mut().map(|f| f as &mut dyn Loader), &limits);
|
||
Ok(report_json(&name, &report))
|
||
}
|
||
|
||
/// Object `v` with `key: value` added when there is a value.
|
||
fn with(mut v: Value, key: &str, value: Option<impl serde::Serialize>) -> Value {
|
||
if let (Some(o), Some(x)) = (v.as_object_mut(), value) {
|
||
o.insert(key.into(), json!(x));
|
||
}
|
||
v
|
||
}
|
||
|
||
/// The reply for report `r` on the script `name`.
|
||
fn report_json(name: &str, r: &Report) -> Value {
|
||
let targets: Vec<Value> = r
|
||
.targets
|
||
.iter()
|
||
.map(|t| {
|
||
let v = json!({"value": t.value, "app": t.app, "line": t.line});
|
||
let v = with(v, "instance", t.instance.as_deref());
|
||
let v = with(v, "version", t.version.as_deref());
|
||
let v = with(v, "locale", t.locale.as_deref());
|
||
with(v, "file", t.file.as_deref())
|
||
})
|
||
.collect();
|
||
let directives: Vec<Value> = r
|
||
.directives
|
||
.iter()
|
||
.map(|d| with(json!({"name": d.name, "value": d.value, "line": d.line, "form": d.form}), "file", d.file.as_deref()))
|
||
.collect();
|
||
let includes: Vec<Value> = r
|
||
.includes
|
||
.iter()
|
||
.map(|i| with(with(json!({"value": i.value, "line": i.line, "status": i.status.id()}), "path", i.path.as_deref()), "file", i.file.as_deref()))
|
||
.collect();
|
||
let features: Vec<Value> = r.features.iter().map(|f| json!({"id": f.id, "count": f.count, "lines": f.lines})).collect();
|
||
let members: Vec<Value> = r.members.iter().map(|m| json!({"path": m.name, "count": m.count})).collect();
|
||
let types: Vec<Value> = r.types.iter().map(|t| json!({"name": t.name, "count": t.count})).collect();
|
||
let strings: Vec<Value> = r.menu_strings.iter().map(|s| json!({"value": s.value, "count": s.count, "lines": s.lines})).collect();
|
||
let outside: Vec<Value> = r
|
||
.outside
|
||
.iter()
|
||
.map(|o| with(json!({"id": o.id, "count": o.count, "lines": o.lines}), "modes", Some(&o.modes).filter(|m| !m.is_empty())))
|
||
.collect();
|
||
let problems: Vec<Value> = r
|
||
.problems
|
||
.iter()
|
||
.map(|p| with(with(with(json!({"code": p.code}), "line", p.line), "file", p.file.as_deref()), "detail", p.detail.as_deref()))
|
||
.collect();
|
||
json!({
|
||
"name": name,
|
||
"kind": r.kind.id(),
|
||
"needs": r.needs.iter().map(|n| n.id()).collect::<Vec<_>>(),
|
||
"lines": r.lines,
|
||
"targets": targets,
|
||
"directives": directives,
|
||
"includes": includes,
|
||
"features": features,
|
||
"dom": {"members": members, "types": types},
|
||
"menuCommands": {"strings": strings, "dynamic": r.menu_dynamic},
|
||
"outside": outside,
|
||
"problems": problems,
|
||
"truncated": r.truncated,
|
||
})
|
||
}
|