use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Read; use std::path::{Path, PathBuf}; use std::rc::Rc; use crate::authority::SourceAuthority; use crate::caps::PathTarget; use crate::package_config::{PackageConfig, Source}; use avril_core::Error; /// Which effects a resolution step may perform. `Acquire` may bind /// packages, load remote configs, and prepare npm roots; `PreparedOnly` /// uses only already-prepared metadata and never performs I/O. /// Shared by `LocalPackages`, `RemotePackages`, and `ModuleGraph` so the /// decision tree branches on one mode type at the edges only. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ResolveMode { Acquire, PreparedOnly, } impl ResolveMode { /// `resolve_target` and export probing allow missing files exactly in /// prepared-only metadata resolution. pub fn allow_missing(self) -> bool { matches!(self, Self::PreparedOnly) } } struct Package { target: PathTarget, config: PackageConfig, bindings: RefCell>>, source: String, } /// Shared prefix of local classification: the owning package plus what its /// own `package_mapping` selects, before any binding acquisition. enum LocalStep { Done(Resolution), Export(Rc, String), Bindings(Rc), } pub enum Resolution { Specifier(String), Module(String), Remote(String), } pub(crate) enum PackageMapping { Specifier(String), Export(String), } pub(crate) fn package_mapping( config: &PackageConfig, specifier: &str, referrer: &url::Url, ) -> Result, String> { if let Some(target) = config .import_map .resolve(specifier, referrer) .map_err(|error| error.to_string())? { return Ok(Some(PackageMapping::Specifier(target))); } if let Some(name) = &config.name && let Some(key) = export_key(specifier, name) { return Ok(Some(PackageMapping::Export(key))); } Ok(config .registry_map .resolve(specifier, referrer) .map_err(|error| error.to_string())? .map(PackageMapping::Specifier)) } pub(crate) fn select_binding( bindings: Option<&BTreeMap>, specifier: &str, ) -> Option<(String, Source)> { bindings .into_iter() .flatten() .find_map(|(binding, source)| { export_key(specifier, binding).map(|key| (key, source.clone())) }) } pub struct LocalPackages { pub(crate) config_inputs: RefCell>, authority: SourceAuthority, directories: RefCell>>>, tsconfigs: RefCell>, } impl LocalPackages { pub fn new(authority: SourceAuthority) -> Self { Self { authority, config_inputs: RefCell::default(), directories: RefCell::new(HashMap::new()), tsconfigs: RefCell::new(HashMap::new()), } } fn package(&self, referrer: &str) -> Result>, Error> { let referrer = Path::new(referrer); let mut directory = referrer .parent() .ok_or_else(|| Error::Module("Expected an absolute referrer path".into()))?; let package = loop { // The reserved `.avril/npm` tree is never a config authority, // even if a published archive plants an avril.conf inside it. // Other `.avril` data (editor output, gitignore) keeps the // existing discovery behavior. if under_npm_tree(directory) { match directory.parent() { Some(parent) => { directory = parent; continue; } None => break None, } } let target = PathTarget::resolve(directory) .map_err(|_| Error::Module("Cannot discover package".into()))?; if target.as_path() != directory || self.authority.check_local(&target).is_err() { break None; } let handle = crate::sources::open_directory(directory) .map_err(|_| Error::Module("Cannot discover package".into()))?; if !trusted( &handle .dir_metadata() .map_err(|_| Error::Module("Cannot inspect package directory".into()))?, ) { break None; } let path = directory.join("avril.conf"); let target = PathTarget::for_entry(&path) .map_err(|_| Error::Module("Cannot discover package".into()))?; // A denial must not disclose whether the config (or a symlink referent) exists. if self.authority.check_local(&target).is_err() { break None; } let package = self.load(directory, &handle, target, false)?; if package.is_some() { break package; } match directory.parent() { Some(parent) => directory = parent, None => break None, } }; Ok(package) } fn load( &self, directory: &Path, handle: &cap_std::fs::Dir, target: PathTarget, required: bool, ) -> Result>, Error> { if target.as_path().parent() != Some(directory) { return Err(Error::Module( "Config directory changed during discovery".into(), )); } let cached = self.directories.borrow().get(directory).cloned(); if let Some(Some(package)) = cached { self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; return Ok(Some(package)); } if matches!(cached, Some(None)) { return if required { Err(Error::Module(format!( "Dependency directory must contain avril.conf: {}", directory.display() ))) } else { Ok(None) }; } let path = target.as_path(); self.authority .check_local(&target) .map_err(|error| Error::Module(error.to_string()))?; let package = match read_config(handle, path).map_err(|error| Error::Module(error.to_string()))? { Some(source) => { self.authority .check_local(&target) .map_err(|error| Error::Module(error.to_string()))?; let base = url::Url::from_file_path(path) .map_err(|_| Error::Module("Config paths must be absolute".into()))?; let render = |error: crate::config::Error| { Error::Module(error.render(&path.to_string_lossy(), &source)) }; let config = PackageConfig::parse(&source, &base).map_err(render)?; self.authority .check_local(&target) .map_err(|error| Error::Module(error.to_string()))?; Some(Rc::new(Package { target, config, bindings: RefCell::default(), source, })) } None if required => { return Err(Error::Module(format!( "Dependency directory must contain avril.conf: {}", directory.display() ))); } None => None, }; self.directories .borrow_mut() .insert(directory.to_owned(), package.clone()); Ok(package) } fn dependency(&self, directory: &Path, specifier: &str) -> Result, Error> { let target = PathTarget::resolve(directory).map_err(|error| self.path_error(specifier, error))?; self.authority .check_local(&target) .map_err(|error| self.path_error(specifier, error))?; let directory = target.as_path(); let handle = crate::sources::open_directory(directory) .map_err(|error| self.path_error(specifier, error))?; if !trusted( &handle .dir_metadata() .map_err(|error| self.path_error(specifier, error))?, ) { return Err(Error::Module(format!( "Untrusted dependency directory: {specifier}" ))); } let target = PathTarget::for_entry(directory.join("avril.conf")) .map_err(|error| self.path_error(specifier, error))?; self.authority .check_local(&target) .map_err(|error| self.path_error(specifier, error))?; let package = self.load(directory, &handle, target, true)?.unwrap(); if package.config.exports.is_empty() { return Err(Error::Module(format!( "Dependency {specifier:?} has no exports: {}", package.target.as_path().display() ))); } Ok(package) } fn path_error(&self, specifier: &str, error: impl std::fmt::Display) -> Error { if self.authority.unrestricted_local() { Error::Module(format!("{specifier}: {error}")) } else if self.authority.is_read() { Error::Module(format!("PermissionDenied: read {specifier}")) } else { Error::Module(format!("PermissionDenied: js-exec {specifier}")) } } /// Loads the package owning `referrer`, binding its dependencies before any guest code runs. pub async fn initialize( &self, referrer: &str, remote: &crate::remote_packages::RemotePackages, ) -> Result<(), Error> { if let Some(package) = self.package(referrer)? { self.bind(&package, remote).await?; } Ok(()) } async fn bind( &self, package: &Package, remote: &crate::remote_packages::RemotePackages, ) -> Result<(), Error> { if package.bindings.borrow().is_none() { let bindings = remote.bindings(&package.config).await.map_err(|error| { Error::Module( error.render(&package.target.as_path().to_string_lossy(), &package.source), ) })?; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; *package.bindings.borrow_mut() = Some(bindings); } Ok(()) } pub fn type_override( &self, specifier: &str, referrer: &str, owner: &str, ) -> Result, Error> { let Some(package) = self.package(owner)? else { return Ok(None); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; self.config_inputs .borrow_mut() .insert(package.target.as_path().to_owned()); let referrer_url = url::Url::from_file_path(referrer) .map_err(|_| Error::Module("Referrers must be absolute paths".into()))?; let result = package .config .types .imports .resolve(specifier, &referrer_url) .map_err(|error| Error::Module(error.to_string()))?; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; Ok(result) } pub fn type_globals(&self, referrer: &str) -> Result, Error> { let Some(package) = self.package(referrer)? else { return Ok(Vec::new()); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; self.config_inputs .borrow_mut() .insert(package.target.as_path().to_owned()); let globals = package.config.types.globals.clone(); self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; Ok(globals) } pub fn jsx_config(&self, referrer: &str) -> Result { let package = self.package(referrer)?; let boundary = package .as_ref() .and_then(|package| package.target.as_path().parent()); crate::project::jsx_config( &self.authority, Path::new(referrer), boundary, &package .as_ref() .map(|package| package.config.compiler_options.clone()) .unwrap_or_default(), &self.config_inputs, &self.tsconfigs, ) } pub fn root(&self, referrer: &str) -> Result { if self.authority.is_read() { self.root_for_repl(referrer) } else { self.discover_root(referrer, false) } } pub fn root_for_repl(&self, referrer: &str) -> Result { let fallback = Path::new(referrer) .parent() .ok_or_else(|| Error::Module("Expected a REPL directory".into()))?; Ok(self .package(referrer)? .and_then(|package| package.target.as_path().parent().map(Path::to_owned)) .unwrap_or_else(|| fallback.to_owned())) } /// The trusted config a REPL session belongs to, through the same /// discovery and trust checks package loading applies. pub fn config_for_repl(&self, referrer: &str) -> Result, Error> { Ok(self .package(referrer)? .map(|package| package.target.as_path().to_owned())) } pub fn root_for_add(&self, referrer: &str) -> Result { self.discover_root(referrer, true) } fn discover_root(&self, referrer: &str, strict: bool) -> Result { let fallback = Path::new(referrer) .parent() .ok_or_else(|| Error::Module("Expected an entry directory".into()))?; let mut directory = Some(fallback); while let Some(path) = directory { if under_npm_tree(path) { directory = path.parent(); continue; } let handle = crate::sources::open_directory(path) .map_err(|error| Error::Module(error.to_string()))?; if !trusted( &handle .dir_metadata() .map_err(|error| Error::Module(error.to_string()))?, ) { break; } match handle.symlink_metadata("avril.conf") { Ok(metadata) if metadata.is_file() && trusted(&metadata) => { return Ok(path.to_owned()); } Ok(_) if strict => { return Err(Error::Module(format!( "Untrusted config: {}", path.join("avril.conf").display() ))); } Ok(_) => break, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(Error::Module(error.to_string())), } directory = path.parent(); } Ok(fallback.to_owned()) } /// The `npm:` dependency targets declared by the package owning /// `referrer`, for joint graph preparation. Empty without a config. pub fn npm_dependencies(&self, referrer: &str) -> Result, Error> { let Some(package) = self.package(referrer)? else { return Ok(Vec::new()); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; Ok(package .config .dependencies .iter() .filter_map(|dependency| match &dependency.source { Source::Npm(request) => Some(request.request_key()), _ => None, }) .collect()) } pub fn remote_dependencies(&self, referrer: &str) -> Result, Error> { let Some(package) = self.package(referrer)? else { return Ok(Vec::new()); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; Ok(package .config .dependencies .iter() .filter_map(|dependency| match &dependency.source { Source::Http(directory) => Some(directory.clone()), _ => None, }) .collect()) } /// Shared synchronous prefix of both modes: the owning package and /// what its own imports/scopes/registry map select. Binding lookup /// and remote exports stay mode-specific below. fn step(&self, specifier: &str, referrer: &str) -> Result, Error> { let Some(package) = self.package(referrer)? else { return Ok(None); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; let url = url::Url::from_file_path(referrer) .map_err(|_| Error::Module("Expected an absolute referrer path".into()))?; match package_mapping(&package.config, specifier, &url).map_err(|error| { Error::Module(format!("{}: {error}", package.target.as_path().display())) })? { Some(PackageMapping::Specifier(target)) => { Ok(Some(LocalStep::Done(Resolution::Specifier(target)))) } Some(PackageMapping::Export(key)) => Ok(Some(LocalStep::Export(package, key))), None => Ok(Some(LocalStep::Bindings(package))), } } /// Prepared bindings as-is: shared by both modes once acquisition (if /// any) has run. fn selected(&self, package: &Package, specifier: &str) -> Option<(String, Source)> { select_binding(package.bindings.borrow().as_ref(), specifier) } pub async fn resolve( &self, specifier: &str, referrer: &str, remote: &crate::remote_packages::RemotePackages, ) -> Result, Error> { let step = self.step(specifier, referrer)?; match step { None => Ok(None), Some(LocalStep::Done(resolution)) => Ok(Some(resolution)), Some(LocalStep::Export(package, key)) => self .export(&package, &key, specifier, ResolveMode::Acquire) .map(Some), Some(LocalStep::Bindings(package)) => { self.bind(&package, remote).await?; match self.selected(&package, specifier) { Some((key, Source::Local(directory))) => { let target = self.dependency(&directory, specifier)?; self.export(&target, &key, specifier, ResolveMode::Acquire) .map(Some) } Some((key, Source::Http(directory))) => { remote.export(&directory, &key, specifier).await.map(Some) } _ => Ok(None), } } } } pub fn resolve_import_meta( &self, specifier: &str, referrer: &str, remote: &crate::remote_packages::RemotePackages, ) -> Result, Error> { let step = self.step(specifier, referrer)?; match step { None => Ok(None), Some(LocalStep::Done(resolution)) => Ok(Some(resolution)), Some(LocalStep::Export(package, key)) => self .export(&package, &key, specifier, ResolveMode::PreparedOnly) .map(Some), Some(LocalStep::Bindings(package)) => match self.selected(&package, specifier) { Some((key, Source::Local(directory))) => { let target = self.dependency(&directory, specifier)?; self.export(&target, &key, specifier, ResolveMode::PreparedOnly) .map(Some) } Some((key, Source::Http(directory))) => remote .resolve_export_meta(&directory, &key, specifier) .map(Some), _ => Ok(None), }, } } fn export( &self, package: &Package, key: &str, specifier: &str, mode: ResolveMode, ) -> Result { let export = package.config.exports.get(key).ok_or_else(|| { Error::Module(format!( "Package has no export {key:?} for {specifier:?}: {}", package.target.as_path().display() )) })?; let path = export .target .to_file_path() .map_err(|_| Error::Module("Expected a local export".into()))?; let target = if mode.allow_missing() { PathTarget::for_creation(path) } else { PathTarget::resolve(path) } .map_err(|error| self.path_error(specifier, error))?; self.authority .check_local(&target) .map_err(|error| self.path_error(specifier, error))?; let directory = package.target.as_path().parent().unwrap(); if !target.as_path().starts_with(directory) { return Err(Error::Module( crate::config::Error { kind: crate::config::ErrorKind::Syntax, message: "Export target escapes its package".into(), span: export.span.clone(), previous: None, } .render(&package.target.as_path().to_string_lossy(), &package.source), )); } let name = target .into_path() .into_os_string() .into_string() .map_err(|_| Error::Module("Module paths must be valid UTF-8".into()))?; self.authority .check_local(&package.target) .map_err(|error| self.path_error(specifier, error))?; Ok(Resolution::Module(name)) } pub fn metadata(&self, referrer: &str) -> Result, Error> { let Some(package) = self.package(referrer)? else { return Ok(None); }; self.authority .check_local(&package.target) .map_err(|error| Error::Module(error.to_string()))?; let exports: std::collections::BTreeMap<_, _> = package .config .exports .iter() .map(|(key, export)| (key, export.target.to_file_path().unwrap())) .collect(); let bindings = package.bindings.borrow(); let dependencies: BTreeMap<_, _> = bindings .as_ref() .into_iter() .flatten() .map(|(name, source)| match source { Source::Local(directory) => (name, serde_json::json!(directory)), Source::Jsr(request) => (name, serde_json::json!(request.specifier)), Source::Npm(request) => (name, serde_json::json!(request.request_key())), Source::Http(directory) => (name, serde_json::json!(directory.as_str())), }) .collect(); Ok(Some( serde_json::json!({"config": url::Url::from_file_path(package.target.as_path()).ok().map(String::from), "name": package.config.name, "version": package.config.version, "description": package.config.description, "exports": exports, "dependencies": dependencies}), )) } } /// Reads a dependency's declared name as host configuration: guest execution grants do not /// apply, but trust checks do. The config is neither bound nor cached, so later guest loads /// still pass through `LocalPackages::load` and its capability checks. /// Read mode must authorize the config itself, including diagnostic excerpts. pub(crate) fn declared_name( directory: &Path, authority: &SourceAuthority, ) -> Result, String> { let target = if authority.is_read() { let target = PathTarget::for_entry(directory.join("avril.conf")) .map_err(|error| error.to_string())?; authority .check_local(&target) .map_err(|error| error.to_string())?; Some(target) } else { None }; let path = match &target { Some(target) => target.as_path().to_owned(), None => PathTarget::resolve(directory) .map_err(|error| error.to_string())? .into_path() .join("avril.conf"), }; let directory = path.parent().unwrap(); let handle = crate::sources::open_directory(directory).map_err(|error| error.to_string())?; if !trusted(&handle.dir_metadata().map_err(|error| error.to_string())?) { return Err(format!( "Untrusted dependency directory: {}", directory.display() )); } let source = read_config(&handle, &path)?.ok_or_else(|| { format!( "Dependency directory must contain avril.conf: {}", directory.display() ) })?; let base = url::Url::from_file_path(&path).map_err(|_| "Config paths must be absolute")?; let parsed = PackageConfig::parse(&source, &base); if let Some(target) = &target { authority .check_local(target) .map_err(|error| error.to_string())?; } parsed .map(|config| config.name) .map_err(|error| error.render(&path.to_string_lossy(), &source)) } fn read_config(handle: &cap_std::fs::Dir, path: &Path) -> Result, String> { match handle.symlink_metadata("avril.conf") { Ok(metadata) if metadata.is_symlink() || !metadata.is_file() => { return Err(format!( "Config must be a regular non-symlink file: {}", path.display() )); } Ok(metadata) if !trusted(&metadata) => { return Err(format!("Untrusted config: {}", path.display())); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(_) => return Err(format!("Cannot discover config {}", path.display())), } let file = crate::sources::open_regular(handle, "avril.conf") .map_err(|_| format!("Cannot read config {}", path.display()))?; let metadata = file .metadata() .map_err(|_| "Cannot inspect config".to_owned())?; if !trusted(&metadata) { return Err(format!("Untrusted config: {}", path.display())); } let mut source = String::new(); file.take(32 * 1024 * 1024 + 1) .read_to_string(&mut source) .map_err(|_| format!("Cannot read config {}", path.display()))?; if source.len() > 32 * 1024 * 1024 { return Err("Config is too large".into()); } Ok(Some(source)) } pub(crate) fn trusted(metadata: &cap_std::fs::Metadata) -> bool { #[cfg(unix)] { use cap_std::fs::MetadataExt; // Root-owned installs are trusted; shared writable directories are not config authorities. (metadata.uid() == 0 || metadata.uid() == unsafe { libc::geteuid() }) && metadata.mode() & 0o002 == 0 } #[cfg(not(unix))] { let _ = metadata; true } } /// Whether `path` lies at or below a reserved `.avril/npm` tree. Only the /// npm instance tree is skipped during config discovery; every other /// `.avril` path keeps its existing behavior. fn under_npm_tree(path: &Path) -> bool { let mut parts = path.components().filter_map(|part| match part { std::path::Component::Normal(name) => name.to_str(), _ => None, }); let mut previous = parts.next(); for current in parts { if previous == Some(".avril") && current == "npm" { return true; } previous = Some(current); } false } pub(crate) fn export_key(specifier: &str, binding: &str) -> Option { if specifier == binding { Some(".".into()) } else { specifier .strip_prefix(binding) .and_then(|suffix| suffix.strip_prefix('/')) .map(|suffix| format!("./{suffix}")) } }