From 7d4fcbaefcc7def3edcb6efff1b87213d0ce8fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:44:01 +0000 Subject: [PATCH 1/6] ipk-verify: detect web/service tech for non-native components Non-native app and service components were previously skipped with "Skip because this component is not native". Instead, detect and report their technology and check per-firmware compatibility, mirroring the native library check. For web/hosted apps, scan the bundled HTML/JS to identify the frontend framework (Enact, React, Vue, Angular, jQuery, webOSTV.js) and its version, and determine the minimum ECMAScript level the shipped bundle requires. For JS/Node services, read the bundled package.json for the declared engines.node requirement and dependencies. Compatibility is checked against each firmware's actual runtime, read from the packages.json already committed under common/data: the Node.js version and the web engine. The web engine resolves across generations and both families -- Chromium (lib32-webruntime / webruntime / chromiumNN / chromium-webos) and the legacy LG WebKit port (webkit-starfish). A definitive incompatibility fails the component and the process exit code; unknown firmware data yields UNKNOWN and never fails. New webdetect-lib crate holds the pure text/JSON detectors; fw-lib gains node_version()/web_engine() accessors; verify-lib gains Package::verify_for_firmware and DetectionResult/CompatVerdict; the ipk-verify report replaces the skip line with a per-firmware summary in all three output formats. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3 --- Cargo.lock | 16 ++ Cargo.toml | 1 + common/fw/src/firmware.rs | 10 +- common/fw/src/lib.rs | 18 ++ common/fw/src/runtime.rs | 168 ++++++++++++ common/ipk/Cargo.toml | 5 +- common/ipk/src/component.rs | 9 + common/ipk/src/lib.rs | 9 + common/verify/Cargo.toml | 5 +- common/verify/src/ipk/component.rs | 10 + common/verify/src/ipk/mod.rs | 153 ++++++++++- common/webdetect/Cargo.toml | 17 ++ common/webdetect/src/eslevel.rs | 135 ++++++++++ common/webdetect/src/lib.rs | 83 ++++++ common/webdetect/src/service.rs | 89 +++++++ common/webdetect/src/web.rs | 350 +++++++++++++++++++++++++ packages/ipk-verify/Cargo.toml | 3 + packages/ipk-verify/src/main.rs | 209 ++++++++++++++- packages/ipk-verify/src/output.rs | 31 ++- packages/ipk-verify/tests/detection.rs | 134 ++++++++++ 20 files changed, 1447 insertions(+), 8 deletions(-) create mode 100644 common/fw/src/runtime.rs create mode 100644 common/webdetect/Cargo.toml create mode 100644 common/webdetect/src/eslevel.rs create mode 100644 common/webdetect/src/lib.rs create mode 100644 common/webdetect/src/service.rs create mode 100644 common/webdetect/src/web.rs create mode 100644 packages/ipk-verify/tests/detection.rs diff --git a/Cargo.lock b/Cargo.lock index f55ebe85..9a2cbcad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -794,6 +794,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "webdetect-lib", ] [[package]] @@ -814,6 +815,7 @@ dependencies = [ "tempfile", "term", "verify-lib", + "webdetect-lib", ] [[package]] @@ -1340,7 +1342,10 @@ name = "verify-lib" version = "0.1.3" dependencies = [ "bin-lib", + "fw-lib", "ipk-lib", + "semver", + "webdetect-lib", ] [[package]] @@ -1410,6 +1415,17 @@ version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +[[package]] +name = "webdetect-lib" +version = "0.1.0" +dependencies = [ + "regex", + "semver", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index db5ed851..d08f5036 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "common/bin", "common/ipk", "common/verify", + "common/webdetect", "packages/elf-verify", "packages/ipk-verify", "packages/fw-extract", diff --git a/common/fw/src/firmware.rs b/common/fw/src/firmware.rs index 761762e7..db2007ab 100644 --- a/common/fw/src/firmware.rs +++ b/common/fw/src/firmware.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use bin_lib::LibraryInfo; -use crate::{Firmware, FirmwareInfo, ReleaseCodename}; +use crate::{Firmware, FirmwareInfo, PackageEntry, ReleaseCodename}; impl FirmwareInfo { pub fn codename(&self) -> Option { @@ -74,10 +74,18 @@ impl Firmware { }); })?; + // packages.json carries the installed OS package versions (Node.js, the + // web engine, ...). Older data dumps may lack it, so tolerate absence. + let packages: HashMap = File::open(path.join("packages.json")) + .ok() + .and_then(|file| serde_json::from_reader(BufReader::new(file)).ok()) + .unwrap_or_default(); + Ok(Firmware { path: path.to_path_buf(), info, index, + packages, }) } diff --git a/common/fw/src/lib.rs b/common/fw/src/lib.rs index c8a462c1..fb6bbce1 100644 --- a/common/fw/src/lib.rs +++ b/common/fw/src/lib.rs @@ -4,10 +4,13 @@ use std::collections::HashMap; use std::path::PathBuf; pub mod firmware; +pub mod runtime; pub(crate) mod version; use version::version_deserialize; use version::version_serialize; +pub use runtime::WebEngine; + #[derive(Debug, Serialize, Deserialize)] pub struct FirmwareInfo { pub version: String, @@ -24,6 +27,21 @@ pub struct Firmware { pub info: FirmwareInfo, path: PathBuf, index: HashMap, + packages: HashMap, +} + +/// One entry in a firmware's `packages.json`, e.g. +/// `"lib32-nodejs": { "version": { "upstream": "16.20.2", ... } }`. +#[derive(Debug, Deserialize)] +pub struct PackageEntry { + pub version: PackageVersion, +} + +#[derive(Debug, Deserialize)] +pub struct PackageVersion { + pub upstream: String, + #[serde(default)] + pub debian_revision: Option, } pub enum ReleaseCodename { diff --git a/common/fw/src/runtime.rs b/common/fw/src/runtime.rs new file mode 100644 index 00000000..a426fbf1 --- /dev/null +++ b/common/fw/src/runtime.rs @@ -0,0 +1,168 @@ +//! Per-firmware runtime versions (Node.js, web engine) read from `packages.json`. +//! +//! webOS ships the JS-service Node.js runtime and the web-app engine as ordinary +//! OS packages, so their versions live in each firmware's `packages.json` rather +//! than in a table we would have to maintain by hand. The package *names* vary by +//! generation, so resolution tries a family of candidates. + +use semver::Version; + +use crate::Firmware; + +/// The web-app rendering engine a firmware ships. webOS has used two families: +/// a modern Chromium-based runtime (WAM) and, on the earliest TVs, an LG WebKit +/// port (`webkit-starfish`, versioned like `537.41`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebEngine { + Chromium(Version), + WebKit(Version), +} + +impl WebEngine { + /// Human-readable label, e.g. "Chromium 120" or "WebKit 537.41". + pub fn label(&self) -> String { + match self { + WebEngine::Chromium(v) => format!("Chromium {}", v.major), + WebEngine::WebKit(v) => format!("WebKit {}.{}", v.major, v.minor), + } + } +} + +impl Firmware { + /// The Node.js version this firmware ships, if a node package is present. + pub fn node_version(&self) -> Option { + ["lib32-nodejs", "nodejs"] + .iter() + .find_map(|name| self.pkg_version(name)) + } + + /// The web-app engine this firmware ships. Resolution order: + /// 1. Chromium family: `lib32-webruntime` → `webruntime` → highest + /// `chromium` → `chromium-webos` → `chromium`; + /// 2. WebKit family: `webkit-starfish` → `qt5-qtwebkit` → `libQt5WebKit`. + /// + /// `com.webos.app.browser` is deliberately ignored — it is the built-in + /// browser *app*, not the web-app runtime. + pub fn web_engine(&self) -> Option { + for name in ["lib32-webruntime", "webruntime"] { + if let Some(v) = self.pkg_version(name) { + return Some(WebEngine::Chromium(v)); + } + } + if let Some(v) = self.highest_numbered_chromium() { + return Some(WebEngine::Chromium(v)); + } + for name in ["chromium-webos", "chromium"] { + if let Some(v) = self.pkg_version(name) { + return Some(WebEngine::Chromium(v)); + } + } + for name in ["webkit-starfish", "qt5-qtwebkit", "libQt5WebKit"] { + if let Some(v) = self.pkg_version(name) { + return Some(WebEngine::WebKit(v)); + } + } + None + } + + /// Parse a package's `upstream` version string into a [`Version`]. + fn pkg_version(&self, name: &str) -> Option { + self.packages + .get(name) + .and_then(|entry| parse_leading_semver(&entry.version.upstream)) + } + + /// Among version-suffixed `chromium` packages (`chromium38`, + /// `chromium53`, ...), the one with the highest `NN`. + fn highest_numbered_chromium(&self) -> Option { + let mut best: Option<(u32, &str)> = None; + for key in self.packages.keys() { + if let Some(rest) = key.strip_prefix("chromium") { + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Ok(n) = rest.parse::() { + if best.map_or(true, |(b, _)| n > b) { + best = Some((n, key)); + } + } + } + } + best.and_then(|(_, key)| self.pkg_version(key)) + } +} + +/// Take the leading dotted-numeric run of an `upstream` string and parse it as a +/// three-component [`Version`]. Debian `upstream` strings are frequently longer +/// than semver allows (`120.0.6099.270-137.paparoa.1`) or have a `-suffix` +/// (`53.0.2785.34-92...`), so keep only the first three numeric parts. +fn parse_leading_semver(upstream: &str) -> Option { + let lead: String = upstream + .chars() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect(); + let mut parts = lead.split('.').filter(|s| !s.is_empty()); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let patch = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + Some(Version::new(major, minor, patch)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Firmware; + use std::collections::HashMap; + use std::path::PathBuf; + + #[test] + fn parses_various_upstream_strings() { + assert_eq!(parse_leading_semver("16.20.2"), Some(Version::new(16, 20, 2))); + assert_eq!(parse_leading_semver("0.10.15"), Some(Version::new(0, 10, 15))); + assert_eq!( + parse_leading_semver("120.0.6099.270-137.paparoa.1"), + Some(Version::new(120, 0, 6099)) + ); + assert_eq!( + parse_leading_semver("53.0.2785.34-92.323.glacier.45"), + Some(Version::new(53, 0, 2785)) + ); + assert_eq!( + parse_leading_semver("537.41-420.afro.2"), + Some(Version::new(537, 41, 0)) + ); + assert_eq!(parse_leading_semver("garbage"), None); + } + + /// Load every committed firmware data dir and assert the runtime accessors + /// resolve the packages we validated by hand. + #[test] + fn resolves_runtimes_from_real_data() { + let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../data"); + if !data.exists() { + return; // data may be absent in some checkouts + } + let firmwares = Firmware::list(&data).expect("list firmwares"); + let by_release: HashMap = firmwares + .iter() + .map(|f| (f.info.release.to_string(), f)) + .collect(); + + let engine = |rel: &str| by_release.get(rel).and_then(|f| f.web_engine()); + let node = |rel: &str| by_release.get(rel).and_then(|f| f.node_version()); + + // Chromium generations, including the version-suffixed names. + assert_eq!(engine("10.2.0"), Some(WebEngine::Chromium(Version::new(120, 0, 6099)))); + assert_eq!(engine("4.4.2"), Some(WebEngine::Chromium(Version::new(53, 0, 2785)))); + assert_eq!(engine("3.4.0"), Some(WebEngine::Chromium(Version::new(38, 0, 2125)))); + assert_eq!(engine("1.2.0"), Some(WebEngine::Chromium(Version::new(26, 0, 1410)))); + + // Pre-Chromium WebKit images. + assert_eq!(engine("2.2.3"), Some(WebEngine::WebKit(Version::new(537, 41, 0)))); + assert!(matches!(engine("1.4.0"), Some(WebEngine::WebKit(_)))); + + // Node.js. + assert_eq!(node("10.2.0"), Some(Version::new(16, 20, 2))); + assert_eq!(node("6.4.0"), Some(Version::new(8, 12, 0))); + } +} diff --git a/common/ipk/Cargo.toml b/common/ipk/Cargo.toml index cbf8235a..b07609c0 100644 --- a/common/ipk/Cargo.toml +++ b/common/ipk/Cargo.toml @@ -14,4 +14,7 @@ path-slash = { workspace = true } common-path = "1.0.0" [dependencies.bin-lib] -path = "../bin" \ No newline at end of file +path = "../bin" + +[dependencies.webdetect-lib] +path = "../webdetect" \ No newline at end of file diff --git a/common/ipk/src/component.rs b/common/ipk/src/component.rs index c4e0deba..04eeb86d 100644 --- a/common/ipk/src/component.rs +++ b/common/ipk/src/component.rs @@ -41,6 +41,11 @@ impl Component { ) })?; if !info.is_native() { + // Web/hosted app: detect the frontend framework and JS syntax level + // from the shipped HTML/JS while the extracted files still exist. + let index_html = dir.join(Cow::from_slash(&info.main)); + let mut info = info; + info.web = Some(webdetect_lib::detect_web_app(dir, &index_html)); return Ok(Self { id: info.id.clone(), info, @@ -84,6 +89,10 @@ impl Component { let info: ServiceInfo = serde_json::from_reader(File::open(dir.join("services.json"))?) .map_err(|e| Error::new(ErrorKind::InvalidData, format!("Bad appinfo.json: {e:?}")))?; if !info.is_native() { + // JS/Node service: detect the declared Node.js runtime from the + // bundled package.json while the extracted files still exist. + let mut info = info; + info.runtime = Some(webdetect_lib::detect_service_runtime(dir)); return Ok(Self { id: info.id.clone(), info: info.clone(), diff --git a/common/ipk/src/lib.rs b/common/ipk/src/lib.rs index 975b616d..fff0a669 100644 --- a/common/ipk/src/lib.rs +++ b/common/ipk/src/lib.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::path::PathBuf; use bin_lib::{BinaryInfo, LibraryInfo}; +use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection}; mod component; mod ipk; @@ -40,6 +41,10 @@ pub struct AppInfo { pub title: String, pub app_description: Option, pub main: String, + /// Web/frontend technology detected for non-native apps (filled at parse + /// time; not part of appinfo.json). + #[serde(skip)] + pub web: Option, } #[derive(Debug, Deserialize, Clone)] @@ -47,6 +52,10 @@ pub struct ServiceInfo { pub id: String, pub engine: Option, pub executable: Option, + /// Node.js runtime detected for non-native services (filled at parse time; + /// not part of services.json). + #[serde(skip)] + pub runtime: Option, } #[derive(Debug)] diff --git a/common/verify/Cargo.toml b/common/verify/Cargo.toml index ae398928..0a5b4542 100644 --- a/common/verify/Cargo.toml +++ b/common/verify/Cargo.toml @@ -8,8 +8,11 @@ edition = "2021" [dependencies] bin-lib = { path = "../../common/bin", optional = true } ipk-lib = { path = "../../common/ipk", optional = true } +fw-lib = { path = "../../common/fw", optional = true } +webdetect-lib = { path = "../../common/webdetect", optional = true } +semver = { workspace = true, optional = true } [features] default = ["bin"] bin = ["dep:bin-lib"] -ipk = ["bin", "dep:ipk-lib"] \ No newline at end of file +ipk = ["bin", "dep:ipk-lib", "dep:fw-lib", "dep:webdetect-lib", "dep:semver"] \ No newline at end of file diff --git a/common/verify/src/ipk/component.rs b/common/verify/src/ipk/component.rs index d98ec84f..94294101 100644 --- a/common/verify/src/ipk/component.rs +++ b/common/verify/src/ipk/component.rs @@ -34,6 +34,13 @@ impl VerifyResult for ComponentVerifyResult { return false; } } + // A definitive non-native incompatibility (e.g. app ES level exceeds the + // firmware's web engine) also fails the component; Unknown does not. + if let Some(detection) = &self.detection { + if detection.is_incompatible() { + return false; + } + } return true; } } @@ -114,6 +121,7 @@ impl Verify for Component { name: String::new(), }, libs: Default::default(), + detection: None, }; }; let bin = self.verify_bin(exe, find_library); @@ -173,6 +181,8 @@ impl Verify for Component { ComponentBinVerifyResult::Failed(bin) }, libs, + // Filled in by Package::verify_for_firmware for non-native components. + detection: None, }; } } diff --git a/common/verify/src/ipk/mod.rs b/common/verify/src/ipk/mod.rs index 8713c533..1530494d 100644 --- a/common/verify/src/ipk/mod.rs +++ b/common/verify/src/ipk/mod.rs @@ -1,5 +1,8 @@ use bin_lib::LibraryInfo; -use ipk_lib::Package; +use fw_lib::WebEngine; +use ipk_lib::{AppInfo, Component, Package, ServiceInfo}; +use semver::{Version, VersionReq}; +use webdetect_lib::{EsLevel, ServiceRuntimeDetection, WebAppDetection}; use crate::{bin::BinVerifyResult, Verify, VerifyResult}; @@ -16,6 +19,9 @@ pub struct ComponentVerifyResult { pub id: String, pub exe: ComponentBinVerifyResult, pub libs: Vec<(bool, ComponentBinVerifyResult)>, + /// Non-native technology detection + per-firmware compatibility. `None` for + /// native components (which go through the exe/libs path instead). + pub detection: Option, } #[derive(Debug, Eq, PartialEq)] @@ -25,6 +31,50 @@ pub enum ComponentBinVerifyResult { Failed(BinVerifyResult), } +/// Detected technology for a non-native component, paired with the verdict +/// against one firmware's runtime. +#[derive(Debug, Clone)] +pub enum DetectionResult { + WebApp { + 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. + es: CompatVerdict, + }, + Service { + detection: ServiceRuntimeDetection, + /// The firmware's Node.js version. + available_node: Option, + /// Whether the firmware's Node.js satisfies the declared requirement. + node: CompatVerdict, + }, +} + +/// Per-firmware compatibility outcome for a detected runtime requirement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompatVerdict { + Ok, + Fail { reason: String }, + Unknown, +} + +impl DetectionResult { + /// The single compatibility verdict for this component on this firmware. + pub fn verdict(&self) -> &CompatVerdict { + match self { + DetectionResult::WebApp { es, .. } => es, + DetectionResult::Service { node, .. } => node, + } + } + + /// Whether this component is definitively incompatible (a `Fail` verdict); + /// `Unknown` is not treated as incompatible. + pub fn is_incompatible(&self) -> bool { + matches!(self.verdict(), CompatVerdict::Fail { .. }) + } +} + impl Verify for Package { fn verify(&self, find_library: &F) -> PackageVerifyResult where @@ -46,3 +96,104 @@ impl VerifyResult for PackageVerifyResult { return self.app.is_good() && self.services.iter().all(|s| s.is_good()); } } + +/// Verify a package against one firmware, layering non-native technology +/// compatibility on top of the native checks. Implemented for [`Package`] +/// (which lives in another crate, hence an extension trait). +pub trait VerifyForFirmware { + fn verify_for_firmware( + &self, + find_library: &F, + node: Option<&Version>, + engine: Option<&WebEngine>, + ) -> PackageVerifyResult + where + F: Fn(&str) -> Option; +} + +impl VerifyForFirmware for Package { + /// `node` and `engine` are the target firmware's resolved runtimes. + fn verify_for_firmware( + &self, + find_library: &F, + node: Option<&Version>, + engine: Option<&WebEngine>, + ) -> PackageVerifyResult + where + F: Fn(&str) -> Option, + { + let mut result = self.verify(find_library); + result.app.detection = web_detection(&self.app, engine); + for (svc_result, svc) in result.services.iter_mut().zip(self.services.iter()) { + svc_result.detection = service_detection(svc, node); + } + return result; + } +} + +fn web_detection(app: &Component, engine: Option<&WebEngine>) -> Option { + let detection = app.info.web.clone()?; + let es = web_verdict(detection.es_level, engine); + return Some(DetectionResult::WebApp { + detection, + engine: engine.cloned(), + es, + }); +} + +fn service_detection( + svc: &Component, + node: Option<&Version>, +) -> Option { + let detection = svc.info.runtime.clone()?; + let verdict = node_verdict(&detection.declared_node, node); + return Some(DetectionResult::Service { + detection, + available_node: node.cloned(), + node: verdict, + }); +} + +/// Does the firmware's web engine support the app's required ES level? +fn web_verdict(es_level: Option, engine: Option<&WebEngine>) -> 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, + }; + if es_level <= fw_max { + CompatVerdict::Ok + } else { + CompatVerdict::Fail { + reason: format!( + "app uses {}, but {} supports up to {}", + es_level.label(), + engine.map(|e| e.label()).unwrap_or_default(), + fw_max.label() + ), + } + } +} + +/// Does the firmware's Node.js satisfy the service's declared `engines.node`? +fn node_verdict(req: &Option, node: Option<&Version>) -> CompatVerdict { + let Some(node) = node else { + return CompatVerdict::Unknown; + }; + match req { + None => CompatVerdict::Unknown, // nothing declared to check against + Some(req) => { + if req.matches(node) { + CompatVerdict::Ok + } else { + CompatVerdict::Fail { + reason: format!("service requires node {req}, firmware ships {node}"), + } + } + } + } +} diff --git a/common/webdetect/Cargo.toml b/common/webdetect/Cargo.toml new file mode 100644 index 00000000..8556cc07 --- /dev/null +++ b/common/webdetect/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "webdetect-lib" +version = "0.1.0" +edition = "2021" + +# Detects the web/frontend framework and JavaScript syntax level of a webOS web +# app, and the declared Node.js runtime of a JS service. Pure text/JSON analysis +# with no ELF/ipk/firmware knowledge. + +[dependencies] +regex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +semver = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/common/webdetect/src/eslevel.rs b/common/webdetect/src/eslevel.rs new file mode 100644 index 00000000..7575e7d3 --- /dev/null +++ b/common/webdetect/src/eslevel.rs @@ -0,0 +1,135 @@ +//! ECMAScript feature/level model and its mapping to Chromium versions. +//! +//! A web app's shipped bundle uses some set of JS syntax features; the *highest* +//! one determines the minimum engine it can run on. We express that as an +//! [`EsLevel`] and map it to the minimum Chromium major that ships the syntax, +//! so a firmware's web-engine version can be turned into a pass/fail verdict. + +/// A coarse ECMAScript level, ordered oldest → newest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum EsLevel { + Es5, + Es2015, + Es2016, + Es2017, + Es2018, + Es2019, + Es2020, + Es2021Plus, +} + +impl EsLevel { + /// Minimum Chromium major that natively supports the syntax this level + /// implies. Values are the standard caniuse/V8 landing points. + pub fn min_chromium_major(self) -> u32 { + match self { + EsLevel::Es5 => 0, // universally supported + EsLevel::Es2015 => 49, // let/const, arrow, class, template, spread + EsLevel::Es2016 => 52, // ** exponentiation + EsLevel::Es2017 => 55, // async/await + EsLevel::Es2018 => 60, // object spread, async iteration + EsLevel::Es2019 => 73, + EsLevel::Es2020 => 80, // optional chaining, nullish coalescing + EsLevel::Es2021Plus => 85, + } + } + + /// Highest [`EsLevel`] a Chromium engine of the given major supports — the + /// inverse of [`EsLevel::min_chromium_major`]. + pub fn from_chromium_major(major: u32) -> EsLevel { + for level in [ + EsLevel::Es2021Plus, + EsLevel::Es2020, + EsLevel::Es2019, + EsLevel::Es2018, + EsLevel::Es2017, + EsLevel::Es2016, + EsLevel::Es2015, + ] { + if major >= level.min_chromium_major() { + return level; + } + } + EsLevel::Es5 + } + + pub fn label(self) -> &'static str { + match self { + EsLevel::Es5 => "ES5", + EsLevel::Es2015 => "ES2015", + EsLevel::Es2016 => "ES2016", + EsLevel::Es2017 => "ES2017", + EsLevel::Es2018 => "ES2018", + EsLevel::Es2019 => "ES2019", + EsLevel::Es2020 => "ES2020", + EsLevel::Es2021Plus => "ES2021+", + } + } +} + +/// A concrete JS syntax feature detected in a bundle, used as evidence for the +/// derived [`EsLevel`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EsFeature { + LetConst, + Arrow, + TemplateLiteral, + Class, + Spread, + Exponent, + AsyncAwait, + OptionalChaining, + NullishCoalescing, +} + +impl EsFeature { + /// The minimum ES level this feature requires. + pub fn level(self) -> EsLevel { + match self { + EsFeature::LetConst + | EsFeature::Arrow + | EsFeature::TemplateLiteral + | EsFeature::Class + | EsFeature::Spread => EsLevel::Es2015, + EsFeature::Exponent => EsLevel::Es2016, + EsFeature::AsyncAwait => EsLevel::Es2017, + EsFeature::OptionalChaining | EsFeature::NullishCoalescing => EsLevel::Es2020, + } + } + + pub fn label(self) -> &'static str { + match self { + EsFeature::LetConst => "let/const", + EsFeature::Arrow => "arrow function", + EsFeature::TemplateLiteral => "template literal", + EsFeature::Class => "class", + EsFeature::Spread => "spread/rest", + EsFeature::Exponent => "exponent (**)", + EsFeature::AsyncAwait => "async/await", + EsFeature::OptionalChaining => "optional chaining (?.)", + EsFeature::NullishCoalescing => "nullish coalescing (??)", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chromium_major_round_trips_to_level() { + assert_eq!(EsLevel::from_chromium_major(120), EsLevel::Es2021Plus); + assert_eq!(EsLevel::from_chromium_major(80), EsLevel::Es2020); + assert_eq!(EsLevel::from_chromium_major(55), EsLevel::Es2017); + assert_eq!(EsLevel::from_chromium_major(49), EsLevel::Es2015); + // WebKit 537-era / Chromium 38 predate ES2015. + assert_eq!(EsLevel::from_chromium_major(38), EsLevel::Es5); + assert_eq!(EsLevel::from_chromium_major(0), EsLevel::Es5); + } + + #[test] + fn feature_levels_order() { + assert!(EsFeature::OptionalChaining.level() > EsFeature::AsyncAwait.level()); + assert!(EsFeature::AsyncAwait.level() > EsFeature::Arrow.level()); + } +} diff --git a/common/webdetect/src/lib.rs b/common/webdetect/src/lib.rs new file mode 100644 index 00000000..b2539584 --- /dev/null +++ b/common/webdetect/src/lib.rs @@ -0,0 +1,83 @@ +//! Web-app and Node-service technology detection for webOS packages. +//! +//! Given the unpacked directory of a non-native component, this crate reports +//! which frontend framework a web app ships and what JavaScript syntax level +//! its bundle requires ([`detect_web_app`]), and what Node.js runtime a service +//! declares ([`detect_service_runtime`]). It is pure text/JSON analysis with no +//! ELF, ipk or firmware knowledge so it can be unit-tested in isolation. + +use semver::{Version, VersionReq}; + +mod eslevel; +mod service; +mod web; + +pub use eslevel::{EsFeature, EsLevel}; +pub use service::detect_service_runtime; +pub use web::detect_web_app; + +/// What was detected about a web/frontend app. +#[derive(Debug, Clone)] +pub struct WebAppDetection { + /// The primary UI framework (always `Some`; `PlainHtml` when none matched). + pub framework: Option, + /// Other frameworks/libraries also present (e.g. jQuery alongside Enact). + pub also_present: Vec, + /// webOSTV.js SDK: `None` absent, `Some(None)` present with unknown version, + /// `Some(Some(v))` present with a detected version. + pub webostvjs: Option>, + /// Minimum ES level the shipped bundle requires of the engine. + pub es_level: Option, + /// The syntax features that evidence `es_level`. + pub es_features: Vec, +} + +#[derive(Debug, Clone)] +pub struct FrameworkInfo { + pub kind: FrameworkKind, + pub version: Option, +} + +impl FrameworkInfo { + pub fn new(kind: FrameworkKind, version: Option) -> Self { + FrameworkInfo { kind, version } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameworkKind { + Enact, + React, + Vue, + Angular, + AngularJs, + Jquery, + WebOsTvJs, + PlainHtml, +} + +impl FrameworkKind { + pub fn label(self) -> &'static str { + match self { + FrameworkKind::Enact => "Enact", + FrameworkKind::React => "React", + FrameworkKind::Vue => "Vue", + FrameworkKind::Angular => "Angular", + FrameworkKind::AngularJs => "AngularJS", + FrameworkKind::Jquery => "jQuery", + FrameworkKind::WebOsTvJs => "webOSTV.js", + FrameworkKind::PlainHtml => "Plain HTML/JS", + } + } +} + +/// What was detected about a Node.js/JS service from its `package.json`. +#[derive(Debug, Clone, Default)] +pub struct ServiceRuntimeDetection { + /// `engines.node` parsed as a semver requirement, when present and valid. + pub declared_node: Option, + /// `dependencies` as (name, version-spec) pairs, sorted by name. + pub dependencies: Vec<(String, String)>, + /// The `main` entry point, if declared. + pub main: Option, +} diff --git a/common/webdetect/src/service.rs b/common/webdetect/src/service.rs new file mode 100644 index 00000000..b49d258b --- /dev/null +++ b/common/webdetect/src/service.rs @@ -0,0 +1,89 @@ +//! Node.js service runtime detection from a bundled `package.json`. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use semver::VersionReq; +use serde::Deserialize; + +use crate::ServiceRuntimeDetection; + +#[derive(Debug, Default, Deserialize)] +struct PackageJson { + #[serde(default)] + main: Option, + #[serde(default)] + engines: Engines, + #[serde(default)] + dependencies: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct Engines { + #[serde(default)] + node: Option, +} + +/// Read `/package.json` and extract the declared Node.js requirement, +/// dependencies and entry point. Returns an empty detection when there is no +/// parseable `package.json` (e.g. a QML or non-JS service). +pub fn detect_service_runtime(dir: &Path) -> ServiceRuntimeDetection { + let text = fs::read_to_string(dir.join("package.json")).unwrap_or_default(); + let pkg: PackageJson = serde_json::from_str(&text).unwrap_or_default(); + + // `engines.node` is a semver range like ">=12.0.0"; parse best-effort. + let declared_node = pkg + .engines + .node + .as_deref() + .and_then(|s| VersionReq::parse(s).ok()); + + ServiceRuntimeDetection { + declared_node, + dependencies: pkg.dependencies.into_iter().collect(), + main: pkg.main, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_pkg(body: &str) -> tempfile::TempDir { + let dir = tempfile::TempDir::new().unwrap(); + let mut f = fs::File::create(dir.path().join("package.json")).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + dir + } + + #[test] + fn parses_engines_and_deps() { + let dir = write_pkg( + r#"{ "main": "service.js", "engines": { "node": ">=12.0.0" }, + "dependencies": { "express": "^4.18.0", "lodash": "4.17.21" } }"#, + ); + let d = detect_service_runtime(dir.path()); + assert_eq!(d.main.as_deref(), Some("service.js")); + assert!(d.declared_node.unwrap().matches(&semver::Version::parse("14.0.0").unwrap())); + assert_eq!(d.dependencies.len(), 2); + assert_eq!(d.dependencies[0].0, "express"); + } + + #[test] + fn no_engines_declared() { + let dir = write_pkg(r#"{ "main": "index.js" }"#); + let d = detect_service_runtime(dir.path()); + assert!(d.declared_node.is_none()); + assert!(d.dependencies.is_empty()); + } + + #[test] + fn missing_package_json_is_empty() { + let dir = tempfile::TempDir::new().unwrap(); + let d = detect_service_runtime(dir.path()); + assert!(d.declared_node.is_none()); + assert!(d.main.is_none()); + } +} diff --git a/common/webdetect/src/web.rs b/common/webdetect/src/web.rs new file mode 100644 index 00000000..7e9f8e94 --- /dev/null +++ b/common/webdetect/src/web.rs @@ -0,0 +1,350 @@ +//! Frontend framework and JavaScript syntax-level detection for a web app. +//! +//! Heuristics operate on the shipped `index.html` and the bundle `*.js` files. +//! They are intentionally conservative: regex on JS cannot tell code from +//! strings, so the ES-level result is treated as a floor ("the engine must +//! support at least this"), and framework versions are only reported when a +//! reliable signal (license banner, `ng-version` attribute) is present. + +use std::fs; +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; +use semver::Version; + +use crate::eslevel::{EsFeature, EsLevel}; +use crate::{FrameworkInfo, FrameworkKind, WebAppDetection}; + +/// Skip individual files larger than this (minified vendor blobs aside, real +/// app code is far smaller); keeps a pathological package from blowing memory. +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; + +/// Detect the framework, webOSTV.js SDK and ES syntax level of the web app +/// rooted at `dir`, whose HTML entry point is `index_html`. +pub fn detect_web_app(dir: &Path, index_html: &Path) -> WebAppDetection { + let html = fs::read_to_string(index_html).unwrap_or_default(); + + let mut js: Vec<(String, String)> = Vec::new(); + collect_js(dir, 0, &mut js); + + let (framework, also_present, webostvjs) = detect_frameworks(&html, &js); + let (es_level, es_features) = detect_es(&js); + + WebAppDetection { + framework, + also_present, + webostvjs, + es_level, + es_features, + } +} + +/// Recursively gather `*.js` file contents (skipping source maps), bounded by +/// the depth/count/size caps above. +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(".min.js.map") || 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())); + } + } +} + +macro_rules! re { + ($name:ident, $pat:expr) => { + static $name: LazyLock = LazyLock::new(|| Regex::new($pat).unwrap()); + }; +} + +// Framework signatures. +re!(RE_NG_VERSION, r#"ng-version="([0-9]+\.[0-9]+\.[0-9]+)""#); +re!(RE_ANGULARJS, r#"(?:angular\.version|"full"\s*:\s*")\s*[:=]?\s*"?(1\.[0-9]+\.[0-9]+)"#); +re!(RE_REACT_BANNER, r"@license React v?([0-9]+\.[0-9]+\.[0-9]+)"); +re!(RE_REACT_PRESENT, r"React\.createElement|react-dom(?:\.production)?(?:\.min)?\.js|__reactContainer|data-reactroot"); +re!(RE_VUE_V2, r"Vue\.js v([0-9]+\.[0-9]+\.[0-9]+)"); +re!(RE_VUE_V3, r"@vue/(?:runtime|shared|reactivity)[^\n]{0,40}?v([0-9]+\.[0-9]+\.[0-9]+)"); +re!(RE_VUE_PRESENT, r"__vue__|createElementVNode|Vue\.createApp|\bVue\b"); +re!(RE_JQUERY, r"jQuery(?: JavaScript Library)? v([0-9]+\.[0-9]+\.[0-9]+)"); +re!(RE_JQUERY_PRESENT, r"jquery|jQuery"); +re!(RE_ENACT, r"@enact/|enactVersion|enact_dev|enactMeta"); +re!(RE_WEBOSTV, r"(?i)webOSTV(?:-dev)?\.js"); +re!(RE_WEBOSTV_VER, r"webOSTV(?:-dev)?\.js\s*(?:v(?:ersion)?)?\s*([0-9]+\.[0-9]+\.[0-9]+)"); + +/// Returns (primary framework, other frameworks present, webOSTV.js presence). +/// +/// `webostvjs`: `None` = absent, `Some(None)` = present (version unknown), +/// `Some(Some(v))` = present with a detected version. +fn detect_frameworks( + html: &str, + js: &[(String, String)], +) -> (Option, Vec, Option>) { + // Search the HTML plus every JS file/name. `haystacks` is scanned for + // content signatures; `names` for filename signatures. + let mut found: Vec = Vec::new(); + + let cap_ver = |re: &Regex, hay: &str| -> Option { + re.captures(hay) + .and_then(|c| c.get(1)) + .and_then(|m| Version::parse(m.as_str()).ok()) + }; + + // Angular (2+): the ng-version attribute lives in index.html and is exact. + if let Some(v) = cap_ver(&RE_NG_VERSION, html) { + found.push(FrameworkInfo::new(FrameworkKind::Angular, Some(v))); + } + + // Everything else: scan HTML + JS contents. `scan` returns the detection + // instead of capturing `found`, so its borrow ends at each call. + let scan = |kind: FrameworkKind, + present: &Regex, + version: Option<&Regex>| + -> Option { + let mut version_found: Option = None; + let mut present_found = false; + for hay in std::iter::once(html).chain(js.iter().map(|(_, c)| c.as_str())) { + if let Some(re) = version { + if version_found.is_none() { + version_found = cap_ver(re, hay); + } + } + if !present_found && present.is_match(hay) { + present_found = true; + } + if present_found && (version.is_none() || version_found.is_some()) { + break; + } + } + if present_found || version_found.is_some() { + Some(FrameworkInfo::new(kind, version_found)) + } else { + None + } + }; + + if let Some(f) = scan(FrameworkKind::React, &RE_REACT_PRESENT, Some(&RE_REACT_BANNER)) { + found.push(f); + } + if let Some(mut vue) = scan(FrameworkKind::Vue, &RE_VUE_PRESENT, Some(&RE_VUE_V2)) { + // Vue 3's banner is a separate pattern; fold its version in if v2 missed it. + if vue.version.is_none() { + vue.version = js.iter().find_map(|(_, c)| cap_ver(&RE_VUE_V3, c)); + } + found.push(vue); + } + if let Some(f) = scan(FrameworkKind::AngularJs, &RE_ANGULARJS, Some(&RE_ANGULARJS)) { + found.push(f); + } + if let Some(f) = scan(FrameworkKind::Jquery, &RE_JQUERY_PRESENT, Some(&RE_JQUERY)) { + found.push(f); + } + + // Enact (LG's React-based framework). Version is not reliably in the bundle. + let enact = std::iter::once(html) + .chain(js.iter().map(|(_, c)| c.as_str())) + .any(|h| RE_ENACT.is_match(h)); + if enact { + found.push(FrameworkInfo::new(FrameworkKind::Enact, None)); + } + + // webOSTV.js SDK — an app can use it alongside any framework. + let webostvjs = { + let by_name = js.iter().any(|(n, _)| RE_WEBOSTV.is_match(n)); + let by_content = std::iter::once(html) + .chain(js.iter().map(|(_, c)| c.as_str())) + .any(|h| RE_WEBOSTV.is_match(h)); + if by_name || by_content { + let ver = std::iter::once(html) + .chain(js.iter().map(|(_, c)| c.as_str())) + .find_map(|h| cap_ver(&RE_WEBOSTV_VER, h)); + Some(ver) + } else { + None + } + }; + + // Pick the primary by precedence; the rest become `also_present`. + let precedence = [ + FrameworkKind::Enact, + FrameworkKind::Angular, + FrameworkKind::React, + FrameworkKind::Vue, + FrameworkKind::AngularJs, + FrameworkKind::Jquery, + ]; + let mut primary: Option = None; + for kind in precedence { + if let Some(pos) = found.iter().position(|f| f.kind == kind) { + primary = Some(found.remove(pos)); + break; + } + } + + let primary = Some(primary.unwrap_or_else(|| FrameworkInfo::new(FrameworkKind::PlainHtml, None))); + (primary, found, webostvjs) +} + +// ES feature signatures. Deliberately narrow to limit false positives on +// strings/comments in minified code. +re!(RE_LET_CONST, r"\b(?:let|const)\s"); +re!(RE_ARROW, r"=>"); +re!(RE_TEMPLATE, r"`"); +re!(RE_CLASS, r"\bclass\s+[A-Za-z_$]"); +re!(RE_SPREAD, r"\.\.\."); +// Require operand context (`a ** b`) so JSDoc `/**` banners don't false-match. +re!(RE_EXPONENT, r"[A-Za-z0-9_)\]]\s*\*\*\s*[A-Za-z0-9_($]"); +re!(RE_ASYNC, r"\b(?:async|await)\b"); +re!(RE_OPTIONAL_CHAIN, r"\?\."); +re!(RE_NULLISH, r"\?\?"); + +/// Scan the bundle for JS syntax features and derive the minimum ES level. +fn detect_es(js: &[(String, String)]) -> (Option, Vec) { + if js.is_empty() { + return (None, Vec::new()); + } + let checks: [(&LazyLock, EsFeature); 9] = [ + (&RE_LET_CONST, EsFeature::LetConst), + (&RE_ARROW, EsFeature::Arrow), + (&RE_TEMPLATE, EsFeature::TemplateLiteral), + (&RE_CLASS, EsFeature::Class), + (&RE_SPREAD, EsFeature::Spread), + (&RE_EXPONENT, EsFeature::Exponent), + (&RE_ASYNC, EsFeature::AsyncAwait), + (&RE_OPTIONAL_CHAIN, EsFeature::OptionalChaining), + (&RE_NULLISH, EsFeature::NullishCoalescing), + ]; + let mut features: Vec = Vec::new(); + for (re, feature) in checks { + if js.iter().any(|(_, c)| re.is_match(c)) { + features.push(feature); + } + } + let level = features + .iter() + .map(|f| f.level()) + .max() + .unwrap_or(EsLevel::Es5); + (Some(level), features) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn js(content: &str) -> Vec<(String, String)> { + vec![("bundle.js".to_string(), content.to_string())] + } + + #[test] + fn detects_angular_version_from_html() { + let (primary, _others, _tv) = + detect_frameworks(r#""#, &[]); + let f = primary.unwrap(); + assert_eq!(f.kind, FrameworkKind::Angular); + assert_eq!(f.version, Some(Version::parse("15.2.9").unwrap())); + } + + #[test] + fn detects_react_from_license_banner() { + let (primary, _o, _t) = detect_frameworks( + "", + &js("/** @license React v18.2.0 */ React.createElement('div')"), + ); + let f = primary.unwrap(); + assert_eq!(f.kind, FrameworkKind::React); + assert_eq!(f.version, Some(Version::parse("18.2.0").unwrap())); + } + + #[test] + fn detects_jquery_version() { + let (primary, _o, _t) = + detect_frameworks("", &js("/*! jQuery JavaScript Library v3.6.0 */ jQuery(function(){})")); + let f = primary.unwrap(); + assert_eq!(f.kind, FrameworkKind::Jquery); + assert_eq!(f.version, Some(Version::parse("3.6.0").unwrap())); + } + + #[test] + fn enact_takes_precedence_over_react() { + // Enact bundles are React under the hood; the primary should be Enact. + let (primary, others, _t) = detect_frameworks( + "", + &js("import '@enact/core'; /** @license React v18.2.0 */ React.createElement"), + ); + assert_eq!(primary.unwrap().kind, FrameworkKind::Enact); + assert!(others.iter().any(|f| f.kind == FrameworkKind::React)); + } + + #[test] + fn detects_webostvjs() { + let files = vec![("webOSTV.js".to_string(), "var webOS = {};".to_string())]; + let (_p, _o, tv) = detect_frameworks("", &files); + assert!(tv.is_some()); + } + + #[test] + fn plain_html_when_nothing_matches() { + let (primary, others, tv) = detect_frameworks("hi", &[]); + assert_eq!(primary.unwrap().kind, FrameworkKind::PlainHtml); + assert!(others.is_empty()); + assert!(tv.is_none()); + } + + #[test] + fn es_level_is_max_feature() { + let (level, feats) = detect_es(&js("const x = a?.b; let y = async () => await z;")); + assert_eq!(level, Some(EsLevel::Es2020)); // optional chaining + assert!(feats.contains(&EsFeature::OptionalChaining)); + assert!(feats.contains(&EsFeature::AsyncAwait)); + assert!(feats.contains(&EsFeature::Arrow)); + } + + #[test] + fn es5_bundle_reads_as_es5() { + let (level, feats) = detect_es(&js("var x = 1; function f() { return x; }")); + assert_eq!(level, Some(EsLevel::Es5)); + assert!(feats.is_empty()); + } + + #[test] + fn jsdoc_banner_is_not_an_exponent() { + // A `/** ... */` license banner must not be read as the `**` operator. + let (level, feats) = detect_es(&js("/** @license v1 */ var x = 1;")); + assert_eq!(level, Some(EsLevel::Es5)); + assert!(!feats.contains(&EsFeature::Exponent)); + } + + #[test] + fn real_exponent_is_detected() { + let (_level, feats) = detect_es(&js("var y = a ** 2;")); + assert!(feats.contains(&EsFeature::Exponent)); + } +} diff --git a/packages/ipk-verify/Cargo.toml b/packages/ipk-verify/Cargo.toml index 6a315d79..1863f748 100644 --- a/packages/ipk-verify/Cargo.toml +++ b/packages/ipk-verify/Cargo.toml @@ -32,6 +32,9 @@ path = "../../common/bin" [dependencies.ipk-lib] path = "../../common/ipk" +[dependencies.webdetect-lib] +path = "../../common/webdetect" + [features] linux-install = ["fw-lib/linux-install"] diff --git a/packages/ipk-verify/src/main.rs b/packages/ipk-verify/src/main.rs index 3e1b44f5..5c5f5607 100644 --- a/packages/ipk-verify/src/main.rs +++ b/packages/ipk-verify/src/main.rs @@ -12,8 +12,12 @@ use semver::VersionReq; use fw_lib::Firmware; use ipk_lib::Package; use verify_lib::bin::BinVerifyResult; -use verify_lib::ipk::{ComponentBinVerifyResult, ComponentVerifyResult, PackageVerifyResult}; -use verify_lib::{Verify, VerifyResult}; +use verify_lib::ipk::{ + ComponentBinVerifyResult, ComponentVerifyResult, CompatVerdict, DetectionResult, + PackageVerifyResult, VerifyForFirmware, +}; +use verify_lib::VerifyResult; +use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection}; use crate::output::ReportOutput; @@ -96,7 +100,11 @@ fn main() { let results: Vec<(&Firmware, PackageVerifyResult)> = firmwares .iter() .map(|fw| { - let verify = package.verify(&|name| fw.find_library(name)); + let verify = package.verify_for_firmware( + &|name| fw.find_library(name), + fw.node_version().as_ref(), + fw.web_engine().as_ref(), + ); return (fw, verify); }) .collect(); @@ -166,6 +174,9 @@ fn print_component_summary( out_fmt: &OutputFormat, ) -> Result<(), Error> { let (_, result) = *results.first().unwrap(); + if result.detection.is_some() { + return print_detection_summary(&results, out, out_fmt); + } if let ComponentBinVerifyResult::Skipped { .. } = &result.exe { out.write_fmt(format_args!("Skip because this component is not native\n"))?; return Ok(()); @@ -213,6 +224,10 @@ fn print_component_details( out: &mut Box, ) -> Result { let (_, result) = *results.first().unwrap(); + if result.detection.is_some() { + print_detection_details(&results, out)?; + return Ok(results.iter().all(|r| r.1.is_good())); + } out.h4(result.exe.name())?; if results.iter().all(|r| r.1.is_good()) { out.write_fmt(format_args!("All OK\n"))?; @@ -262,3 +277,191 @@ fn print_bin_verify_details( } return Ok(()); } + +/// Render the summary for a non-native component: the firmware-independent +/// detected technology as a text line, then a per-firmware compatibility table. +fn print_detection_summary( + results: &Vec<(&Firmware, &ComponentVerifyResult)>, + out: &mut Box, + out_fmt: &OutputFormat, +) -> Result<(), Error> { + let (_, first) = results.first().unwrap(); + let detection = first.detection.as_ref().unwrap(); + + let mut table = Table::new(); + table.set_format(out.table_format(out_fmt)); + table.set_titles(Row::from_iter(iter::once(String::new()).chain( + results.iter().map(|(fw, _)| fw.info.release.to_string()), + ))); + + match detection { + DetectionResult::WebApp { detection: web, .. } => { + out.write_fmt(format_args!("Web app — {}\n\n", describe_web(web)))?; + // Each firmware's web engine. + table.add_row(Row::new( + iter::once(Cell::new("Web engine")) + .chain(results.iter().map(|(_, r)| Cell::new(&web_engine_label(r)))) + .collect(), + )); + // Whether that engine supports the app's ES level. + let title = match web.es_level { + Some(level) => format!("{} support", level.label()), + None => "ES support".to_string(), + }; + table.add_row(Row::new( + iter::once(Cell::new(&title)) + .chain( + results + .iter() + .map(|(_, r)| out.verdict_cell(component_verdict(r), out_fmt)), + ) + .collect(), + )); + } + DetectionResult::Service { detection: svc, .. } => { + out.write_fmt(format_args!("JS service — {}\n\n", describe_service(svc)))?; + // Each firmware's Node.js version. + table.add_row(Row::new( + iter::once(Cell::new("Node.js")) + .chain(results.iter().map(|(_, r)| Cell::new(&node_label(r)))) + .collect(), + )); + table.add_row(Row::new( + iter::once(Cell::new("Node support")) + .chain( + results + .iter() + .map(|(_, r)| out.verdict_cell(component_verdict(r), out_fmt)), + ) + .collect(), + )); + } + } + out.print_table(&table)?; + return Ok(()); +} + +/// Render `--details` for a non-native component: syntax-feature / dependency +/// evidence, then any firmware on which it is incompatible and why. +fn print_detection_details( + results: &Vec<(&Firmware, &ComponentVerifyResult)>, + out: &mut Box, +) -> Result<(), Error> { + let (_, first) = results.first().unwrap(); + let detection = first.detection.as_ref().unwrap(); + match detection { + DetectionResult::WebApp { detection: web, .. } => { + out.h4("Web app")?; + if let Some(fw) = &web.framework { + out.write_fmt(format_args!("* Framework: {}\n", framework_label(fw)))?; + } + for other in &web.also_present { + out.write_fmt(format_args!("* Also present: {}\n", framework_label(other)))?; + } + if !web.es_features.is_empty() { + let feats: Vec<&str> = web.es_features.iter().map(|f| f.label()).collect(); + out.write_fmt(format_args!("* JS syntax used: {}\n", feats.join(", ")))?; + } + } + DetectionResult::Service { detection: svc, .. } => { + out.h4("JS service")?; + if let Some(req) = &svc.declared_node { + out.write_fmt(format_args!("* Requires Node.js {req}\n"))?; + } + for (name, ver) in &svc.dependencies { + out.write_fmt(format_args!("* Dependency: {name} {ver}\n"))?; + } + } + } + // Report incompatible firmwares with their reason. + let mut any_fail = false; + for (fw, r) in results { + if let Some(detection) = &r.detection { + if let CompatVerdict::Fail { reason } = detection.verdict() { + if !any_fail { + out.h5("Incompatible on")?; + any_fail = true; + } + out.write_fmt(format_args!("* {}: {reason}\n", fw.info))?; + } + } + } + out.write_fmt(format_args!("\n"))?; + return Ok(()); +} + +fn framework_label(fw: &webdetect_lib::FrameworkInfo) -> String { + match &fw.version { + Some(v) => format!("{} {}", fw.kind.label(), v), + None => fw.kind.label().to_string(), + } +} + +/// One-line description of the detected web app (framework, SDK, ES level). +fn describe_web(web: &WebAppDetection) -> String { + let mut parts: Vec = Vec::new(); + if let Some(fw) = &web.framework { + parts.push(framework_label(fw)); + } + for other in &web.also_present { + parts.push(format!("+ {}", framework_label(other))); + } + if let Some(tv) = &web.webostvjs { + parts.push(match tv { + Some(v) => format!("webOSTV.js {v}"), + None => "webOSTV.js".to_string(), + }); + } + if let Some(level) = web.es_level { + parts.push(format!("requires {}", level.label())); + } + if parts.is_empty() { + "no framework detected".to_string() + } else { + parts.join("; ") + } +} + +/// One-line description of the detected JS service (Node requirement, deps). +fn describe_service(svc: &ServiceRuntimeDetection) -> String { + let mut parts: Vec = Vec::new(); + match &svc.declared_node { + Some(req) => parts.push(format!("requires Node.js {req}")), + None => parts.push("no engines.node declared".to_string()), + } + match svc.dependencies.len() { + 0 => {} + 1 => parts.push("1 dependency".to_string()), + n => parts.push(format!("{n} dependencies")), + } + parts.join("; ") +} + +/// The compatibility verdict for a component result (Unknown if absent). +fn component_verdict(result: &ComponentVerifyResult) -> &CompatVerdict { + result + .detection + .as_ref() + .map(|d| d.verdict()) + .unwrap_or(&CompatVerdict::Unknown) +} + +fn web_engine_label(result: &ComponentVerifyResult) -> String { + match &result.detection { + Some(DetectionResult::WebApp { + engine: Some(engine), + .. + }) => engine.label(), + _ => "unknown".to_string(), + } +} + +fn node_label(result: &ComponentVerifyResult) -> String { + match &result.detection { + Some(DetectionResult::Service { + available_node: Some(v), + .. + }) => format!("Node {v}"), + _ => "unknown".to_string(), + } +} diff --git a/packages/ipk-verify/src/output.rs b/packages/ipk-verify/src/output.rs index d028bcc9..e426a0d3 100644 --- a/packages/ipk-verify/src/output.rs +++ b/packages/ipk-verify/src/output.rs @@ -5,7 +5,7 @@ use prettytable::format::{FormatBuilder, LinePosition, LineSeparator, TableForma use prettytable::{Cell, Table}; use term::{color, Attr}; -use verify_lib::ipk::ComponentBinVerifyResult; +use verify_lib::ipk::{ComponentBinVerifyResult, CompatVerdict}; use crate::OutputFormat; @@ -34,6 +34,35 @@ pub trait PrintTable { }; } + /// Cell for a non-native compatibility verdict, styled like `result_cell`. + fn verdict_cell(&self, verdict: &CompatVerdict, out_fmt: &OutputFormat) -> Cell { + return match verdict { + CompatVerdict::Ok => { + let mut cell = Cell::new(if *out_fmt == OutputFormat::Markdown { + ":ok:" + } else { + "OK" + }); + cell.style(Attr::ForegroundColor(color::BRIGHT_GREEN)); + cell + } + CompatVerdict::Fail { .. } => { + let mut cell = Cell::new(if *out_fmt == OutputFormat::Markdown { + ":x:" + } else { + "FAIL" + }); + cell.style(Attr::ForegroundColor(color::BRIGHT_RED)); + cell + } + CompatVerdict::Unknown => Cell::new(if *out_fmt == OutputFormat::Markdown { + ":grey_question:" + } else { + "UNKNOWN" + }), + }; + } + fn table_format(&self, out_fmt: &OutputFormat) -> TableFormat { match out_fmt { OutputFormat::Markdown => FormatBuilder::new() diff --git a/packages/ipk-verify/tests/detection.rs b/packages/ipk-verify/tests/detection.rs new file mode 100644 index 00000000..6b2047dd --- /dev/null +++ b/packages/ipk-verify/tests/detection.rs @@ -0,0 +1,134 @@ +//! Tests for non-native technology compatibility verdicts. +//! +//! These exercise `Package::verify_for_firmware` end-to-end from parsed +//! detection facts to a per-firmware `CompatVerdict` and the `is_good` exit +//! signal, without needing a real `.ipk` on disk. + +use bin_lib::LibraryInfo; +use fw_lib::WebEngine; +use ipk_lib::{AppInfo, Component, Package, ServiceInfo}; +use semver::{Version, VersionReq}; +use verify_lib::ipk::{CompatVerdict, VerifyForFirmware}; +use verify_lib::VerifyResult; +use webdetect_lib::{ + EsLevel, FrameworkInfo, FrameworkKind, ServiceRuntimeDetection, WebAppDetection, +}; + +fn web_app(es: EsLevel) -> Component { + Component { + id: "com.example.app".to_string(), + info: AppInfo { + id: "com.example.app".to_string(), + version: "1.0.0".to_string(), + r#type: "web".to_string(), + title: "Example".to_string(), + app_description: None, + main: "index.html".to_string(), + web: Some(WebAppDetection { + framework: Some(FrameworkInfo::new( + FrameworkKind::React, + Version::parse("18.2.0").ok(), + )), + also_present: vec![], + webostvjs: None, + es_level: Some(es), + es_features: vec![], + }), + }, + exe: None, + libs: vec![], + } +} + +fn node_service(id: &str, req: &str) -> Component { + Component { + id: id.to_string(), + info: ServiceInfo { + id: id.to_string(), + engine: Some("node".to_string()), + executable: None, + runtime: Some(ServiceRuntimeDetection { + declared_node: VersionReq::parse(req).ok(), + dependencies: vec![], + main: None, + }), + }, + exe: None, + libs: vec![], + } +} + +fn package(app: Component, services: Vec>) -> Package { + Package { + id: "com.example.app".to_string(), + installed_size: None, + app, + services, + } +} + +fn no_libs(_: &str) -> Option { + None +} + +#[test] +fn web_app_es_level_checked_against_engine() { + let pkg = package(web_app(EsLevel::Es2017), vec![]); + + // Chromium 120 supports ES2017 → OK, component is good. + let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::Chromium(Version::new(120, 0, 0)))); + assert_eq!(r.app.detection.as_ref().unwrap().verdict(), &CompatVerdict::Ok); + assert!(r.app.is_good()); + + // Chromium 53 (webOS 4) predates async/await → FAIL, component not good. + let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::Chromium(Version::new(53, 0, 2785)))); + assert!(matches!( + r.app.detection.as_ref().unwrap().verdict(), + CompatVerdict::Fail { .. } + )); + assert!(!r.app.is_good()); + + // Legacy WebKit engine → FAIL. + let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::WebKit(Version::new(537, 41, 0)))); + assert!(matches!( + r.app.detection.as_ref().unwrap().verdict(), + CompatVerdict::Fail { .. } + )); + + // Firmware with no known web engine → Unknown, which must NOT fail the build. + let r = pkg.verify_for_firmware(&no_libs, None, None); + assert_eq!(r.app.detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); + assert!(r.app.is_good()); +} + +#[test] +fn multiple_services_each_get_their_own_node_verdict() { + let pkg = package( + web_app(EsLevel::Es5), + vec![ + node_service("com.example.app.svc1", ">=12.0.0"), + node_service("com.example.app.svc2", ">=99.0.0"), + ], + ); + + // webOS 10.2 ships Node 16.20.2. + let r = pkg.verify_for_firmware( + &no_libs, + Some(&Version::new(16, 20, 2)), + Some(&WebEngine::Chromium(Version::new(120, 0, 0))), + ); + assert_eq!(r.services.len(), 2); + assert_eq!(r.services[0].detection.as_ref().unwrap().verdict(), &CompatVerdict::Ok); + assert!(r.services[0].is_good()); + assert!(matches!( + r.services[1].detection.as_ref().unwrap().verdict(), + CompatVerdict::Fail { .. } + )); + assert!(!r.services[1].is_good()); + + // No Node package on the firmware → Unknown for both, never a fail. + let r = pkg.verify_for_firmware(&no_libs, None, None); + assert_eq!(r.services[0].detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); + assert_eq!(r.services[1].detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); + assert!(r.services[1].is_good()); +} From 764535984848f4038b3599e7b59156b5ed799da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:21:13 +0000 Subject: [PATCH 2/6] webdetect: add HTML-level checks (script type=module, remote resources) The JS scan misses two compat-relevant signals that live only in index.html. Detect "#)); + assert!(RE_SCRIPT_MODULE.is_match(r#""#)); + assert!(!RE_SCRIPT_MODULE.is_match(r#""#)); + } + + #[test] + fn collects_remote_resources_deduped() { + let html = r#" + + + + + "#; + let remotes = detect_remote_resources(html); + assert_eq!(remotes.len(), 2); // local bundle.js excluded, duplicate deduped + assert!(remotes.iter().any(|u| u.contains("cdn.example.com/lib.js"))); + assert!(remotes.iter().any(|u| u.starts_with("//fonts.example.com"))); + } + + #[test] + fn no_remote_resources_when_all_local() { + let remotes = detect_remote_resources( + r#""#, + ); + assert!(remotes.is_empty()); + } } diff --git a/packages/ipk-verify/src/main.rs b/packages/ipk-verify/src/main.rs index 5c5f5607..fc54c962 100644 --- a/packages/ipk-verify/src/main.rs +++ b/packages/ipk-verify/src/main.rs @@ -360,7 +360,10 @@ fn print_detection_details( } if !web.es_features.is_empty() { let feats: Vec<&str> = web.es_features.iter().map(|f| f.label()).collect(); - out.write_fmt(format_args!("* JS syntax used: {}\n", feats.join(", ")))?; + out.write_fmt(format_args!("* Language features used: {}\n", feats.join(", ")))?; + } + for url in &web.remote_resources { + out.write_fmt(format_args!("* Remote resource: {url}\n"))?; } } DetectionResult::Service { detection: svc, .. } => { @@ -415,6 +418,11 @@ fn describe_web(web: &WebAppDetection) -> String { if let Some(level) = web.es_level { parts.push(format!("requires {}", level.label())); } + match web.remote_resources.len() { + 0 => {} + 1 => parts.push("loads 1 remote resource".to_string()), + n => parts.push(format!("loads {n} remote resources")), + } if parts.is_empty() { "no framework detected".to_string() } else { diff --git a/packages/ipk-verify/tests/detection.rs b/packages/ipk-verify/tests/detection.rs index 6b2047dd..48035265 100644 --- a/packages/ipk-verify/tests/detection.rs +++ b/packages/ipk-verify/tests/detection.rs @@ -33,6 +33,7 @@ fn web_app(es: EsLevel) -> Component { webostvjs: None, es_level: Some(es), es_features: vec![], + remote_resources: vec![], }), }, exe: None, From 3d4b987a0ae32aab0dbedebd9629bd6b816ba891 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:33:11 +0000 Subject: [PATCH 3/6] ipk: guard against path traversal in untrusted package metadata A .ipk is attacker-controlled. Several path fragments from its metadata were joined onto the extraction directory and then opened without a containment check: the app id and service ids from packageinfo.json, and the main/executable entry from appinfo.json/services.json. A value like "../../../../etc/passwd" or "/dev/zero" would make the verifier open a file outside the package (a device read is an easy DoS; tar's unpack_in only guards the extraction writes, not these later reads). Add a lexical path-containment guard (ensure_within) in ipk-lib and apply it at every join site. Traversal is now rejected before the file is opened, with a clear error, so the malicious path is never read. Also cap the index.html read in webdetect to MAX_FILE_BYTES so a pathological entry can't exhaust memory. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3 --- common/ipk/src/component.rs | 10 +++-- common/ipk/src/ipk.rs | 29 ++++++++------ common/ipk/src/lib.rs | 1 + common/ipk/src/path.rs | 77 +++++++++++++++++++++++++++++++++++++ common/webdetect/src/web.rs | 11 +++++- 5 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 common/ipk/src/path.rs diff --git a/common/ipk/src/component.rs b/common/ipk/src/component.rs index 04eeb86d..66082472 100644 --- a/common/ipk/src/component.rs +++ b/common/ipk/src/component.rs @@ -10,6 +10,7 @@ use path_slash::CowExt; use bin_lib::{BinaryInfo, LibraryInfo, LibraryPriority}; +use crate::path::ensure_within; use crate::{AppInfo, Component, ServiceInfo, Symlinks}; impl AppInfo { @@ -43,7 +44,8 @@ impl Component { if !info.is_native() { // Web/hosted app: detect the frontend framework and JS syntax level // from the shipped HTML/JS while the extracted files still exist. - let index_html = dir.join(Cow::from_slash(&info.main)); + // `main` is untrusted; keep it inside the app directory. + let index_html = ensure_within(dir, &dir.join(Cow::from_slash(&info.main)))?; let mut info = info; info.web = Some(webdetect_lib::detect_web_app(dir, &index_html)); return Ok(Self { @@ -53,7 +55,7 @@ impl Component { libs: Default::default(), }); } - let exe_path = dir.join(Cow::from_slash(&info.main)); + let exe_path = ensure_within(dir, &dir.join(Cow::from_slash(&info.main)))?; let bin_info = BinaryInfo::parse( File::open(&exe_path).map_err(|e| { Error::new( @@ -101,9 +103,9 @@ impl Component { }); } let executable = info.executable.as_ref().unwrap(); - let exe_path = dir.join(Cow::from_slash(executable)); + let exe_path = ensure_within(dir, &dir.join(Cow::from_slash(executable)))?; let bin_info = BinaryInfo::parse( - File::open(dir.join(&exe_path))?, + File::open(&exe_path)?, exe_path.file_name().unwrap().to_string_lossy(), true, ) diff --git a/common/ipk/src/ipk.rs b/common/ipk/src/ipk.rs index 7a04f2e8..1e47e9db 100644 --- a/common/ipk/src/ipk.rs +++ b/common/ipk/src/ipk.rs @@ -7,6 +7,7 @@ use std::path::Path; use debpkg::{Control, DebPkg}; use path_slash::CowExt; +use crate::path::ensure_within; use crate::{AppInfo, Component, Package, PackageInfo, ServiceInfo, Symlinks}; impl Package { @@ -49,29 +50,35 @@ impl Package { } } let links = Symlinks::new(links); - let package_info = File::open(tmp.as_ref().join(Cow::from_slash(&format!( - "usr/palm/packages/{id}/packageinfo.json" - ))))?; + // The package id and the app/service ids come from untrusted metadata; + // guard every path joined onto the extraction dir against traversal. + let root = tmp.as_ref(); + let package_info_path = ensure_within( + root, + &root.join(Cow::from_slash(&format!( + "usr/palm/packages/{id}/packageinfo.json" + ))), + )?; + let package_info = File::open(package_info_path)?; let package_info: PackageInfo = serde_json::from_reader(package_info).map_err(|e| { Error::new( ErrorKind::InvalidData, format!("Bad packageinfo.json: {e:?}"), ) })?; - let app = Component::::parse( - tmp.as_ref().join(Cow::from_slash(&format!( + let app_dir = ensure_within( + root, + &root.join(Cow::from_slash(&format!( "usr/palm/applications/{}", package_info.app ))), - &links, )?; + let app = Component::::parse(app_dir, &links)?; let mut services = Vec::new(); for id in &package_info.services { - let service = Component::::parse( - tmp.as_ref() - .join(Cow::from_slash(&format!("usr/palm/services/{id}"))), - &links, - )?; + let service_dir = + ensure_within(root, &root.join(Cow::from_slash(&format!("usr/palm/services/{id}"))))?; + let service = Component::::parse(service_dir, &links)?; services.push(service); } return Ok(Self { diff --git a/common/ipk/src/lib.rs b/common/ipk/src/lib.rs index fff0a669..bb315ceb 100644 --- a/common/ipk/src/lib.rs +++ b/common/ipk/src/lib.rs @@ -8,6 +8,7 @@ use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection}; mod component; mod ipk; mod links; +mod path; #[derive(Debug)] pub struct Package { diff --git a/common/ipk/src/path.rs b/common/ipk/src/path.rs new file mode 100644 index 00000000..6f127352 --- /dev/null +++ b/common/ipk/src/path.rs @@ -0,0 +1,77 @@ +//! Path-containment guard for untrusted package metadata. +//! +//! A `.ipk` is attacker-controlled input. Its metadata carries path fragments +//! that get joined onto the extraction directory and then opened — the app id +//! and service ids from `packageinfo.json`, and the `main`/`executable` entry +//! from `appinfo.json`/`services.json`. Without a check, a value like +//! `../../../../dev/zero` or `/etc/passwd` would make the verifier open a file +//! outside the package (a device read is an easy DoS). `tar`'s `unpack_in` +//! already blocks traversal when *writing* extracted files; this guards the +//! *reads* we do afterwards. + +use std::io::{Error, ErrorKind}; +use std::path::{Component, Path, PathBuf}; + +/// Lexically resolve `.` / `..` components without touching the filesystem. +/// +/// An extracted package has no on-disk symlinks (they are recorded in memory, +/// never written), so lexical resolution matches canonicalization for our tree +/// while never opening the candidate — a traversal path is rejected before it +/// is ever read. +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other.as_os_str()), + } + } + out +} + +/// Ensure `candidate` stays within `root` after resolving `.`/`..`, returning +/// the normalized path. Rejects path traversal via untrusted package metadata. +pub(crate) fn ensure_within(root: &Path, candidate: &Path) -> Result { + let root = lexical_normalize(root); + let candidate = lexical_normalize(candidate); + if !candidate.starts_with(&root) { + return Err(Error::new( + ErrorKind::InvalidData, + format!("unsafe path escapes package directory: {}", candidate.display()), + )); + } + Ok(candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_paths_inside_root() { + let root = Path::new("/tmp/pkg"); + assert!(ensure_within(root, Path::new("/tmp/pkg/usr/palm/app/index.html")).is_ok()); + // `..` that stays within root is fine. + assert_eq!( + ensure_within(root, Path::new("/tmp/pkg/usr/../usr/main")).unwrap(), + PathBuf::from("/tmp/pkg/usr/main") + ); + } + + #[test] + fn rejects_traversal_escaping_root() { + let root = Path::new("/tmp/pkg"); + assert!(ensure_within(root, Path::new("/tmp/pkg/../../../../etc/passwd")).is_err()); + assert!(ensure_within(root, Path::new("/tmp/pkg/a/../../../dev/zero")).is_err()); + } + + #[test] + fn rejects_absolute_paths_outside_root() { + let root = Path::new("/tmp/pkg"); + // A `main` of "/etc/passwd" makes join() reset to the absolute path. + assert!(ensure_within(root, Path::new("/etc/passwd")).is_err()); + } +} diff --git a/common/webdetect/src/web.rs b/common/webdetect/src/web.rs index 58430640..e1e52a86 100644 --- a/common/webdetect/src/web.rs +++ b/common/webdetect/src/web.rs @@ -30,7 +30,7 @@ const MAX_REMOTE: usize = 20; /// Detect the framework, webOSTV.js SDK, ES syntax level and remote resources /// of the web app rooted at `dir`, whose HTML entry point is `index_html`. pub fn detect_web_app(dir: &Path, index_html: &Path) -> WebAppDetection { - let html = fs::read_to_string(index_html).unwrap_or_default(); + let html = read_capped(index_html); let mut js: Vec<(String, String)> = Vec::new(); collect_js(dir, 0, &mut js); @@ -51,6 +51,15 @@ pub fn detect_web_app(dir: &Path, index_html: &Path) -> WebAppDetection { } } +/// Read a file to a string, but skip (return empty) anything larger than +/// [`MAX_FILE_BYTES`] so a pathological entry can't exhaust memory. +fn read_capped(path: &Path) -> String { + if fs::metadata(path).map(|m| m.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { + return String::new(); + } + fs::read_to_string(path).unwrap_or_default() +} + /// Recursively gather `*.js` file contents (skipping source maps), bounded by /// the depth/count/size caps above. fn collect_js(dir: &Path, depth: usize, out: &mut Vec<(String, String)>) { From 3f055921adffd7e26daa9dc312a86f1c9fd1a66f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:47:41 +0000 Subject: [PATCH 4/6] webdetect: detect via token stream (ress) and DOM (tl) instead of regex Regex scanning could not tell code from strings/comments, forcing conservative patterns and still misfiring (e.g. a JSDoc /** banner read as the ** operator). Replace it with lightweight parsing for the two places accuracy matters: - JS ES-feature detection now runs over a ress token stream, so keywords and operators inside strings, comments and regex literals are ignored. ?. and ?? (not single tokens in this lexer) are reconstructed from adjacent ?/./? with span adjacency; async is confirmed as a contextual keyword (identifier followed by function/(/ident) rather than a variable named "async". - HTML signals (script type=module, remote src/href, Angular ng-version) now come from a tl DOM instead of attribute regexes, so body text or comments can't false-match. Framework identity stays on banner/marker scanning (license comments are unambiguous as substrings). Both crates are light: tl has zero transitive deps and ress adds only log + unicode-xid. The ES-level result remains a conservative engine floor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3 --- Cargo.lock | 24 ++ common/webdetect/Cargo.toml | 2 + common/webdetect/src/eslevel.rs | 2 +- common/webdetect/src/web.rs | 394 ++++++++++++++++++++++---------- 4 files changed, 294 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a2cbcad..98e41558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,16 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "ress" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d77111a94ef694bb2fe9867a93e21007e2151943c9a48b2ed72734bde41875a" +dependencies = [ + "log", + "unicode-xid", +] + [[package]] name = "rowan" version = "0.15.16" @@ -1284,6 +1294,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tl" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b130bd8a58c163224b44e217b4239ca7b927d82bf6cc2fea1fc561d15056e3f7" + [[package]] name = "typenum" version = "1.17.0" @@ -1302,6 +1318,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.3" @@ -1420,10 +1442,12 @@ name = "webdetect-lib" version = "0.1.0" dependencies = [ "regex", + "ress", "semver", "serde", "serde_json", "tempfile", + "tl", ] [[package]] diff --git a/common/webdetect/Cargo.toml b/common/webdetect/Cargo.toml index 8556cc07..6bdcaf56 100644 --- a/common/webdetect/Cargo.toml +++ b/common/webdetect/Cargo.toml @@ -12,6 +12,8 @@ regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } semver = { workspace = true } +ress = "0.11.7" +tl = "0.7.8" [dev-dependencies] tempfile = { workspace = true } diff --git a/common/webdetect/src/eslevel.rs b/common/webdetect/src/eslevel.rs index 884b3c8e..ab939d31 100644 --- a/common/webdetect/src/eslevel.rs +++ b/common/webdetect/src/eslevel.rs @@ -69,7 +69,7 @@ impl EsLevel { /// A concrete JS syntax feature detected in a bundle, used as evidence for the /// derived [`EsLevel`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum EsFeature { LetConst, Arrow, diff --git a/common/webdetect/src/web.rs b/common/webdetect/src/web.rs index e1e52a86..ad93310f 100644 --- a/common/webdetect/src/web.rs +++ b/common/webdetect/src/web.rs @@ -1,10 +1,17 @@ //! Frontend framework and JavaScript syntax-level detection for a web app. //! -//! Heuristics operate on the shipped `index.html` and the bundle `*.js` files. -//! They are intentionally conservative: regex on JS cannot tell code from -//! strings, so the ES-level result is treated as a floor ("the engine must -//! support at least this"), and framework versions are only reported when a -//! reliable signal (license banner, `ng-version` attribute) is present. +//! Accuracy comes from parsing rather than regex-ing the source: +//! - JS ES-feature detection runs over a **token stream** (`ress`), so a +//! keyword or operator inside a string or comment can't false-match (the +//! classic `/**`-as-`**` problem). +//! - HTML signals (`"#).has_module); + assert!(detect_html_signals(r#""#).has_module); + assert!(!detect_html_signals(r#""#).has_module); + } + + #[test] + fn module_word_in_text_is_not_a_module_script() { + // A DOM parse won't be fooled by the word "module" in body text/attrs. + let sig = detect_html_signals(r#"type="module" is great"#); + assert!(!sig.has_module); + } + + #[test] + fn collects_remote_resources_deduped() { + let sig = detect_html_signals( + r#" + + + + + "#, + ); + assert_eq!(sig.remote_resources.len(), 2); + assert!(sig.remote_resources.iter().any(|u| u.contains("cdn.example.com/lib.js"))); + assert!(sig.remote_resources.iter().any(|u| u.starts_with("//fonts.example.com"))); + } + + #[test] + fn no_remote_resources_when_all_local() { + let sig = detect_html_signals(r#""#); + assert!(sig.remote_resources.is_empty()); + } + + // --- ES features (ress token stream) --- + #[test] fn es_level_is_max_feature() { let (level, feats) = detect_es(&js("const x = a?.b; let y = async () => await z;"), false); @@ -370,6 +505,14 @@ mod tests { assert!(feats.contains(&EsFeature::OptionalChaining)); assert!(feats.contains(&EsFeature::AsyncAwait)); assert!(feats.contains(&EsFeature::Arrow)); + assert!(feats.contains(&EsFeature::LetConst)); + } + + #[test] + fn detects_nullish_coalescing() { + let (level, feats) = detect_es(&js("var x = a ?? b;"), false); + assert_eq!(level, Some(EsLevel::Es2020)); + assert!(feats.contains(&EsFeature::NullishCoalescing)); } #[test] @@ -379,9 +522,29 @@ mod tests { assert!(feats.is_empty()); } + #[test] + fn features_inside_strings_and_comments_are_ignored() { + // The whole point of tokenizing: none of these are real syntax. + let src = r#" + // const x = () => {}; a ** b; a?.b; a ?? b; async function q(){} + var s = "const y = async () => await z ** 2 ?? w ?.p"; + var t = `template-looking ${'but a string'}`; + var u = 1; + "#; + let (level, feats) = detect_es(&js(src), false); + // The backtick template IS real code here, so ES2015; but NO async, + // arrow, exponent, optional-chaining or nullish from the string/comment. + assert!(!feats.contains(&EsFeature::AsyncAwait)); + assert!(!feats.contains(&EsFeature::Arrow)); + assert!(!feats.contains(&EsFeature::Exponent)); + assert!(!feats.contains(&EsFeature::OptionalChaining)); + assert!(!feats.contains(&EsFeature::NullishCoalescing)); + assert_eq!(level, Some(EsLevel::Es2015)); // only the real template literal + assert!(feats.contains(&EsFeature::TemplateLiteral)); + } + #[test] fn jsdoc_banner_is_not_an_exponent() { - // A `/** ... */` license banner must not be read as the `**` operator. let (level, feats) = detect_es(&js("/** @license v1 */ var x = 1;"), false); assert_eq!(level, Some(EsLevel::Es5)); assert!(!feats.contains(&EsFeature::Exponent)); @@ -393,40 +556,17 @@ mod tests { assert!(feats.contains(&EsFeature::Exponent)); } + #[test] + fn async_identifier_variable_is_not_async_function() { + // `var async = 1;` must not be read as async/await usage. + let (_level, feats) = detect_es(&js("var async = 1; var b = async + 2;"), false); + assert!(!feats.contains(&EsFeature::AsyncAwait)); + } + #[test] fn script_module_raises_es_level_over_es5_bundle() { - // An otherwise-ES5 bundle loaded as a module still needs a modern engine. let (level, feats) = detect_es(&js("var x = 1;"), true); assert_eq!(level, Some(EsLevel::Es2018)); assert!(feats.contains(&EsFeature::EsModule)); } - - #[test] - fn detects_script_module_tag() { - assert!(RE_SCRIPT_MODULE.is_match(r#""#)); - assert!(RE_SCRIPT_MODULE.is_match(r#""#)); - assert!(!RE_SCRIPT_MODULE.is_match(r#""#)); - } - - #[test] - fn collects_remote_resources_deduped() { - let html = r#" - - - - - "#; - let remotes = detect_remote_resources(html); - assert_eq!(remotes.len(), 2); // local bundle.js excluded, duplicate deduped - assert!(remotes.iter().any(|u| u.contains("cdn.example.com/lib.js"))); - assert!(remotes.iter().any(|u| u.starts_with("//fonts.example.com"))); - } - - #[test] - fn no_remote_resources_when_all_local() { - let remotes = detect_remote_resources( - r#""#, - ); - assert!(remotes.is_empty()); - } } From ebd1074162b42e78575cf1d1559c75cbd58df853 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:57:20 +0000 Subject: [PATCH 5/6] webdetect: detect the Enyo framework (with version) Enact was detected but Enyo -- the legacy LG/webOS framework Enact descends from, still used by many older webOS TV apps -- was not. Add FrameworkKind::Enyo, matched from the enyo.* API and its bundle/file markers (enyo.kind/Control/version/..., @enyo/, enyo.js), with a best-effort version from `enyo.version = { core: "x.y.z" }`. Enyo ranks just below Enact in precedence so an Enact bundle (which shares lineage) is still reported as Enact. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3 --- common/webdetect/src/lib.rs | 2 ++ common/webdetect/src/web.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/common/webdetect/src/lib.rs b/common/webdetect/src/lib.rs index 8384bb67..118b4fb1 100644 --- a/common/webdetect/src/lib.rs +++ b/common/webdetect/src/lib.rs @@ -50,6 +50,7 @@ impl FrameworkInfo { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FrameworkKind { Enact, + Enyo, React, Vue, Angular, @@ -63,6 +64,7 @@ impl FrameworkKind { pub fn label(self) -> &'static str { match self { FrameworkKind::Enact => "Enact", + FrameworkKind::Enyo => "Enyo", FrameworkKind::React => "React", FrameworkKind::Vue => "Vue", FrameworkKind::Angular => "Angular", diff --git a/common/webdetect/src/web.rs b/common/webdetect/src/web.rs index ad93310f..d1f1d015 100644 --- a/common/webdetect/src/web.rs +++ b/common/webdetect/src/web.rs @@ -178,6 +178,13 @@ re!(RE_VUE_PRESENT, r"__vue__|createElementVNode|Vue\.createApp|\bVue\b"); re!(RE_JQUERY, r"jQuery(?: JavaScript Library)? v([0-9]+\.[0-9]+\.[0-9]+)"); re!(RE_JQUERY_PRESENT, r"jquery|jQuery"); re!(RE_ENACT, r"@enact/|enactVersion|enact_dev|enactMeta"); +// Enyo (the legacy LG/webOS framework Enact descends from): the `enyo.*` API +// and its bundle/file markers. +re!( + RE_ENYO, + r"\benyo\.(?:kind|Control|Component|Application|version|ready|singleton)\b|@enyo/|\benyojs\b|enyo(?:\.min)?\.js" +); +re!(RE_ENYO_VER, r#"enyo\.version\s*=\s*\{[^}]*?core["']?\s*:\s*["']([0-9]+\.[0-9]+\.[0-9]+)"#); re!(RE_WEBOSTV, r"(?i)webOSTV(?:-dev)?\.js"); re!(RE_WEBOSTV_VER, r"webOSTV(?:-dev)?\.js\s*(?:v(?:ersion)?)?\s*([0-9]+\.[0-9]+\.[0-9]+)"); @@ -256,6 +263,11 @@ fn detect_frameworks( found.push(FrameworkInfo::new(FrameworkKind::Enact, None)); } + // Enyo (best-effort version from `enyo.version = { core: "x.y.z" }`). + if let Some(f) = scan(FrameworkKind::Enyo, &RE_ENYO, Some(&RE_ENYO_VER)) { + found.push(f); + } + let webostvjs = { let by_name = js.iter().any(|(n, _)| RE_WEBOSTV.is_match(n)); let by_content = std::iter::once(html) @@ -273,6 +285,7 @@ fn detect_frameworks( let precedence = [ FrameworkKind::Enact, + FrameworkKind::Enyo, FrameworkKind::Angular, FrameworkKind::React, FrameworkKind::Vue, @@ -459,6 +472,34 @@ mod tests { assert!(tv.is_none()); } + #[test] + fn detects_enyo_from_api_usage() { + let (primary, _o, _t) = + detect_frameworks("", &js("enyo.kind({ name: 'App', kind: enyo.Control });"), None); + assert_eq!(primary.unwrap().kind, FrameworkKind::Enyo); + } + + #[test] + fn detects_enyo_version() { + let (primary, _o, _t) = detect_frameworks( + "", + &js(r#"enyo.version = { core: "2.7.0", canvas: "2.7.0" };"#), + None, + ); + let f = primary.unwrap(); + assert_eq!(f.kind, FrameworkKind::Enyo); + assert_eq!(f.version, Some(Version::parse("2.7.0").unwrap())); + } + + #[test] + fn enact_takes_precedence_over_enyo_markers() { + // Enact's lineage means some enyo-ish strings can co-occur; Enact wins. + let (primary, others, _t) = + detect_frameworks("", &js("import '@enact/core'; enyo.kind({});"), None); + assert_eq!(primary.unwrap().kind, FrameworkKind::Enact); + assert!(others.iter().any(|f| f.kind == FrameworkKind::Enyo)); + } + // --- HTML signals (tl DOM) --- #[test] From 968196799759e825c5052fe725f669fa9cfb2c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:21:01 +0000 Subject: [PATCH 6/6] webdetect: scan inline + "#, + ); + assert!(sig.inline_js.contains("async")); + assert!(!sig.inline_js.contains("not")); // JSON script body excluded + } + // --- ES features (ress token stream) --- #[test] diff --git a/packages/ipk-verify/src/main.rs b/packages/ipk-verify/src/main.rs index fc54c962..8f6368bc 100644 --- a/packages/ipk-verify/src/main.rs +++ b/packages/ipk-verify/src/main.rs @@ -320,21 +320,13 @@ fn print_detection_summary( } DetectionResult::Service { detection: svc, .. } => { out.write_fmt(format_args!("JS service — {}\n\n", describe_service(svc)))?; - // Each firmware's Node.js version. + // Each firmware's Node.js version — informational only; there is no + // reliable requirement to check a webOS service against. table.add_row(Row::new( - iter::once(Cell::new("Node.js")) + iter::once(Cell::new("Node.js (firmware)")) .chain(results.iter().map(|(_, r)| Cell::new(&node_label(r)))) .collect(), )); - table.add_row(Row::new( - iter::once(Cell::new("Node support")) - .chain( - results - .iter() - .map(|(_, r)| out.verdict_cell(component_verdict(r), out_fmt)), - ) - .collect(), - )); } } out.print_table(&table)?; @@ -368,9 +360,6 @@ fn print_detection_details( } DetectionResult::Service { detection: svc, .. } => { out.h4("JS service")?; - if let Some(req) = &svc.declared_node { - out.write_fmt(format_args!("* Requires Node.js {req}\n"))?; - } for (name, ver) in &svc.dependencies { out.write_fmt(format_args!("* Dependency: {name} {ver}\n"))?; } @@ -380,7 +369,7 @@ fn print_detection_details( let mut any_fail = false; for (fw, r) in results { if let Some(detection) = &r.detection { - if let CompatVerdict::Fail { reason } = detection.verdict() { + if let Some(CompatVerdict::Fail { reason }) = detection.verdict() { if !any_fail { out.h5("Incompatible on")?; any_fail = true; @@ -430,27 +419,23 @@ fn describe_web(web: &WebAppDetection) -> String { } } -/// One-line description of the detected JS service (Node requirement, deps). +/// One-line description of the detected JS service (dependencies only — its +/// Node.js requirement is not inferable, see ServiceRuntimeDetection). fn describe_service(svc: &ServiceRuntimeDetection) -> String { - let mut parts: Vec = Vec::new(); - match &svc.declared_node { - Some(req) => parts.push(format!("requires Node.js {req}")), - None => parts.push("no engines.node declared".to_string()), - } match svc.dependencies.len() { - 0 => {} - 1 => parts.push("1 dependency".to_string()), - n => parts.push(format!("{n} dependencies")), + 0 => "Node.js service".to_string(), + 1 => "Node.js service; 1 dependency".to_string(), + n => format!("Node.js service; {n} dependencies"), } - parts.join("; ") } -/// The compatibility verdict for a component result (Unknown if absent). +/// The web app's ES compatibility verdict for a component result (Unknown if +/// absent). Only web apps carry a verdict; services are informational. fn component_verdict(result: &ComponentVerifyResult) -> &CompatVerdict { result .detection .as_ref() - .map(|d| d.verdict()) + .and_then(|d| d.verdict()) .unwrap_or(&CompatVerdict::Unknown) } diff --git a/packages/ipk-verify/tests/detection.rs b/packages/ipk-verify/tests/detection.rs index 48035265..a9be04db 100644 --- a/packages/ipk-verify/tests/detection.rs +++ b/packages/ipk-verify/tests/detection.rs @@ -7,7 +7,7 @@ use bin_lib::LibraryInfo; use fw_lib::WebEngine; use ipk_lib::{AppInfo, Component, Package, ServiceInfo}; -use semver::{Version, VersionReq}; +use semver::Version; use verify_lib::ipk::{CompatVerdict, VerifyForFirmware}; use verify_lib::VerifyResult; use webdetect_lib::{ @@ -41,7 +41,7 @@ fn web_app(es: EsLevel) -> Component { } } -fn node_service(id: &str, req: &str) -> Component { +fn node_service(id: &str) -> Component { Component { id: id.to_string(), info: ServiceInfo { @@ -49,8 +49,7 @@ fn node_service(id: &str, req: &str) -> Component { engine: Some("node".to_string()), executable: None, runtime: Some(ServiceRuntimeDetection { - declared_node: VersionReq::parse(req).ok(), - dependencies: vec![], + dependencies: vec![("express".to_string(), "^4.18.0".to_string())], main: None, }), }, @@ -78,14 +77,14 @@ fn web_app_es_level_checked_against_engine() { // Chromium 120 supports ES2017 → OK, component is good. let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::Chromium(Version::new(120, 0, 0)))); - assert_eq!(r.app.detection.as_ref().unwrap().verdict(), &CompatVerdict::Ok); + assert_eq!(r.app.detection.as_ref().unwrap().verdict(), Some(&CompatVerdict::Ok)); assert!(r.app.is_good()); // Chromium 53 (webOS 4) predates async/await → FAIL, component not good. let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::Chromium(Version::new(53, 0, 2785)))); assert!(matches!( r.app.detection.as_ref().unwrap().verdict(), - CompatVerdict::Fail { .. } + Some(CompatVerdict::Fail { .. }) )); assert!(!r.app.is_good()); @@ -93,43 +92,41 @@ fn web_app_es_level_checked_against_engine() { let r = pkg.verify_for_firmware(&no_libs, None, Some(&WebEngine::WebKit(Version::new(537, 41, 0)))); assert!(matches!( r.app.detection.as_ref().unwrap().verdict(), - CompatVerdict::Fail { .. } + Some(CompatVerdict::Fail { .. }) )); // Firmware with no known web engine → Unknown, which must NOT fail the build. let r = pkg.verify_for_firmware(&no_libs, None, None); - assert_eq!(r.app.detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); + assert_eq!(r.app.detection.as_ref().unwrap().verdict(), Some(&CompatVerdict::Unknown)); assert!(r.app.is_good()); } #[test] -fn multiple_services_each_get_their_own_node_verdict() { +fn services_are_detected_but_never_fail_on_node() { + // engines.node is not trusted, so a JS service carries no compat verdict and + // must never fail the build regardless of the firmware's Node.js version. let pkg = package( web_app(EsLevel::Es5), vec![ - node_service("com.example.app.svc1", ">=12.0.0"), - node_service("com.example.app.svc2", ">=99.0.0"), + node_service("com.example.app.svc1"), + node_service("com.example.app.svc2"), ], ); - // webOS 10.2 ships Node 16.20.2. let r = pkg.verify_for_firmware( &no_libs, Some(&Version::new(16, 20, 2)), Some(&WebEngine::Chromium(Version::new(120, 0, 0))), ); assert_eq!(r.services.len(), 2); - assert_eq!(r.services[0].detection.as_ref().unwrap().verdict(), &CompatVerdict::Ok); - assert!(r.services[0].is_good()); - assert!(matches!( - r.services[1].detection.as_ref().unwrap().verdict(), - CompatVerdict::Fail { .. } - )); - assert!(!r.services[1].is_good()); + for svc in &r.services { + let detection = svc.detection.as_ref().unwrap(); + assert!(detection.verdict().is_none()); // informational only + assert!(!detection.is_incompatible()); + assert!(svc.is_good()); + } - // No Node package on the firmware → Unknown for both, never a fail. + // Even with no Node package on the firmware, services stay good. let r = pkg.verify_for_firmware(&no_libs, None, None); - assert_eq!(r.services[0].detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); - assert_eq!(r.services[1].detection.as_ref().unwrap().verdict(), &CompatVerdict::Unknown); - assert!(r.services[1].is_good()); + assert!(r.services.iter().all(|s| s.is_good())); }