diff --git a/Cargo.lock b/Cargo.lock index f55ebe85..98e41558 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]] @@ -1061,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" @@ -1282,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" @@ -1300,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" @@ -1340,7 +1364,10 @@ name = "verify-lib" version = "0.1.3" dependencies = [ "bin-lib", + "fw-lib", "ipk-lib", + "semver", + "webdetect-lib", ] [[package]] @@ -1410,6 +1437,19 @@ 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", + "ress", + "semver", + "serde", + "serde_json", + "tempfile", + "tl", +] + [[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..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 { @@ -41,6 +42,12 @@ 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. + // `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 { id: info.id.clone(), info, @@ -48,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( @@ -84,6 +91,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(), @@ -92,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 975b616d..bb315ceb 100644 --- a/common/ipk/src/lib.rs +++ b/common/ipk/src/lib.rs @@ -3,10 +3,12 @@ use std::collections::HashMap; use std::path::PathBuf; use bin_lib::{BinaryInfo, LibraryInfo}; +use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection}; mod component; mod ipk; mod links; +mod path; #[derive(Debug)] pub struct Package { @@ -40,6 +42,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 +53,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/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/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..03549828 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; +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,51 @@ 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 — informational only. There is no + /// compat verdict for services: `engines.node` isn't trusted and webOS + /// services carry no other reliable runtime requirement. + available_node: Option, + }, +} + +/// 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 compatibility verdict for this component on this firmware, if it has + /// one. Services are informational only (no verdict). + pub fn verdict(&self) -> Option<&CompatVerdict> { + match self { + DetectionResult::WebApp { es, .. } => Some(es), + DetectionResult::Service { .. } => None, + } + } + + /// Whether this component is definitively incompatible (a `Fail` verdict); + /// `Unknown`/no-verdict is not treated as incompatible. + pub fn is_incompatible(&self) -> bool { + matches!(self.verdict(), Some(CompatVerdict::Fail { .. })) + } +} + impl Verify for Package { fn verify(&self, find_library: &F) -> PackageVerifyResult where @@ -46,3 +97,84 @@ 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()?; + return Some(DetectionResult::Service { + detection, + available_node: node.cloned(), + }); +} + +/// 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() + ), + } + } +} + diff --git a/common/webdetect/Cargo.toml b/common/webdetect/Cargo.toml new file mode 100644 index 00000000..6bdcaf56 --- /dev/null +++ b/common/webdetect/Cargo.toml @@ -0,0 +1,19 @@ +[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 } +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 new file mode 100644 index 00000000..ab939d31 --- /dev/null +++ b/common/webdetect/src/eslevel.rs @@ -0,0 +1,144 @@ +//! 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, Hash)] +pub enum EsFeature { + LetConst, + Arrow, + TemplateLiteral, + Class, + Spread, + Exponent, + AsyncAwait, + OptionalChaining, + NullishCoalescing, + /// An `"#).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()); + } + + #[test] + fn collects_inline_script_body() { + // Apps that inline all JS into index.html (Enyo/Enact single-file builds). + let sig = detect_html_signals( + r#" + "#, + ); + assert!(sig.inline_js.contains("async")); + assert!(!sig.inline_js.contains("not")); // JSON script body excluded + } + + // --- 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); + assert_eq!(level, Some(EsLevel::Es2020)); // optional chaining + 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] + fn es5_bundle_reads_as_es5() { + let (level, feats) = detect_es(&js("var x = 1; function f() { return x; }"), false); + assert_eq!(level, Some(EsLevel::Es5)); + 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() { + let (level, feats) = detect_es(&js("/** @license v1 */ var x = 1;"), false); + 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;"), false); + 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() { + let (level, feats) = detect_es(&js("var x = 1;"), true); + assert_eq!(level, Some(EsLevel::Es2018)); + assert!(feats.contains(&EsFeature::EsModule)); + } +} 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..8f6368bc 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,184 @@ 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 — informational only; there is no + // reliable requirement to check a webOS service against. + table.add_row(Row::new( + iter::once(Cell::new("Node.js (firmware)")) + .chain(results.iter().map(|(_, r)| Cell::new(&node_label(r)))) + .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!("* 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, .. } => { + out.h4("JS service")?; + 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 Some(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())); + } + 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 { + parts.join("; ") + } +} + +/// 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 { + match svc.dependencies.len() { + 0 => "Node.js service".to_string(), + 1 => "Node.js service; 1 dependency".to_string(), + n => format!("Node.js service; {n} dependencies"), + } +} + +/// 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() + .and_then(|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..a9be04db --- /dev/null +++ b/packages/ipk-verify/tests/detection.rs @@ -0,0 +1,132 @@ +//! 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; +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![], + remote_resources: vec![], + }), + }, + exe: None, + libs: vec![], + } +} + +fn node_service(id: &str) -> Component { + Component { + id: id.to_string(), + info: ServiceInfo { + id: id.to_string(), + engine: Some("node".to_string()), + executable: None, + runtime: Some(ServiceRuntimeDetection { + dependencies: vec![("express".to_string(), "^4.18.0".to_string())], + 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(), 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(), + Some(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(), + 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(), Some(&CompatVerdict::Unknown)); + assert!(r.app.is_good()); +} + +#[test] +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"), + node_service("com.example.app.svc2"), + ], + ); + + 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); + 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()); + } + + // Even with no Node package on the firmware, services stay good. + let r = pkg.verify_for_firmware(&no_libs, None, None); + assert!(r.services.iter().all(|s| s.is_good())); +}