diff --git a/common/bin/src/artifact.rs b/common/bin/src/artifact.rs new file mode 100644 index 0000000..68b8f35 --- /dev/null +++ b/common/bin/src/artifact.rs @@ -0,0 +1,109 @@ +//! Lightweight ELF classification for *bundled* artifacts. +//! +//! Unlike [`crate::BinaryInfo`]/[`crate::LibraryInfo`] (which parse the dynamic +//! symbol/needed tables for link verification), this only reads the ELF header +//! and program headers to answer two questions cheaply: is this file an +//! executable or a shared library, and for which architecture? It is used to +//! surface the native binaries a JS service ships alongside its scripts (its own +//! `node`, `ffmpeg`, `.so`s) as supplementary report info. + +use std::io::{Read, Seek}; + +use elf::endian::AnyEndian; +use elf::file::Class; +use elf::{abi, ElfStream}; +use serde::{Deserialize, Serialize}; + +/// Whether a bundled ELF is a program or a shared library. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ArtifactKind { + Executable, + SharedLibrary, +} + +impl ArtifactKind { + pub fn label(self) -> &'static str { + match self { + ArtifactKind::Executable => "executable", + ArtifactKind::SharedLibrary => "shared library", + } + } +} + +/// A native ELF file bundled inside a component, described just enough for a +/// report line: its path within the component, kind, and architecture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundledArtifact { + /// Slash-separated path relative to the component directory. + pub path: String, + pub kind: ArtifactKind, + /// Human-readable architecture (e.g. "ARM (32-bit)"), or `None` if unknown. + pub arch: Option, +} + +impl BundledArtifact { + /// Classify an ELF stream. Returns `None` for anything that isn't an ELF + /// executable or shared library (data files, scripts, unreadable files). + pub fn identify(source: S, path: P) -> Option + where + S: Read + Seek, + P: Into, + { + let elf = ElfStream::::open_stream(source).ok()?; + let e_type = elf.ehdr.e_type; + let e_machine = elf.ehdr.e_machine; + let class = elf.ehdr.class; + // A `PT_INTERP` program header marks a file that requests an interpreter + // — i.e. a program to run, not a library to load. This is what tells a + // PIE executable (also `ET_DYN`) apart from a real shared object. + let has_interp = elf.segments().iter().any(|ph| ph.p_type == abi::PT_INTERP); + let kind = match e_type { + abi::ET_EXEC => ArtifactKind::Executable, + abi::ET_DYN if has_interp => ArtifactKind::Executable, + abi::ET_DYN => ArtifactKind::SharedLibrary, + _ => return None, + }; + Some(BundledArtifact { + path: path.into(), + kind, + arch: arch_label(e_machine, class), + }) + } +} + +/// Map an ELF machine + class to a readable architecture label. +fn arch_label(machine: u16, class: Class) -> Option { + let name = match machine { + abi::EM_ARM => "ARM (32-bit)", + abi::EM_AARCH64 => "AArch64 (64-bit)", + abi::EM_386 => "x86", + abi::EM_X86_64 => "x86-64", + _ => { + return Some(match class { + Class::ELF32 => "unknown (32-bit)".to_string(), + Class::ELF64 => "unknown (64-bit)".to_string(), + }) + } + }; + Some(name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn identifies_sample_fixture() { + let mut content = Cursor::new(include_bytes!("fixtures/sample.bin")); + let a = BundledArtifact::identify(&mut content, "sample.bin").expect("is an ELF"); + // The fixture links libc.so.6, so it's an ELF of some kind with an arch. + assert!(a.arch.is_some()); + } + + #[test] + fn rejects_non_elf() { + let mut content = Cursor::new(b"#!/bin/sh\necho hi\n"); + assert!(BundledArtifact::identify(&mut content, "script.sh").is_none()); + } +} diff --git a/common/bin/src/binary.rs b/common/bin/src/binary.rs index 97e30bd..92d8aca 100644 --- a/common/bin/src/binary.rs +++ b/common/bin/src/binary.rs @@ -20,40 +20,42 @@ impl BinaryInfo { .map(|tbl| tbl.iter().collect()) .unwrap_or_default(); - let dynstr_header = *elf.section_header_by_name(".dynstr")?.unwrap(); - let dynstr_table = elf.section_data_as_strtab(&dynstr_header).unwrap(); - for entry in dynamic_entries { - match entry.d_tag { - abi::DT_NEEDED => { - needed.push(String::from( - dynstr_table.get(entry.d_val() as usize).unwrap(), - )); - } - abi::DT_RPATH | abi::DT_RUNPATH => { - if with_rpath { - rpath.extend( - dynstr_table - .get(entry.d_val() as usize) - .unwrap() - .split(":") - .map(|s| String::from(s)), - ) + // A statically-linked / non-dynamic ELF (some bundled `ffmpeg` builds, + // for instance) has no `.dynstr`/`.dynsym`. Treat it as having no dynamic + // dependencies or imports rather than panicking. + if let Some(dynstr_header) = elf.section_header_by_name(".dynstr")?.copied() { + let dynstr_table = elf.section_data_as_strtab(&dynstr_header)?; + for entry in dynamic_entries { + match entry.d_tag { + abi::DT_NEEDED => { + if let Ok(s) = dynstr_table.get(entry.d_val() as usize) { + needed.push(String::from(s)); + } + } + abi::DT_RPATH | abi::DT_RUNPATH => { + if with_rpath { + if let Ok(s) = dynstr_table.get(entry.d_val() as usize) { + rpath.extend(s.split(":").map(|s| String::from(s))); + } + } } + _ => {} } - _ => {} } } - let (sym_table, str) = elf.dynamic_symbol_table()?.unwrap(); - let symbols: Vec<(Symbol, String)> = sym_table - .iter() - .map(move |sym| { - ( - sym.clone(), - String::from(str.get(sym.st_name as usize).unwrap_or("")), - ) - }) - .collect(); + let symbols: Vec<(Symbol, String)> = match elf.dynamic_symbol_table()? { + Some((sym_table, str)) => sym_table + .iter() + .map(move |sym| { + ( + sym.clone(), + String::from(str.get(sym.st_name as usize).unwrap_or("")), + ) + }) + .collect(), + None => Vec::new(), + }; let ver_table = elf.symbol_version_table()?; let undefined: Vec = symbols diff --git a/common/bin/src/lib.rs b/common/bin/src/lib.rs index b09a4ec..198775d 100644 --- a/common/bin/src/lib.rs +++ b/common/bin/src/lib.rs @@ -1,9 +1,12 @@ use serde::{Deserialize, Serialize}; +pub mod artifact; pub mod binary; pub mod library; -#[derive(Debug, Serialize, Deserialize)] +pub use artifact::{ArtifactKind, BundledArtifact}; + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BinaryInfo { pub name: String, pub rpath: Vec, diff --git a/common/bin/src/library.rs b/common/bin/src/library.rs index 6f1736c..accc8ee 100644 --- a/common/bin/src/library.rs +++ b/common/bin/src/library.rs @@ -54,41 +54,44 @@ impl LibraryInfo { .map(|tbl| tbl.iter().collect()) .unwrap_or_default(); - let dynstr_header = *elf.section_header_by_name(".dynstr")?.unwrap(); - let dynstr_table = elf.section_data_as_strtab(&dynstr_header).unwrap(); - for entry in dynamic_entries { - match entry.d_tag { - abi::DT_NEEDED => { - needed.push(String::from( - dynstr_table.get(entry.d_val() as usize).unwrap(), - )); - } - abi::DT_SONAME => { - name = String::from(dynstr_table.get(entry.d_val() as usize).unwrap()); - } - abi::DT_RPATH | abi::DT_RUNPATH => { - rpath.extend( - dynstr_table - .get(entry.d_val() as usize) - .unwrap() - .split(":") - .map(|s| String::from(s)), - ); + // Guard against an ELF without `.dynstr`/`.dynsym` (not a real shared + // object) instead of panicking. + if let Some(dynstr_header) = elf.section_header_by_name(".dynstr")?.copied() { + let dynstr_table = elf.section_data_as_strtab(&dynstr_header)?; + for entry in dynamic_entries { + match entry.d_tag { + abi::DT_NEEDED => { + if let Ok(s) = dynstr_table.get(entry.d_val() as usize) { + needed.push(String::from(s)); + } + } + abi::DT_SONAME => { + if let Ok(s) = dynstr_table.get(entry.d_val() as usize) { + name = String::from(s); + } + } + abi::DT_RPATH | abi::DT_RUNPATH => { + if let Ok(s) = dynstr_table.get(entry.d_val() as usize) { + rpath.extend(s.split(":").map(|s| String::from(s))); + } + } + _ => {} } - _ => {} } } - let (sym_table, str) = elf.dynamic_symbol_table()?.unwrap(); - let all_syms: Vec<(Symbol, String)> = sym_table - .iter() - .map(move |sym| { - ( - sym.clone(), - String::from(str.get(sym.st_name as usize).unwrap_or("")), - ) - }) - .collect(); + let all_syms: Vec<(Symbol, String)> = match elf.dynamic_symbol_table()? { + Some((sym_table, str)) => sym_table + .iter() + .map(move |sym| { + ( + sym.clone(), + String::from(str.get(sym.st_name as usize).unwrap_or("")), + ) + }) + .collect(), + None => Vec::new(), + }; let ver_table = elf.symbol_version_table()?; let mut symbols: Vec = all_syms .iter() diff --git a/common/ipk/src/component.rs b/common/ipk/src/component.rs index 6608247..23c9482 100644 --- a/common/ipk/src/component.rs +++ b/common/ipk/src/component.rs @@ -6,9 +6,9 @@ use std::fs::File; use std::io::{Error, ErrorKind}; use std::path::{Path, PathBuf}; -use path_slash::CowExt; +use path_slash::{CowExt, PathExt}; -use bin_lib::{BinaryInfo, LibraryInfo, LibraryPriority}; +use bin_lib::{BinaryInfo, BundledArtifact, LibraryInfo, LibraryPriority}; use crate::path::ensure_within; use crate::{AppInfo, Component, ServiceInfo, Symlinks}; @@ -92,9 +92,13 @@ impl Component { .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. + // bundled package.json while the extracted files still exist, and + // note any native binaries it ships (its own node/ffmpeg/.so). let mut info = info; info.runtime = Some(webdetect_lib::detect_service_runtime(dir)); + let scan = scan_bundled(dir, links); + info.bundled = scan.artifacts; + info.bundled_bins = scan.bins; return Ok(Self { id: info.id.clone(), info: info.clone(), @@ -129,6 +133,99 @@ impl Component { } } +/// Recursion depth cap for the bundled-artifact walk. +const BUNDLED_MAX_DEPTH: usize = 12; +/// Stop after collecting this many bundled artifacts. +const BUNDLED_MAX: usize = 256; + +/// The bundled native content of a JS service: an inventory of every ELF (kind + +/// arch, for a quick listing) and, for each bundled *executable*, a verifiable +/// unit (its `exe` plus the libraries reachable via its rpath / sibling `lib` +/// dir) so the bundled runtime can be checked against a firmware's libraries. +#[derive(Default)] +pub(crate) struct BundledScan { + pub artifacts: Vec, + pub bins: Vec>, +} + +/// Walk a service directory: classify every bundled ELF and, for each +/// executable, build a verifiable [`Component`]. Non-ELF files (scripts, JSON, +/// assets) are skipped. Output is sorted by path for stable report ordering. +pub(crate) fn scan_bundled(dir: &Path, links: &Symlinks) -> BundledScan { + let mut scan = BundledScan::default(); + walk_bundled(dir, dir, 0, links, &mut scan); + scan.artifacts.sort_by(|a, b| a.path.cmp(&b.path)); + scan.bins.sort_by(|a, b| a.id.cmp(&b.id)); + scan +} + +fn walk_bundled(root: &Path, dir: &Path, depth: usize, links: &Symlinks, scan: &mut BundledScan) { + if depth > BUNDLED_MAX_DEPTH || scan.artifacts.len() >= BUNDLED_MAX { + return; + } + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if scan.artifacts.len() >= BUNDLED_MAX { + return; + } + let Ok(ft) = entry.file_type() else { continue }; + let path = entry.path(); + if ft.is_dir() { + walk_bundled(root, &path, depth + 1, links, scan); + continue; + } + if !ft.is_file() { + continue; + } + let Ok(file) = File::open(&path) else { continue }; + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_slash_lossy() + .into_owned(); + let Some(artifact) = BundledArtifact::identify(file, rel.clone()) else { + continue; + }; + let is_exe = artifact.kind == bin_lib::ArtifactKind::Executable; + scan.artifacts.push(artifact); + if is_exe { + if let Some(component) = verifiable_bundled_exe(&path, rel, links) { + scan.bins.push(component); + } + } + } +} + +/// Parse a bundled executable and discover the libraries it can load, mirroring +/// how a native component resolves its own libraries. These services locate +/// their libs via the loader's `--library-path /lib` at spawn time rather +/// than a `DT_RPATH`, so the executable's sibling `lib/` directory is treated as +/// a search path (rpath precedence) in addition to any real rpath. +fn verifiable_bundled_exe(path: &Path, rel: String, links: &Symlinks) -> Option> { + let bin = BinaryInfo::parse( + File::open(path).ok()?, + path.file_name().unwrap().to_string_lossy(), + true, + ) + .ok()?; + let parent = path.parent()?; + let mut rpath = Component::<()>::rpath(&bin.rpath, path); + if let Ok(sibling_lib) = parent.join("lib").canonicalize() { + if !rpath.contains(&sibling_lib) { + rpath.push(sibling_lib); + } + } + let libs = Component::<()>::list_libs(parent, &rpath, links).ok()?; + Some(Component { + id: rel, + info: (), + exe: Some(bin), + libs, + }) +} + impl Component { pub fn find_lib(&self, name: &str) -> Option<&'_ LibraryInfo> { return self.libs.iter().find(|lib| lib.has_name(name)); @@ -239,3 +336,77 @@ impl Component { Ok(libs.into_values().collect()) } } + +#[cfg(test)] +mod tests { + use super::*; + use bin_lib::ArtifactKind; + use std::collections::HashMap; + + fn empty_links() -> Symlinks { + Symlinks::new(HashMap::new()) + } + + #[test] + fn js_service_reports_bundled_binaries() { + let dir = tempfile::TempDir::new().unwrap(); + let d = dir.path(); + // A non-native service: no `engine`/`executable` → runs on system Node. + fs::write(d.join("services.json"), r#"{"id":"com.example.app.service"}"#).unwrap(); + fs::write(d.join("package.json"), r#"{"main":"launch.js"}"#).unwrap(); + fs::write(d.join("launch.js"), "var x = 1;").unwrap(); + // A bundled native binary next to the scripts. + fs::create_dir_all(d.join("bin")).unwrap(); + fs::write( + d.join("bin/node"), + &include_bytes!("../../bin/src/fixtures/sample.bin")[..], + ) + .unwrap(); + + let svc = Component::::parse(d, &empty_links()).unwrap(); + assert!( + svc.info + .bundled + .iter() + .any(|a| a.path == "bin/node" && a.arch.is_some()), + "expected bin/node to be reported, got {:?}", + svc.info.bundled + ); + // The bundled executable also becomes a verifiable unit. + assert!( + svc.info + .bundled_bins + .iter() + .any(|c| c.id == "bin/node" && c.exe.is_some()), + "expected bin/node as a verifiable component, got {:?}", + svc.info.bundled_bins.iter().map(|c| &c.id).collect::>() + ); + } + + #[test] + fn js_service_without_binaries_reports_none() { + let dir = tempfile::TempDir::new().unwrap(); + let d = dir.path(); + fs::write(d.join("services.json"), r#"{"id":"com.example.app.service"}"#).unwrap(); + fs::write(d.join("package.json"), r#"{"main":"launch.js"}"#).unwrap(); + fs::write(d.join("launch.js"), "var x = 1;").unwrap(); + + let svc = Component::::parse(d, &empty_links()).unwrap(); + assert!(svc.info.bundled.is_empty()); + } + + #[test] + fn kind_classifies_sample_fixture() { + // Sanity: the shared fixture classifies as one of the two kinds. + let d = tempfile::TempDir::new().unwrap(); + fs::write( + d.path().join("f"), + &include_bytes!("../../bin/src/fixtures/lib_runpath.so")[..], + ) + .unwrap(); + let a = + BundledArtifact::identify(File::open(d.path().join("f")).unwrap(), "lib_runpath.so") + .unwrap(); + assert_eq!(a.kind, ArtifactKind::SharedLibrary); + } +} diff --git a/common/ipk/src/lib.rs b/common/ipk/src/lib.rs index bb315ce..430b1ba 100644 --- a/common/ipk/src/lib.rs +++ b/common/ipk/src/lib.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; -use bin_lib::{BinaryInfo, LibraryInfo}; +use bin_lib::{BinaryInfo, BundledArtifact, LibraryInfo}; use webdetect_lib::{ServiceRuntimeDetection, WebAppDetection}; mod component; @@ -18,7 +18,7 @@ pub struct Package { pub services: Vec>, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Component { pub id: String, pub info: T, @@ -57,6 +57,17 @@ pub struct ServiceInfo { /// not part of services.json). #[serde(skip)] pub runtime: Option, + /// Native ELF files (own `node`, `ffmpeg`, `.so`s) a JS service bundles + /// alongside its scripts. Supplementary report info; filled at parse time, + /// not part of services.json. + #[serde(skip)] + pub bundled: Vec, + /// Each bundled executable as its own verifiable unit (its `exe` plus the + /// libraries reachable via its rpath), so the bundled runtime can be checked + /// against a firmware's libraries the same way a native component is. Filled + /// at parse time; supplementary (never gates the verdict). + #[serde(skip)] + pub bundled_bins: Vec>, } #[derive(Debug)] diff --git a/common/verify/src/ipk/component.rs b/common/verify/src/ipk/component.rs index 9429410..14c24fe 100644 --- a/common/verify/src/ipk/component.rs +++ b/common/verify/src/ipk/component.rs @@ -122,6 +122,7 @@ impl Verify for Component { }, libs: Default::default(), detection: None, + bundled: Default::default(), }; }; let bin = self.verify_bin(exe, find_library); @@ -183,6 +184,7 @@ impl Verify for Component { libs, // Filled in by Package::verify_for_firmware for non-native components. detection: None, + bundled: Default::default(), }; } } diff --git a/common/verify/src/ipk/mod.rs b/common/verify/src/ipk/mod.rs index 83421bd..09a3253 100644 --- a/common/verify/src/ipk/mod.rs +++ b/common/verify/src/ipk/mod.rs @@ -1,4 +1,4 @@ -use bin_lib::LibraryInfo; +use bin_lib::{BundledArtifact, LibraryInfo}; use fw_lib::WebEngine; use ipk_lib::{AppInfo, Component, Package, ServiceInfo}; use semver::Version; @@ -22,6 +22,10 @@ pub struct ComponentVerifyResult { /// Non-native technology detection + per-firmware compatibility. `None` for /// native components (which go through the exe/libs path instead). pub detection: Option, + /// A JS service's bundled native binaries, each verified against the + /// firmware's libraries like a native component. Supplementary — these + /// results never gate the package verdict. Empty for everything else. + pub bundled: Vec, } #[derive(Debug, Eq, PartialEq)] @@ -56,6 +60,9 @@ pub enum DetectionResult { /// Whether the firmware's Node.js natively provides the runtime APIs the /// service uses (advisory). api: CompatVerdict, + /// Native ELF files the service bundles (its own node/ffmpeg/.so). + /// Supplementary info; does not affect the verdict. + bundled: Vec, }, } @@ -143,6 +150,15 @@ impl VerifyForFirmware for Package { 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); + // Verify each bundled native binary like a native component, so the + // report can show whether the service's own runtime loads on this + // firmware. Supplementary: does not affect `svc_result.is_good()`. + svc_result.bundled = svc + .info + .bundled_bins + .iter() + .map(|component| component.verify(find_library)) + .collect(); } return result; } @@ -178,6 +194,7 @@ fn service_detection( available_node: node.cloned(), node: node_verdict, api, + bundled: svc.info.bundled.clone(), }); } diff --git a/common/webdetect/src/js.rs b/common/webdetect/src/js.rs index 5a6b034..6371f3b 100644 --- a/common/webdetect/src/js.rs +++ b/common/webdetect/src/js.rs @@ -6,9 +6,9 @@ //! means keywords/operators/identifiers inside strings, comments and regex //! literals are ignored. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashSet, VecDeque}; use std::fs; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use ress::prelude::*; @@ -64,6 +64,163 @@ pub(crate) fn collect_js(dir: &Path, depth: usize, out: &mut Vec<(String, String } } +/// Module-resolution extensions tried for an extensionless specifier and for a +/// directory's `index` file, in Node's precedence order. +const RESOLVE_EXTS: [&str; 3] = ["js", "cjs", "mjs"]; + +/// Gather the JS a Node service's entry point actually pulls in: starting from +/// `main` (default `index.js`), follow the **static** module graph via relative +/// `require`/`import`/`export … from`/`import(...)` specifiers, staying inside +/// `dir`. +/// +/// Unlike [`collect_js`] (used for a web app's flat bundle), this does *not* +/// slurp every `.js` under the tree. A service commonly ships vendored code — +/// `node_modules`, or a server it spawns on its **own** bundled Node — whose ES +/// level says nothing about what the firmware's Node runs. Only first-party code +/// reachable from `main` runs on the firmware Node, so only that is graded. +/// Bare specifiers (npm packages, built-ins) are deliberately not followed. +pub(crate) fn collect_service_js(dir: &Path, main: Option<&str>, out: &mut Vec<(String, String)>) { + let Some(entry) = resolve_module(dir, dir, main.unwrap_or("index.js")) else { + return; + }; + let mut visited: HashSet = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + queue.push_back(entry); + while let Some(path) = queue.pop_front() { + if out.len() >= MAX_JS_FILES { + return; + } + if !visited.insert(path.clone()) { + continue; + } + if fs::metadata(&path).map(|m| m.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES { + continue; + } + let Ok(bytes) = fs::read(&path) else { continue }; + let content = String::from_utf8_lossy(&bytes).into_owned(); + let base = path.parent().unwrap_or(dir); + for spec in extract_relative_specifiers(&content) { + if let Some(dep) = resolve_module(dir, base, &spec) { + if !visited.contains(&dep) { + queue.push_back(dep); + } + } + } + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + out.push((name, content)); + } +} + +/// Resolve a relative module specifier `spec` (or an entry `main`) against +/// `base`, Node-style: try the path as a JS file, then with a `.js/.cjs/.mjs` +/// extension, then as a directory's `index` file. Returns the resolved path only +/// when it exists and stays lexically within `root`. +fn resolve_module(root: &Path, base: &Path, spec: &str) -> Option { + let candidate = base.join(spec); + // 1. Exact path to an existing JS-family file (an explicit extension). + if candidate.is_file() { + return has_js_ext(&candidate).then(|| contain(root, &candidate)).flatten(); + } + // 2. Bare specifier + extension (`./foo` → `./foo.js`). Appends rather than + // replacing, so a dotted basename (`./foo.bar`) isn't mangled. + for ext in RESOLVE_EXTS { + let p = PathBuf::from(format!("{}.{ext}", candidate.to_string_lossy())); + if p.is_file() { + return contain(root, &p); + } + } + // 3. Directory index (`./dir` → `./dir/index.js`). + if candidate.is_dir() { + for ext in RESOLVE_EXTS { + let p = candidate.join(format!("index.{ext}")); + if p.is_file() { + return contain(root, &p); + } + } + } + None +} + +fn has_js_ext(path: &Path) -> bool { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("js") | Some("cjs") | Some("mjs") + ) +} + +/// Keep `path` only if it stays within `root` after lexically resolving `.`/`..` +/// (an extracted package has no on-disk symlinks, so this matches the real +/// tree). Guards against a specifier like `../../etc/passwd`. +fn contain(root: &Path, path: &Path) -> Option { + let root = lexical_normalize(root); + let path = lexical_normalize(path); + path.starts_with(&root).then_some(path) +} + +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 +} + +/// A token, reduced to just what the module-specifier lookback needs. +enum SpecTok { + Import, + OpenParen, + Ident(String), + Str(String), + Other, +} + +/// Extract the **relative** module specifiers (`./`, `../`) a source statically +/// references via `require('x')`, `import(...)`, `import 'x'`, and +/// `import`/`export … from 'x'`. Tokenizing means specifiers written as string +/// literals in comments or unrelated strings are not matched. +fn extract_relative_specifiers(content: &str) -> Vec { + let mut specs = Vec::new(); + // The last two meaningful (non-comment) tokens, for lookback. + let mut prev: Option = None; + let mut prev2: Option = None; + for item in Scanner::new(content) { + let Ok(item) = item else { break }; + let cur = match &item.token { + Token::Comment(_) => continue, + Token::String(s) => SpecTok::Str(s.as_ref().to_string()), + Token::Ident(id) => SpecTok::Ident(id.as_ref().to_string()), + Token::Keyword(Keyword::Import(_)) => SpecTok::Import, + Token::Punct(Punct::OpenParen) => SpecTok::OpenParen, + _ => SpecTok::Other, + }; + if let SpecTok::Str(s) = &cur { + let is_specifier = match (&prev, &prev2) { + // require('x') + (Some(SpecTok::OpenParen), Some(SpecTok::Ident(n))) if n == "require" => true, + // import('x') + (Some(SpecTok::OpenParen), Some(SpecTok::Import)) => true, + // import … from 'x' / export … from 'x' (`from` is contextual) + (Some(SpecTok::Ident(n)), _) if n == "from" => true, + // side-effect import 'x' + (Some(SpecTok::Import), _) => true, + _ => false, + }; + if is_specifier && (s.starts_with("./") || s.starts_with("../")) { + specs.push(s.clone()); + } + } + prev2 = prev.take(); + prev = Some(cur); + } + specs +} + /// Analyze JS sources for ES syntax level and static runtime-API usage. /// `html_module` marks a `