use std::path::{Path, PathBuf}; use avril_core::{Error, ModuleLoader}; use crate::caps::PathTarget; use crate::modules::{ModuleGraph, ResolutionMode, lexical_absolute}; use oxc::allocator::Allocator; use oxc::parser::Parser; use oxc::span::SourceType; /// Static CommonJS named exports. pub(crate) fn named_exports(source: &str) -> Vec { use oxc::ast::ast::{ AssignmentTarget, CallExpression, Expression, ObjectPropertyKind, PropertyKey, }; use oxc::ast_visit::{Visit, walk}; use oxc::syntax::identifier::is_identifier_name; fn valid_name(name: &str) -> bool { // Synthetic ESM wrappers need importable identifiers; string-keyed // properties like `exports["foo-bar"]` stay available via `default`. !name.is_empty() && is_identifier_name(name) } fn string_key(key: &PropertyKey<'_>) -> Option { match key { PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.to_string()), PropertyKey::StringLiteral(literal) => Some(literal.value.to_string()), _ => None, } } fn expr_is_exports(expr: &Expression<'_>) -> bool { matches!(expr, Expression::Identifier(identifier) if identifier.name == "exports") } fn expr_is_module_exports(expr: &Expression<'_>) -> bool { match expr { Expression::StaticMemberExpression(member) => { matches!(&member.object, Expression::Identifier(object) if object.name == "module") && member.property.name == "exports" } _ => false, } } #[derive(Default)] struct Collector { names: std::collections::BTreeSet, } impl<'a> Visit<'a> for Collector { fn visit_assignment_expression(&mut self, expr: &oxc::ast::ast::AssignmentExpression<'a>) { let mut direct: Option = None; let mut object_assign: Option<&Expression<'a>> = None; match &expr.left { AssignmentTarget::StaticMemberExpression(member) => { let prop = member.property.name.to_string(); if expr_is_exports(&member.object) || expr_is_module_exports(&member.object) { direct = Some(prop); } else if matches!(&member.object, Expression::Identifier(object) if object.name == "module") && member.property.name == "exports" { // `module.exports = …` itself; object literal handled below. object_assign = Some(&expr.right); } } AssignmentTarget::ComputedMemberExpression(member) => { let prop = match &member.expression { Expression::StringLiteral(literal) => Some(literal.value.to_string()), _ => None, }; if let Some(prop) = prop && (expr_is_exports(&member.object) || expr_is_module_exports(&member.object)) { direct = Some(prop); } // `module.exports = …` via computed `module["exports"]` is rare; skip. } _ => {} } if let Some(name) = direct && valid_name(&name) { self.names.insert(name); } // `module.exports = { a, b: …, "c": … }`. if let Some(right) = object_assign && let Expression::ObjectExpression(object) = right { for property in &object.properties { match property { ObjectPropertyKind::ObjectProperty(property) => { if property.computed { continue; } if let Some(name) = string_key(&property.key) && valid_name(&name) { self.names.insert(name); } } ObjectPropertyKind::SpreadProperty(_) => {} } } } walk::walk_assignment_expression(self, expr); } fn visit_call_expression(&mut self, expr: &CallExpression<'a>) { if let Expression::StaticMemberExpression(callee) = &expr.callee && matches!(&callee.object, Expression::Identifier(object) if object.name == "Object") && callee.property.name == "defineProperty" && expr.arguments.len() >= 2 { let target = expr.arguments.first().and_then(|arg| arg.as_expression()); let name = expr.arguments.get(1).and_then(|arg| arg.as_expression()); let is_exports_target = target.is_some_and(|target| { expr_is_exports(target) || expr_is_module_exports(target) }); if is_exports_target && let Some(Expression::StringLiteral(literal)) = name { let name = literal.value.to_string(); if valid_name(&name) { self.names.insert(name); } } } walk::walk_call_expression(self, expr); } } let allocator = Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::cjs()).parse(); // On syntax errors return what was found; execution reports the error. let mut collector = Collector::default(); collector.visit_program(&parsed.program); collector.names.into_iter().collect() } enum LocalLookup { Missing, Err(Error), } impl ModuleGraph { /// Shared synchronous source for `require` demand loads: verified npm /// bytes via `npm_source` (virtual-URL authority + integrity) or local /// files via `read_text` (source authority, no broader grant needed). /// Never performs network I/O. pub(crate) fn sync_source(&self, name: &str) -> Result { self.check_access(name)?; let source = if self.npm.owning_instance(Path::new(name)).is_some() { self.npm .npm_source(Path::new(name)) .map_err(|error| Error::Module(error.to_string()))? } else if self.npm.is_reserved_npm_path(Path::new(name)) { return Err(Error::Module(format!( "npm cache path is not owned by a prepared instance: {name}" ))); } else { crate::sources::read_text(&self.target(name)?, &self.authority) .map_err(|error| self.path_error(name, error))? }; self.check_access(name)?; Ok(source) } pub(crate) fn require_referrer_path(referrer: &str) -> Option { let path = Path::new(referrer); if path.is_absolute() { return lexical_absolute(path).ok(); } if let Ok(url) = url::Url::parse(referrer) && url.scheme() == "file" { return lexical_absolute(&url.to_file_path().ok()?).ok(); } None } fn resolve_npm_for_anchor( &self, anchor: &str, npm_specifier: &str, original: &str, ) -> Result { let root = self .packages .root(anchor) .map_err(|error| Error::Module(error.to_string()))?; let handles = self.npm_handles.borrow(); let Some(handle) = handles.get(&root) else { return Err(Error::Module(format!( "npm package is not prepared in this context (sync require cannot acquire new packages): {original}" ))); }; self.npm .resolve_in(handle, npm_specifier, ResolutionMode::Require) .map(|resolved| { resolved .path .to_str() .expect("npm paths are UTF-8") .to_owned() }) .map_err(|error| Error::Module(error.to_string())) } fn resolve_local_require( &self, specifier: &str, referrer_path: &Path, ) -> Result { let path = if specifier .get(..5) .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) { let url = url::Url::parse(specifier) .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))?; if url.query().is_some() || url.fragment().is_some() { return Err(Error::Module( "File URL queries and fragments are not supported yet".into(), )); } url.to_file_path() .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))? } else { let path = Path::new(specifier); if path.is_absolute() { path.to_owned() } else if specifier.starts_with("./") || specifier.starts_with("../") { referrer_path.parent().unwrap_or(Path::new(".")).join(path) } else { return Err(Error::Module(format!( "Unsupported module specifier for sync require (use import() instead): {specifier}" ))); } }; match self.local_file_require(&path, 0) { Ok(name) => Ok(name), Err(LocalLookup::Missing) => { Err(self.path_error(specifier, format!("Module not found: {}", path.display()))) } Err(LocalLookup::Err(error)) => Err(error), } } fn local_file_require(&self, path: &Path, depth: usize) -> Result { if depth > 5 { return Err(LocalLookup::Err(Error::Module( "Local require lookup is too deep".into(), ))); } match self.local_candidate(path) { Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err), Err(LocalLookup::Missing) => {} Err(other) => return Err(other), } for extension in [".js", ".json", ".node"] { let mut candidate = path.as_os_str().to_owned(); candidate.push(extension); let candidate_path = PathBuf::from(candidate); match self.local_candidate(&candidate_path) { Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err), Err(LocalLookup::Missing) => continue, Err(other) => return Err(other), } } if self.local_is_dir(path) { match self.local_dir_main(path) { Ok(Some(main)) => { let candidate = path.join(main); match self.local_file_require(&candidate, depth + 1) { Ok(name) => return Ok(name), Err(LocalLookup::Missing) => {} Err(other) => return Err(other), } } Ok(None) => {} Err(error) => return Err(LocalLookup::Err(error)), } for index in ["index.js", "index.json", "index.cjs", "index.node"] { match self.local_candidate(&path.join(index)) { Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err), Err(LocalLookup::Missing) => continue, Err(other) => return Err(other), } } } Err(LocalLookup::Missing) } fn check_native(name: String) -> Result { if name.to_ascii_lowercase().ends_with(".node") { return Err(Error::Module(format!( "Native addons are not supported: {name}" ))); } Ok(name) } fn local_candidate(&self, path: &Path) -> Result { let logical = lexical_absolute(path).map_err(LocalLookup::Err)?; if self.npm.is_reserved_npm_path(&logical) { return self.npm_local_candidate(&logical); } let target = match PathTarget::resolve(path) { Ok(target) => target, Err(_) => return Err(LocalLookup::Missing), }; if self.npm.is_reserved_npm_path(target.as_path()) { return self.npm_local_candidate(target.as_path()); } if let Err(error) = self.authority.check_local(&target) { return Err(LocalLookup::Err( self.path_error(&path.display().to_string(), error), )); } let resolved = target.as_path().to_owned(); if !std::fs::symlink_metadata(&resolved).is_ok_and(|meta| meta.is_file()) { return Err(LocalLookup::Missing); } resolved .into_os_string() .into_string() .map_err(|_| LocalLookup::Err(Error::Module("Module paths must be valid UTF-8".into()))) } fn npm_local_candidate(&self, path: &Path) -> Result { match self.npm.resolve_require_path(path) { Ok(Some(module)) => module.path.into_os_string().into_string().map_err(|_| { LocalLookup::Err(Error::Module("Module paths must be valid UTF-8".into())) }), Ok(None) => Err(LocalLookup::Missing), Err(error) => Err(LocalLookup::Err(Error::Module(error.to_string()))), } } fn local_is_dir(&self, path: &Path) -> bool { PathTarget::resolve(path) .ok() .filter(|target| self.authority.check_local(target).is_ok()) .is_some_and(|target| target.as_path().is_dir()) } fn local_dir_main(&self, dir: &Path) -> Result, Error> { let package = dir.join("package.json"); let target = match PathTarget::resolve(&package) { Ok(target) => target, Err(_) => return Ok(None), }; let Some(name) = target.as_path().to_str() else { return Ok(None); }; let source = self.sync_source(name)?; let value: serde_json::Value = serde_json::from_str(&source).map_err(|error| { Error::Module(format!( "{}: invalid package.json: {error}", package.display() )) })?; let Some(main) = value.get("main").and_then(|entry| entry.as_str()) else { return Ok(None); }; if main.is_empty() || main.contains('\0') { return Err(Error::Module(format!( "{}: invalid package.json main", package.display() ))); } if main.contains("../") || main.starts_with("..") { return Ok(None); } let main = main.strip_prefix("./").unwrap_or(main); if main.is_empty() || main .split('/') .any(|part| part.is_empty() || part == "." || part == "..") { return Ok(None); } Ok(Some(PathBuf::from(main))) } pub(crate) fn require_resolve_sync( &self, specifier: &str, referrer: &str, ) -> Result { if specifier.starts_with("ext:") { return Err(Error::Module("Extension modules are private".into())); } if specifier.starts_with("node:") || specifier == "node" { return Err(Error::Module(format!( "node: builtin modules are not supported: {specifier}" ))); } if specifier.starts_with("avril:") { return Ok(specifier.to_owned()); } // Never expose internal extension modules via any path. // `require_resolve` itself performs no I/O beyond verified metadata // and canonicalized filesystem probes; capability checks happen in // the core `require` wrapper (and `require.resolve`) around this. if let Some(referrer_path) = Self::require_referrer_path(referrer) { if self.npm.is_reserved_npm_path(&referrer_path) { if self.npm.owning_instance(&referrer_path).is_none() { return Err(Error::Module(format!( "npm cache path is not owned by a prepared instance: {}", referrer_path.display() ))); } return self .npm .resolve_from_file(specifier, &referrer_path, ResolutionMode::Require) .map(|resolved| { resolved .path .to_str() .expect("npm paths are UTF-8") .to_owned() }) .map_err(|error| Error::Module(error.to_string())); } let anchor = referrer_path .to_str() .ok_or_else(|| Error::Module("Module paths must be valid UTF-8".into()))?; if let Some(mapped) = self.packages .resolve_import_meta(specifier, anchor, &self.remote)? { match mapped { crate::local_packages::Resolution::Specifier(target) => { if target.starts_with("npm:") { return self.resolve_npm_for_anchor(anchor, &target, specifier); } if target.starts_with("ext:") { return Err(Error::Module("Extension modules are private".into())); } if target.starts_with("node:") || target == "node" { return Err(Error::Module(format!( "node: builtin modules are not supported: {specifier}" ))); } if target.starts_with("avril:") || target.starts_with("jsr:") || target.starts_with("http:") || target.starts_with("https:") { return Err(Error::Module(format!( "Unsupported module specifier for sync require (use import() instead): {specifier}" ))); } return self.resolve_local_require(&target, &referrer_path); } crate::local_packages::Resolution::Module(name) => { if name.starts_with("ext:") { return Err(Error::Module("Extension modules are private".into())); } return Ok(name); } crate::local_packages::Resolution::Remote(_) => { return Err(Error::Module(format!( "Unsupported module specifier for sync require (use import() instead): {specifier}" ))); } } } if specifier.starts_with("npm:") { return self.resolve_npm_for_anchor(anchor, specifier, specifier); } return self.resolve_local_require(specifier, &referrer_path); } Err(Error::Module(format!( "Unsupported module specifier for sync require (use import() instead): {specifier}" ))) } } #[cfg(test)] mod require_referrer_tests { use super::ModuleGraph; #[test] fn referrer_paths_are_portable() { assert!(ModuleGraph::require_referrer_path("/tmp/a.cjs").is_some()); assert!(ModuleGraph::require_referrer_path("file:///tmp/a.cjs").is_some()); assert!(ModuleGraph::require_referrer_path("./a.cjs").is_none()); assert!(ModuleGraph::require_referrer_path("https://example.test/a.js").is_none()); assert!(ModuleGraph::require_referrer_path("avril:std").is_none()); } #[cfg(windows)] #[test] fn windows_absolute_paths_resolve() { assert!(ModuleGraph::require_referrer_path(r"C:\proj\a.cjs").is_some()); } }