From f7c5062e4b341fd9a72628eec7aa21c4f44e44dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:57:16 +0000 Subject: [PATCH] webdetect: check service language level vs Node; advisory runtime APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups after the merged web-app work. Service language-version check (gating): a service's own .js is tokenized for its ES syntax level and checked against each firmware's bundled Node.js version (EsLevel::min_node_version). This is the trustworthy, code-derived Node requirement that replaces the untrusted engines.node — a service using optional chaining now correctly fails on Node 12 and passes on Node 16. Runtime-API detection (advisory, polyfill-aware): unambiguous namespaced APIs (Object.assign, Object.entries/fromEntries, Array.from, Promise.allSettled, globalThis, Reflect.*, ...) are detected from the token stream and flagged when newer than the target engine/Node — a "may need polyfills" note that never gates. Prototype methods are excluded (ambiguous receiver). Crucially, testing real packages showed bundles ship core-js/babel-runtime that merely *reference* every API, so the advisory is suppressed when a polyfill library is detected; the component is instead noted as self-polyfilling. JS token analysis (ES features + APIs + polyfill sniffing) is factored into a shared js module used by both web-app and service detection. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3 --- common/verify/src/ipk/mod.rs | 113 +++++-- common/webdetect/src/eslevel.rs | 25 ++ common/webdetect/src/js.rs | 446 +++++++++++++++++++++++++ common/webdetect/src/lib.rs | 24 ++ common/webdetect/src/service.rs | 18 +- common/webdetect/src/web.rs | 258 +------------- packages/ipk-verify/src/main.rs | 121 ++++++- packages/ipk-verify/src/output.rs | 26 ++ packages/ipk-verify/tests/detection.rs | 58 ++-- 9 files changed, 777 insertions(+), 312 deletions(-) create mode 100644 common/webdetect/src/js.rs diff --git a/common/verify/src/ipk/mod.rs b/common/verify/src/ipk/mod.rs index 0354982..83421bd 100644 --- a/common/verify/src/ipk/mod.rs +++ b/common/verify/src/ipk/mod.rs @@ -39,15 +39,23 @@ pub enum DetectionResult { detection: WebAppDetection, /// The firmware's web engine (for rendering the compat column). engine: Option, - /// Whether the firmware's engine supports the app's ES level. + /// Whether the firmware's engine supports the app's ES **syntax** level + /// (gating — syntax can't be polyfilled). es: CompatVerdict, + /// Whether the firmware's engine natively provides the runtime APIs the + /// app uses (advisory — APIs can be polyfilled). + api: CompatVerdict, }, Service { detection: ServiceRuntimeDetection, - /// The firmware's Node.js version — informational only. There is no - /// compat verdict for services: `engines.node` isn't trusted and webOS - /// services carry no other reliable runtime requirement. + /// The firmware's Node.js version. available_node: Option, + /// Whether the firmware's Node.js supports the service's ES **syntax** + /// level (gating — derived from the code, not `engines.node`). + node: CompatVerdict, + /// Whether the firmware's Node.js natively provides the runtime APIs the + /// service uses (advisory). + api: CompatVerdict, }, } @@ -60,19 +68,27 @@ pub enum CompatVerdict { } impl DetectionResult { - /// The compatibility verdict for this component on this firmware, if it has - /// one. Services are informational only (no verdict). - pub fn verdict(&self) -> Option<&CompatVerdict> { + /// The gating compatibility verdict for this component on this firmware + /// (web app: ES syntax vs engine; service: ES syntax vs Node.js). + pub fn verdict(&self) -> &CompatVerdict { match self { - DetectionResult::WebApp { es, .. } => Some(es), - DetectionResult::Service { .. } => None, + DetectionResult::WebApp { es, .. } => es, + DetectionResult::Service { node, .. } => node, } } - /// Whether this component is definitively incompatible (a `Fail` verdict); - /// `Unknown`/no-verdict is not treated as incompatible. + /// The advisory runtime-API verdict — never gates compatibility. + pub fn api_advisory(&self) -> &CompatVerdict { + match self { + DetectionResult::WebApp { api, .. } => api, + DetectionResult::Service { api, .. } => api, + } + } + + /// Whether this component is definitively incompatible (a gating `Fail`); + /// `Unknown` and the advisory API verdict never count. pub fn is_incompatible(&self) -> bool { - matches!(self.verdict(), Some(CompatVerdict::Fail { .. })) + matches!(self.verdict(), CompatVerdict::Fail { .. }) } } @@ -134,11 +150,17 @@ impl VerifyForFirmware for Package { fn web_detection(app: &Component, engine: Option<&WebEngine>) -> Option { let detection = app.info.web.clone()?; - let es = web_verdict(detection.es_level, engine); + let es = web_verdict(detection.es_level, engine, "app uses"); + // Advisory: highest ES level implied by the runtime APIs used. + let api_level = detection.es_apis.iter().map(|a| a.level).max(); + let api = web_verdict(api_level, engine, "app calls APIs from").demote_reason( + "may need polyfills", + ); return Some(DetectionResult::WebApp { detection, engine: engine.cloned(), es, + api, }); } @@ -147,34 +169,81 @@ fn service_detection( node: Option<&Version>, ) -> Option { let detection = svc.info.runtime.clone()?; + let node_verdict = service_verdict(detection.es_level, node, "service uses"); + let api_level = detection.es_apis.iter().map(|a| a.level).max(); + let api = service_verdict(api_level, node, "service calls APIs from") + .demote_reason("may need polyfills"); return Some(DetectionResult::Service { detection, available_node: node.cloned(), + node: node_verdict, + api, }); } -/// Does the firmware's web engine support the app's required ES level? -fn web_verdict(es_level: Option, engine: Option<&WebEngine>) -> CompatVerdict { +/// Whether the firmware's web engine supports the given ES level. +fn web_verdict(es_level: Option, engine: Option<&WebEngine>, verb: &str) -> CompatVerdict { let Some(es_level) = es_level else { return CompatVerdict::Unknown; }; - let fw_max = match engine { - Some(WebEngine::Chromium(v)) => EsLevel::from_chromium_major(v.major as u32), - // The LG WebKit port (537.x) predates reliable ES2015 support. - Some(WebEngine::WebKit(_)) => EsLevel::Es5, - None => return CompatVerdict::Unknown, + let Some(engine) = engine else { + return CompatVerdict::Unknown; }; + let fw_max = engine_max_es(engine); if es_level <= fw_max { CompatVerdict::Ok } else { CompatVerdict::Fail { reason: format!( - "app uses {}, but {} supports up to {}", + "{verb} {}, but {} supports up to {}", es_level.label(), - engine.map(|e| e.label()).unwrap_or_default(), + engine.label(), fw_max.label() ), } } } +/// Whether the firmware's Node.js supports the given ES level. +fn service_verdict(es_level: Option, node: Option<&Version>, verb: &str) -> CompatVerdict { + let Some(es_level) = es_level else { + return CompatVerdict::Unknown; + }; + let Some(node) = node else { + return CompatVerdict::Unknown; + }; + let (maj, min) = es_level.min_node_version(); + if node.major > maj || (node.major == maj && node.minor >= min) { + CompatVerdict::Ok + } else { + CompatVerdict::Fail { + reason: format!( + "{verb} {}, which needs Node.js {maj}.{min}, but firmware ships {node}", + es_level.label(), + ), + } + } +} + +/// The highest ES level a firmware's web engine supports. +pub fn engine_max_es(engine: &WebEngine) -> EsLevel { + match engine { + WebEngine::Chromium(v) => EsLevel::from_chromium_major(v.major as u32), + // The LG WebKit port (537.x) predates reliable ES2015 support. + WebEngine::WebKit(_) => EsLevel::Es5, + } +} + +impl CompatVerdict { + /// Rewrite a `Fail` reason's lead-in so the advisory reads as a polyfill + /// note rather than a hard failure. No-op for `Ok`/`Unknown`. + fn demote_reason(self, note: &str) -> CompatVerdict { + match self { + CompatVerdict::Fail { reason } => CompatVerdict::Fail { + reason: format!("{reason} ({note})"), + }, + other => other, + } + } +} + diff --git a/common/webdetect/src/eslevel.rs b/common/webdetect/src/eslevel.rs index ab939d3..2f07d16 100644 --- a/common/webdetect/src/eslevel.rs +++ b/common/webdetect/src/eslevel.rs @@ -53,6 +53,23 @@ impl EsLevel { EsLevel::Es5 } + /// Minimum Node.js major/minor that supports the syntax this level implies + /// (V8 landing points). Used to check a service's own code against the + /// firmware's bundled Node.js — a trustworthy, code-derived requirement + /// (unlike `engines.node`, which isn't read). + pub fn min_node_version(self) -> (u64, u64) { + match self { + EsLevel::Es5 => (0, 10), + EsLevel::Es2015 => (6, 0), // let/const/arrow/class/template/spread + EsLevel::Es2016 => (7, 0), // ** exponentiation + EsLevel::Es2017 => (7, 6), // async/await + EsLevel::Es2018 => (10, 0), // object spread, async iteration + EsLevel::Es2019 => (12, 0), + EsLevel::Es2020 => (14, 0), // optional chaining, nullish coalescing + EsLevel::Es2021Plus => (15, 0), + } + } + pub fn label(self) -> &'static str { match self { EsLevel::Es5 => "ES5", @@ -136,6 +153,14 @@ mod tests { assert_eq!(EsLevel::from_chromium_major(0), EsLevel::Es5); } + #[test] + fn min_node_versions_are_ordered() { + assert_eq!(EsLevel::Es5.min_node_version(), (0, 10)); + assert_eq!(EsLevel::Es2017.min_node_version(), (7, 6)); + assert_eq!(EsLevel::Es2020.min_node_version(), (14, 0)); + assert!(EsLevel::Es2020.min_node_version() > EsLevel::Es2015.min_node_version()); + } + #[test] fn feature_levels_order() { assert!(EsFeature::OptionalChaining.level() > EsFeature::AsyncAwait.level()); diff --git a/common/webdetect/src/js.rs b/common/webdetect/src/js.rs new file mode 100644 index 0000000..5a6b034 --- /dev/null +++ b/common/webdetect/src/js.rs @@ -0,0 +1,446 @@ +//! Shared JavaScript analysis: gather `.js` sources and, from a `ress` token +//! stream, derive the ES syntax level and any notable runtime-API usage. +//! +//! Used by both web-app detection (checked against the firmware's web engine) +//! and service detection (checked against the firmware's Node.js). Tokenizing +//! means keywords/operators/identifiers inside strings, comments and regex +//! literals are ignored. + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::Path; + +use ress::prelude::*; + +use crate::eslevel::{EsFeature, EsLevel}; +use crate::ApiUse; + +/// Skip individual files larger than this. +pub(crate) const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Stop after scanning this many JS files. +const MAX_JS_FILES: usize = 400; +/// Recursion depth cap for the directory walk. +const MAX_DEPTH: usize = 12; + +/// The result of analyzing a set of JS sources. +pub(crate) struct JsAnalysis { + pub es_level: Option, + pub es_features: Vec, + pub es_apis: Vec, +} + +/// Recursively gather `*.js` file contents (skipping source maps), bounded by +/// the depth/count/size caps above. +pub(crate) fn collect_js(dir: &Path, depth: usize, out: &mut Vec<(String, String)>) { + if depth > MAX_DEPTH || out.len() >= MAX_JS_FILES { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if out.len() >= MAX_JS_FILES { + return; + } + let path = entry.path(); + let Ok(ft) = entry.file_type() else { continue }; + if ft.is_dir() { + collect_js(&path, depth + 1, out); + continue; + } + if !ft.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".js") || name.ends_with(".map") { + continue; + } + if entry.metadata().map(|m| m.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { + continue; + } + if let Ok(content) = fs::read(&path) { + out.push((name, String::from_utf8_lossy(&content).into_owned())); + } + } +} + +/// Analyze JS sources for ES syntax level and static runtime-API usage. +/// `html_module` marks a `