use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use super::package::{ NpmImportsTarget, NpmManifest, NpmModuleKind, NpmPackageScope, NpmPackageType, classify_npm_module, npm_conditions, parse_npm_manifest, parse_npm_scope, platform_satisfied, resolve_exports_target, resolve_imports_target, split_bare, }; use super::{ MAX_NPM_CONCURRENT_DOWNLOADS, NpmArtifact, NpmIdentity, NpmRequest, npm_virtual_module, parse_npm_request, storage, validate_npm_name, }; use crate::registry::{Access, NpmInstancePin, PackageStore}; /// Failures preparing and resolving npm package graphs. #[derive(Clone, Debug, thiserror::Error)] pub enum ResolutionError { #[error("Frozen lockfile is missing {name}")] FrozenLockfileMissing { name: String }, #[error("Invalid npm export subpath: {subpath}")] InvalidNpmExportSubpath { subpath: String }, #[error("Invalid npm lock identity")] InvalidNpmLockIdentity, #[error("Invalid npm relative import: {specifier:?}")] InvalidNpmRelativeImport { specifier: String }, #[error("Invalid npm version range {range:?}: {detail}")] InvalidNpmVersionRange { range: String, detail: String }, #[error("Invalid TypeScript version {version:?}: {detail}")] InvalidTypeScriptVersion { version: String, detail: String }, #[error("Package {name} uses typesVersions; call set_typescript_version first")] TypeScriptVersionRequired { name: String }, #[error("JSON modules are not supported: {rel}")] JsonModuleUnsupported { rel: String }, #[error("Locked npm instance changed for {id}")] LockedNpmInstanceChanged { id: String }, #[error("Lockfile changed concurrently for {key}")] LockfileChanged { key: String }, #[error("Native addons are not supported: {rel}/index.node")] NativeAddonUnsupported { rel: String }, #[error("node: builtin modules are not supported: {specifier}")] NodeBuiltinUnsupported { specifier: String }, #[error("npm browser remap of {specifier:?} is cyclic or too deep")] NpmBrowserRemapCyclic { specifier: String }, #[error("npm browser remap target is not in the package manifest: {target:?}")] NpmBrowserRemapTargetNotInManifest { target: String }, #[error("npm cache path is not owned by a prepared instance: {}", path.display())] NpmCachePathUnowned { path: std::path::PathBuf }, #[error("npm declaration has no executable code: {rel}")] NpmDeclarationNoCode { rel: String }, #[error("npm dependency graph is too deep")] NpmDependencyGraphTooDeep, #[error("npm dependency graph is too large")] NpmDependencyGraphTooLarge, #[error("npm dependency {alias:?} could not be resolved")] NpmDependencyUnresolvable { alias: String }, #[error("npm dependency {alias:?} uses unsupported protocol {protocol:?}")] NpmDependencyUnsupportedProtocol { alias: String, protocol: String }, #[error("npm export target escapes its package: {target:?}")] NpmExportTargetEscapes { target: String }, #[error("npm export target is not in the package manifest: {target:?}")] NpmExportTargetNotInManifest { target: String }, #[error("npm exports mixes subpath and condition keys")] NpmExportsMixesKeys, #[error("npm file integrity mismatch: {rel}")] NpmFileIntegrityMismatch { rel: String }, #[error("npm file is not in the package manifest: {path}")] NpmFileNotInManifest { path: String }, #[error("Frozen lockfile is missing npm instance {id}")] NpmFrozenLockfileMissingInstance { id: String }, #[error("npm import target is not in the package manifest: {specifier:?}")] NpmImportTargetNotInManifest { specifier: String }, #[error("npm imports target {target:?} is not a declared dependency")] NpmImportsTargetUndeclared { target: String }, #[error("npm instance is not prepared: {id}")] NpmInstanceNotPrepared { id: String }, #[error("npm module is not UTF-8: {}", path.display())] NpmModuleNotUtf8 { path: std::path::PathBuf }, #[error("npm package {name} has no export {export:?}")] NpmPackageMissingExport { name: String, export: String }, #[error("npm package {name} has no file {export:?}")] NpmPackageMissingFile { name: String, export: String }, #[error("npm package {name} has no import {specifier:?}")] NpmPackageMissingImport { name: String, specifier: String }, #[error("npm package {name} has no usable entry point")] NpmPackageNoEntryPoint { name: String }, #[error("npm package {name:?} is not a declared dependency of {owner}")] NpmPackageNotDeclared { name: String, owner: String }, #[error("npm package is not prepared: {request_key}")] NpmPackageNotPrepared { request_key: String }, #[error("npm package is not prepared: {request}")] NpmPackageNotPreparedRequest { request: String }, #[error("npm package is not prepared: {target}")] NpmPackageNotPreparedTarget { target: String }, #[error("npm package is not supported here: {request_key}")] NpmPackageNotSupportedHere { request_key: String }, #[error("npm package {name}@{version} is not supported on this platform")] NpmPackagePlatformUnsupported { name: String, version: String }, #[error("npm path escapes its instance: {}", path.display())] NpmPathEscapesInstance { path: std::path::PathBuf }, #[error("npm path escapes its package: {rel:?}")] NpmPathEscapesPackage { rel: String }, #[error("npm path must be UTF-8")] NpmPathMustBeUtf8, #[error("npm path must be UTF-8: {}", path.display())] NpmPathNotUtf8 { path: std::path::PathBuf }, #[error("npm peer dependency {peer:?} could not be resolved")] NpmPeerUnresolvable { peer: String }, #[error("npm peer dependency {peer:?} wants {wanted} but the context provides {provided}")] NpmPeerWantsMismatch { peer: String, wanted: String, provided: String, }, #[error("npm referrer must be an absolute path")] NpmReferrerNotAbsolute, #[error("npm relative import escapes its package")] NpmRelativeImportEscapes, #[error("npm relative imports must name a file")] NpmRelativeImportMustNameFile, #[error("npm relative import is not in the package manifest: {specifier:?}")] NpmRelativeImportNotInManifest { specifier: String }, #[error("npm relative import must be UTF-8")] NpmRelativeImportNotUtf8, #[error( "npm request {request_key} is prepared in several peer contexts; resolve it from an owning file instead" )] NpmRequestAmbiguousContexts { request_key: String }, #[error("npm request {request_key} is not prepared in this context")] NpmRequestNotPrepared { request_key: String }, #[error( "npm request {request_key} is not prepared in this context (sync require cannot acquire new packages)" )] NpmRequestNotPreparedSync { request_key: String }, #[error("npm require lookup is too deep")] NpmRequireLookupTooDeep, #[error("npm require lookup is too deep: {specifier:?}")] NpmRequireLookupTooDeepSpecifier { specifier: String }, #[error("npm resolver does not own {}", path.display())] NpmResolverNotOwner { path: std::path::PathBuf }, #[error(transparent)] NpmUnsupportedModule(#[from] super::package::NpmUnsupported), #[error(transparent)] Npm(#[from] super::NpmError), #[error(transparent)] Storage(#[from] super::storage::StorageError), #[error(transparent)] Package(#[from] super::package::PackageError), } use futures::stream::{self, StreamExt}; /// One root the caller wants prepared, with the peer providers its consumer /// already selected: every root in a `prepare` call shares this context, so a /// plugin and its host bind the same provider instances. #[derive(Clone, Debug)] pub struct NpmRootRequest { pub request: String, pub context: BTreeMap, } /// The roots one `prepare` call established: request lock keys to instance /// ids. Later integration layers keep one handle per owning local config /// instead of re-deriving contexts from paths. #[derive(Clone, Debug, Default)] pub struct PreparedNpmRoots { pub roots: BTreeMap, } impl PreparedNpmRoots { /// Exact lock keys in this handle for one package request. pub fn keys_for_request(&self, request: &str) -> Vec { let mut keys: Vec = self .roots .keys() .filter(|key| key.as_str() == request || key.starts_with(&format!("{request}\0"))) .cloned() .collect(); keys.sort(); keys } } /// A file resolved inside an npm instance, with a canonical absolute path. #[derive(Clone, Debug)] pub struct ResolvedNpmModule { pub path: PathBuf, pub format: NpmModuleKind, pub instance: String, } /// How a specifier is loaded: `import` or `require` conditions, and /// whether the target must be executable code or may be a declaration. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub enum ResolutionMode { Import, /// CommonJS `require` conditions, with Node-style extension and /// directory inference for package-relative paths. Require, TypeImport, TypeRequire, } impl ResolutionMode { pub fn is_require(self) -> bool { matches!(self, Self::Require | Self::TypeRequire) } pub fn is_type(self) -> bool { matches!(self, Self::TypeImport | Self::TypeRequire) } } enum RequireLookup { Missing, Err(ResolutionError), } /// Stands in for files and bare specifiers a package's `browser` field maps /// to `false`: an empty CommonJS module with an identity per excluded key. pub const BROWSER_EXCLUDED: &str = "\0browser-false.cjs"; #[derive(Debug)] struct ResolvedNode { instance: String, identity: NpmIdentity, dir: PathBuf, files: BTreeMap, manifest: NpmManifest, scopes: Mutex>>, deps: BTreeMap, peers: BTreeMap, } /// Upper bound on resolution graph size; malicious dependency graphs fail /// with an error instead of expanding forever. const MAX_NPM_GRAPH_NODES: usize = 4096; /// Upper bound on traversal depth. The node cap alone does not bound async /// recursion depth, so deep chains fail instead of overflowing the stack. const MAX_NPM_GRAPH_DEPTH: usize = 256; #[derive(Debug)] struct PreparedInstance { pin: NpmInstancePin, dir: PathBuf, loaded: Mutex>>, } #[derive(Clone, Debug, Default)] struct ResolverState { roots: BTreeMap, nodes: BTreeMap>, instances_by_dir: HashMap, } /// Local per-prepare occurrence graph. Memoized by (identity, context) with /// an in-progress stack for cycles; refinement later merges behaviorally /// identical occurrences into shared instances. #[derive(Default)] struct OccGraph { occs: Vec, index: BTreeMap<(String, String), usize>, roots: BTreeMap, } /// Mutable accumulators carried through one occurrence-graph walk. struct Walk<'a> { build: &'a mut OccGraph, acq: &'a mut BTreeMap, stack: &'a mut Vec, } struct Occurrence { identity: NpmIdentity, artifact: NpmArtifact, manifest: NpmManifest, dep_edges: BTreeMap, peer_edges: BTreeMap, } struct FinalizedGraph { roots: BTreeMap, nodes: BTreeMap>, } /// Standalone npm package resolver. It prepares version graphs into /// peer-scoped instances and resolves specifiers to verified files without /// consulting project configs, import maps, or the local filesystem beyond /// the reserved instance tree. pub struct NpmResolver { store: Arc, state: Mutex, /// Serializes graph builds. Preparation stages bindings locally and /// never holds the state lock across an await. prepare_gate: futures::lock::Mutex<()>, /// Browser bundles select `browser` conditions and honor the `browser` /// field instead of the runtime's `avril` branches; fixed at construction. browser: bool, typescript_version: Mutex>, } impl NpmResolver { pub fn new(store: Arc) -> Self { Self { store, state: Mutex::new(ResolverState::default()), prepare_gate: futures::lock::Mutex::new(()), browser: false, typescript_version: Mutex::new(None), } } pub fn for_browser(store: Arc) -> Self { Self { browser: true, ..Self::new(store) } } fn conditions(&self, mode: ResolutionMode) -> &'static [&'static str] { match (self.browser, mode) { (false, ResolutionMode::Import) => &["avril", "import", "module", "default"], (false, ResolutionMode::Require) => &["avril", "require", "default"], (true, ResolutionMode::Import) => &["browser", "import", "module", "default"], (true, ResolutionMode::Require) => &["browser", "require", "default"], (false, ResolutionMode::TypeImport) => { &["types", "avril", "import", "module", "default"] } (true, ResolutionMode::TypeImport) => { &["types", "browser", "import", "module", "default"] } (false, ResolutionMode::TypeRequire) => &["types", "avril", "require", "default"], (true, ResolutionMode::TypeRequire) => &["types", "browser", "require", "default"], } } /// Declared `sideEffects` for one resolved file, if the package says anything usable. pub fn side_effects(&self, path: &Path) -> Option { let (node, rel) = self.identify_locked(path).ok()?; super::package::declared_side_effects(node.manifest.side_effects.as_ref()?, &rel) } pub fn store(&self) -> &Arc { &self.store } pub fn set_typescript_version(&self, version: &str) -> Result<(), ResolutionError> { let parsed = deno_semver::Version::parse_from_npm(version).map_err(|detail| { ResolutionError::InvalidTypeScriptVersion { version: version.to_owned(), detail: detail.to_string(), } })?; *self.typescript_version.lock().unwrap() = Some(parsed); Ok(()) } pub fn audit(&self) -> Result<(), ResolutionError> { let state = self.state.lock().unwrap(); let nodes: Vec<_> = state .nodes .keys() .map(|id| self.node_locked(&state, id)) .collect::>()?; drop(state); let options = self.store.options(); let mut audited = BTreeSet::new(); for node in nodes { super::check_npm_exec( self.store.authority.as_ref(), &node.identity.registry, &node.identity.name, &node.identity.version, &node.files, )?; let backing = storage::npm_package_dir( options, super::NPM_DEFAULT_INSTANCE, &node.identity.name, &node.identity.version, ); for dir in [&node.dir, &backing] { if audited.insert(dir.clone()) { storage::verify_and_load_npm_dir(&options.project_root, dir, &node.files)?; } } } Ok(()) } /// Prepare several roots together. Root identities are selected first /// so every root traverses against the same provider context; late roots /// reuse established instances instead of rebinding them. Returns a /// handle callers keep per owning config for context-pinned resolution. pub async fn prepare( &self, roots: &[NpmRootRequest], ) -> Result { let mut normalized: Vec<(String, BTreeMap)> = Vec::new(); for root in roots { let (request_key, _) = split_npm_request(&root.request)?; for (peer, provider) in &root.context { validate_npm_name(peer)?; super::parse_npm_key(provider)?; } normalized.push((request_key, root.context.clone())); } // Exact tuples only: same request under different caller contexts // resolves independently and must not be merged away. normalized.sort(); normalized.dedup(); if normalized.is_empty() { return Ok(PreparedNpmRoots::default()); } let _gate = self.prepare_gate.lock().await; let mut handle = PreparedNpmRoots::default(); let finalized = if let Some(graph) = self.restore_locked(&normalized).await? { graph } else { if let Some(sink) = &self.store.progress { sink(&super::super::ProgressEvent::NpmResolving); } // All root identities are selected before any traversal so roots // share providers deterministically regardless of root order. // Selection goes through acquisition (one code path, cached per // prepare), never a parallel probe that could diverge from it. let mut preselected: BTreeMap = BTreeMap::new(); let mut build = OccGraph::default(); let mut acq: BTreeMap = BTreeMap::new(); let preselect_keys: Vec = normalized.iter().map(|(key, _)| key.clone()).collect(); self.acquire_many(&mut acq, &preselect_keys).await?; for request_key in &preselect_keys { let request = super::parse_npm_request(request_key)?; let identity = acq .get(request_key) .ok_or_else(|| ResolutionError::NpmPackageNotPrepared { request_key: request_key.to_owned(), })? .identity .key(); preselected.insert(request.name.clone(), identity); } // Roots and instances are recorded only by record_graph_locked on // success below. Artifact pins that acquisition caches along the way // are verified truth reusable like the JSR cache, so failures leave // no graph behind to poison a retry; deleting shared usage here // would race concurrent resolvers and workers instead. { let mut stack = Vec::new(); let mut walk = Walk { build: &mut build, acq: &mut acq, stack: &mut stack, }; self.build_roots(&mut walk, &mut handle, &normalized, &preselected) .await?; } if build.roots.is_empty() { return Ok(handle); } self.finalize_build(build).await? }; self.record_graph_locked(&finalized)?; { let mut state = self.state.lock().unwrap(); for (key, instance) in &finalized.roots { state .roots .entry(key.clone()) .or_insert_with(|| instance.clone()); handle.roots.insert(key.clone(), instance.clone()); } for (id, node) in &finalized.nodes { let directory = state .nodes .entry(id.clone()) .or_insert_with(|| node.clone()) .dir .clone(); state .instances_by_dir .entry(directory) .or_insert_with(|| id.clone()); } } Ok(handle) } /// Restore bindings, not resolution inputs. The lock owns dependency and /// peer choices; package documents are authenticated only when consumed. async fn restore_locked( &self, normalized: &[(String, BTreeMap)], ) -> Result, ResolutionError> { if self.store.options().update { return Ok(None); } let lock = self.store.lock.lock().unwrap().npm.clone(); let options = self.store.options(); let mut selected = BTreeMap::new(); for (key, _) in normalized { let Some(version) = lock.specifiers.get(key) else { if options.frozen { return Err(ResolutionError::FrozenLockfileMissing { name: key.clone() }); } return Ok(None); }; let request = parse_npm_request(key)?; selected.insert( request.name.clone(), format!("{}|{}|{version}", options.npm_registry, request.name), ); } let mut roots = BTreeMap::new(); for (request, context) in normalized { let key = root_lock_key(request, &effective_context(&selected, context).merged()); let Some(id) = lock.roots.get(&key) else { if options.frozen { return Err(ResolutionError::FrozenLockfileMissing { name: key }); } return Ok(None); }; let pin = lock.instances.get(id).ok_or_else(|| { super::NpmError::LockfileMissingNpmInstance { instance: id.clone() } })?; let name = parse_npm_request(request)?.name; let expected = format!( "{}|{name}|{}", options.npm_registry, lock.specifiers[request] ); if pin.package != expected { return Err(ResolutionError::InvalidNpmLockIdentity); } roots.insert(key, id.clone()); } let mut nodes = BTreeMap::new(); let mut pending: Vec<_> = roots.values().cloned().collect(); let mut acquired = BTreeMap::::new(); while let Some(id) = pending.pop() { if nodes.contains_key(&id) { continue; } if nodes.len() >= MAX_NPM_GRAPH_NODES { return Err(ResolutionError::NpmDependencyGraphTooLarge); } let pin = lock.instances.get(&id).ok_or_else(|| { super::NpmError::LockfileMissingNpmInstance { instance: id.clone() } })?; let (registry, name, version) = super::parse_npm_key(&pin.package)?; if registry != options.npm_registry { return Err(super::NpmError::NpmOriginOutsideRegistry { url: registry.to_string(), } .into()); } let base = super::npm_version_base(®istry, &name, &version)?; Access::Package(base) .check(self.store.authority.as_ref()) .map_err(super::NpmError::from)?; let package_pin = lock.packages.get(&pin.package).ok_or_else(|| { super::NpmError::LockfileMissingNpmPackage { package: pin.package.clone() } })?; let disk = storage::npm_disk_instance(&id, &pin.package); let dir = storage::npm_package_dir(options, disk, &name, &version); let backing = storage::npm_package_dir(options, super::NPM_DEFAULT_INSTANCE, &name, &version); if !storage::npm_instance_ready(options, &dir, &backing, package_pin)? { if !acquired.contains_key(&pin.package) { let identity = NpmIdentity { registry, name, version }; let artifact = self .store .npm_acquire_locked(&identity, package_pin) .await?; acquired.insert(pin.package.clone(), artifact); } storage::ensure_npm_instance(options, &acquired[&pin.package], disk)?; } pending.extend(pin.deps.values().chain(pin.peers.values()).cloned()); nodes.insert( id, Arc::new(PreparedInstance { pin: pin.clone(), dir, loaded: Mutex::new(None), }), ); } Ok(Some(FinalizedGraph { roots, nodes })) } /// Resolve an explicit `npm:` request, preparing its package on demand. /// A context-free request matches the single prepared root for its /// package request; several contexts for one request are ambiguous. pub async fn resolve_request( &self, specifier: &str, mode: ResolutionMode, ) -> Result { let request = super::parse_npm_request(specifier)?; self.resolve_request_parsed(&request, mode).await } pub async fn resolve_request_parsed( &self, request: &super::NpmRequest, mode: ResolutionMode, ) -> Result { let request_key = request.request_key(); let subpath = request.subpath.clone(); let instance = { let state = self.state.lock().unwrap(); self.lookup_root_locked(&state, &request_key)? }; let instance = match instance { Some(instance) => instance, None => { let handle = self .prepare(&[NpmRootRequest { request: request_key.clone(), context: BTreeMap::new(), }]) .await?; handle.roots.values().next().cloned().ok_or_else(|| { ResolutionError::NpmPackageNotPrepared { request_key: request_key.to_owned(), } })? } }; let export = match subpath { Some(path) => subpath_to_export(&path)?, None => ".".into(), }; self.resolve_in_instance(&instance, &export, mode) } /// ESM-warmable requests for a package or the export prefix its /// subpath names, in the specifier's own range form so frozen runs pin /// the same roots. Exact export keys resolve directly; wildcard /// patterns invert over verified files to their public subpaths, never /// the private file names. Conditions select executable branches only: /// misses, blocks, and non-executable kinds are skipped, never /// compiled. Any other resolution or security error fails the whole /// enumeration instead of warming a partial prefix. pub async fn export_requests(&self, specifier: &str) -> Result, ResolutionError> { let (request_key, subpath) = split_npm_request(specifier)?; let scope = subpath .map(|path| path.trim_end_matches('/').to_owned()) .unwrap_or_default(); if self .lookup_root_locked(&self.state.lock().unwrap(), &request_key)? .is_none() { self.prepare(&[NpmRootRequest { request: request_key.clone(), context: BTreeMap::new(), }]) .await?; } let state = self.state.lock().unwrap(); let instance = self .lookup_root_locked(&state, &request_key)? .ok_or_else(|| ResolutionError::NpmPackageNotPrepared { request_key: request_key.to_owned(), })?; let node = self.node_locked(&state, &instance)?; self.export_requests_in(&node, &request_key, &scope) } fn export_requests_in( &self, node: &ResolvedNode, base: &str, scope: &str, ) -> Result, ResolutionError> { match &node.manifest.exports_raw { // Without an exports map only the entry file is public; legacy // subpaths warm per file beneath a prefix scope instead. None if scope.is_empty() => Ok(vec![base.to_owned()]), None => { let mut requests = Vec::new(); for rel in node.files.keys() { if rel != scope && !rel.starts_with(&format!("{scope}/")) { continue; } if !self.export_file_executable(node, rel)? { continue; } requests.push(format!("{base}/{rel}")); } requests.sort(); requests.dedup(); Ok(requests) } Some(exports) => self.export_requests_mapped(node, base, scope, exports), } } fn export_requests_mapped( &self, node: &ResolvedNode, base: &str, scope: &str, exports: &serde_json::Value, ) -> Result, ResolutionError> { // A bare target, a fallback list, or a condition object only // ever selects the root. let map = match exports { serde_json::Value::Object(map) => map, _ => { if scope.is_empty() && self.export_executable_in(node, exports, ".")?.is_some() { return Ok(vec![base.to_owned()]); } return Ok(Vec::new()); } }; let subpaths = map.keys().filter(|key| key.starts_with('.')).count(); if subpaths > 0 && subpaths != map.len() { return Err(ResolutionError::NpmExportsMixesKeys); } if subpaths == 0 { if scope.is_empty() && self.export_executable_in(node, exports, ".")?.is_some() { return Ok(vec![base.to_owned()]); } return Ok(Vec::new()); } let mut subs: BTreeSet = BTreeSet::new(); if scope.is_empty() && map.contains_key(".") { subs.insert(".".to_owned()); } for key in map.keys() { if !key.starts_with("./") || key.contains('*') { continue; } if map.get(key).is_some_and(|value| value.is_null()) { continue; } if under_export_scope(&key["./".len()..], scope) { subs.insert(key.clone()); } } for key in map.keys() { let Some((key_prefix, _)) = key.split_once('*') else { continue; }; if !key.starts_with("./") || key[key_prefix.len() + 1..].contains('*') { continue; } let (_, key_suffix) = key.split_once('*').unwrap(); let mut targets = Vec::new(); pattern_targets(&map[key], &mut targets); for target in targets { // File keys omit the trailer's leading `./`. let target = target.strip_prefix("./").unwrap_or(&target); let Some((target_prefix, target_suffix)) = target.split_once('*') else { continue; }; for file in node.files.keys() { if file.len() < target_prefix.len() + target_suffix.len() || !file.starts_with(target_prefix) || !file.ends_with(&target_suffix) { continue; } let captured = &file[target_prefix.len()..file.len() - target_suffix.len()]; let public = format!("{key_prefix}{captured}{key_suffix}"); if !public.starts_with("./") { continue; } if !under_export_scope(&public["./".len()..], scope) { continue; } match resolve_exports_target(exports, &public, &npm_conditions()) { Ok(Some(_)) => { subs.insert(public); } Ok(None) => {} Err(error) => return Err(error.into()), } } } } let mut requests = Vec::new(); for sub in subs { if self.export_executable_in(node, exports, &sub)?.is_none() { continue; } if sub == "." { requests.push(base.to_owned()); } else { requests.push(format!("{base}/{}", sub.strip_prefix("./").unwrap_or(&sub))); } } requests.sort(); requests.dedup(); Ok(requests) } /// Resolve one public export to its verified file when the active /// conditions select executable code. Misses and non-executable kinds /// (including explicitly blocked entries) yield None without failing; /// malformed, missing, escaping, or unauthorized targets fail. fn export_executable_in( &self, node: &ResolvedNode, exports: &serde_json::Value, export: &str, ) -> Result, ResolutionError> { if exports .as_object() .and_then(|map| map.get(export)) .is_some_and(|value| value.is_null()) { return Ok(None); } let target = match resolve_exports_target(exports, export, &npm_conditions()) { Ok(Some(target)) => target, Ok(None) => return Ok(None), Err(error) => return Err(error.into()), }; let rel = target .strip_prefix("./") .ok_or_else(|| ResolutionError::NpmExportTargetEscapes { target: target.to_owned() })?; super::validate_npm_relpath(rel)?; if rel.split('/').any(|part| part == "node_modules") { return Err(ResolutionError::NpmExportTargetEscapes { target: target.to_owned() }); } if !node.files.contains_key(rel) { return Err(ResolutionError::NpmExportTargetNotInManifest { target: target.to_owned(), }); } let url = super::npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, rel, )?; super::check_npm_url(self.store.authority.as_ref(), &url)?; match node.classify(&self.store.options().project_root, rel) { Ok(NpmModuleKind::Esm) | Ok(NpmModuleKind::TypeScript) | Ok(NpmModuleKind::CommonJs) => Ok(Some(rel.to_owned())), Ok(_) | Err(ResolutionError::NpmUnsupportedModule(_)) => Ok(None), Err(error) => Err(error), } } /// Whether one legacy file warms as executable code: same /// classification execution applies, without an exports map. fn export_file_executable( &self, node: &ResolvedNode, rel: &str, ) -> Result { let url = super::npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, rel, )?; super::check_npm_url(self.store.authority.as_ref(), &url)?; match node.classify(&self.store.options().project_root, rel) { Ok(NpmModuleKind::Esm) | Ok(NpmModuleKind::TypeScript) | Ok(NpmModuleKind::CommonJs) => Ok(true), Ok(_) | Err(ResolutionError::NpmUnsupportedModule(_)) => Ok(false), Err(error) => Err(error), } } /// The exact recorded root key behind one resolution, for lock /// tracking that keeps successful contexts only. pub fn tracking_key(&self, request_key: &str, instance: &str) -> Option { let state = self.state.lock().unwrap(); let mut keys: Vec = state .roots .iter() .filter(|(key, existing)| { existing.as_str() == instance && root_request_part(key) == request_key }) .map(|(key, _)| key.clone()) .collect(); keys.sort(); keys.into_iter().next() } pub(crate) fn resolve_require_path( &self, path: &Path, ) -> Result, ResolutionError> { let state = self.state.lock().unwrap(); let instance = self.owning_instance_locked(&state, path)?; let node = self.node_locked(&state, &instance)?; let rel = path .strip_prefix(&node.dir) .map_err(|_| ResolutionError::NpmPathEscapesInstance { path: path.to_owned() })? .to_str() .ok_or_else(|| ResolutionError::NpmPathNotUtf8 { path: path.to_owned() })? .replace('\\', "/"); match self.resolve_relative_require(&node, &rel, &path.display().to_string(), 0) { Ok(module) => Ok(Some(module)), Err(ResolutionError::NpmRelativeImportNotInManifest { .. }) => Ok(None), Err(error) => Err(error), } } /// Resolve `specifier` against the roots of one prepare handle. pub fn resolve_in( &self, prepared: &PreparedNpmRoots, specifier: &str, mode: ResolutionMode, ) -> Result { let (request_key, subpath) = split_npm_request(specifier)?; let mut candidates = prepared .roots .iter() .filter(|(key, _)| root_request_part(key) == request_key) .map(|(_, instance)| instance.clone()) .collect::>(); candidates.sort(); candidates.dedup(); if candidates.len() != 1 { return Err(if mode == ResolutionMode::Require { ResolutionError::NpmRequestNotPreparedSync { request_key: request_key.to_owned(), } } else { ResolutionError::NpmRequestNotPrepared { request_key: request_key.to_owned(), } }); } let export = match subpath { Some(path) => subpath_to_export(&path)?, None => ".".into(), }; self.resolve_in_instance(&candidates.into_iter().next().unwrap(), &export, mode) } fn npm_absolute_path(specifier: &str) -> Option { let path = Path::new(specifier); if path.is_absolute() { return Some(path.to_owned()); } if specifier.starts_with("file:") && let Ok(url) = url::Url::parse(specifier) && url.scheme() == "file" && url.query().is_none() && url.fragment().is_none() { return url.to_file_path().ok(); } None } /// Resolve `specifier` against the instance owning `referrer`. The /// referrer must be a canonical absolute path inside a prepared instance; /// anything else (including unowned reserved cache paths) is an error. /// `require` modes add Node-style extension/directory inference, still /// only over verified manifests and never the filesystem directly. pub fn resolve_from_file( &self, specifier: &str, referrer: &Path, mode: ResolutionMode, ) -> Result { let state = self.state.lock().unwrap(); let instance = self.owning_instance_locked(&state, referrer)?; let node = self.node_locked(&state, &instance)?; if specifier.starts_with('.') || specifier.starts_with("file:") { return self.resolve_relative_locked(&node, referrer, specifier, mode); } if let Some(absolute) = Self::npm_absolute_path(specifier) { if let Ok(rel) = absolute.strip_prefix(&node.dir) { let rel = rel .to_str() .ok_or(ResolutionError::NpmPathMustBeUtf8)? .replace('\\', "/"); let trimmed = rel.trim_end_matches('/'); if mode.is_type() { return self.resolve_types_target_locked( &node, &format!("./{trimmed}"), trimmed, mode, ); } if mode.is_require() { return self.resolve_relative_require(&node, trimmed, specifier, 0); } super::validate_npm_relpath(trimmed)?; if !node.files.contains_key(trimmed) { return Err(ResolutionError::NpmFileNotInManifest { path: specifier.to_owned(), }); } return self.finish_file_locked(&node, trimmed, mode); } return Err(ResolutionError::NpmPathEscapesPackage { rel: specifier.to_owned() }); } if specifier.starts_with('#') { return self.resolve_hash_locked(&state, &node, specifier, mode); } if specifier.starts_with("npm:") { let (request_key, sub) = split_npm_request(specifier)?; let instance = state.roots.get(&request_key).cloned().ok_or_else(|| { ResolutionError::NpmPackageNotPrepared { request_key: request_key.to_owned(), } })?; let node = self.node_locked(&state, &instance)?; let export = match sub { Some(path) => subpath_to_export(&path)?, None => ".".into(), }; return self.resolve_in_node_locked(&node, &export, mode); } self.resolve_bare_locked(&state, &node, specifier, mode, 0) } fn resolve_bare_locked( &self, state: &ResolverState, node: &ResolvedNode, specifier: &str, mode: ResolutionMode, depth: usize, ) -> Result { let (name, sub) = split_bare(specifier); if name == "node" || name.starts_with("node:") { return Err(ResolutionError::NodeBuiltinUnsupported { specifier: specifier.to_owned(), }); } // An exact `dep/sub` key replaces the whole specifier; a `dep` key keeps `/sub`. let map = self.browser_map(node); let remap = match map.and_then(|map| map.get(specifier)) { Some(target) => Some((target, None)), None => sub.and_then(|sub| Some((map?.get(name)?, Some(sub)))), }; match remap { Some((serde_json::Value::Bool(false), _)) => return Ok(excluded(node, specifier)), Some((serde_json::Value::String(_), _)) if depth >= 8 => { return Err(ResolutionError::NpmBrowserRemapCyclic { specifier: specifier.to_owned(), }); } Some((serde_json::Value::String(target), None)) if target.starts_with('.') => { if mode.is_type() { return self.resolve_types_target_locked( node, &format!("./{}", target.trim_start_matches("./")), specifier, mode, ); } return self.finish_file_locked(node, target.trim_start_matches("./"), mode); } Some((serde_json::Value::String(target), rest)) if !target.starts_with('.') => { let target = match rest { Some(rest) => format!("{target}/{rest}"), None => target.clone(), }; return self.resolve_bare_locked(state, node, &target, mode, depth + 1); } _ => {} } if name == node.manifest.name && node.manifest.exports_raw.is_some() { let export = sub .map(|path| format!("./{path}")) .unwrap_or_else(|| ".".into()); return self.resolve_in_node_locked(node, &export, mode); } let export = sub .map(|path| format!("./{path}")) .unwrap_or_else(|| ".".into()); let resolved = if let Some(provider) = node.deps.get(name).or_else(|| node.peers.get(name)) { let provider = self.node_locked(state, provider)?; Some(self.resolve_in_node_locked(&provider, &export, mode)?) } else { None }; if mode.is_type() && !name.starts_with("@types/") && resolved.as_ref().is_none_or(|module| { !matches!( module .path .extension() .and_then(|extension| extension.to_str()), Some("ts" | "tsx" | "mts" | "cts") ) }) { let types = super::package::types_package_name(name); if let Some(provider) = node.deps.get(&types).or_else(|| node.peers.get(&types)) { let provider = self.node_locked(state, provider)?; return self.resolve_in_node_locked(&provider, &export, mode); } } if let Some(resolved) = resolved { return Ok(resolved); } Err(ResolutionError::NpmPackageNotDeclared { name: name.to_owned(), owner: node.manifest.name.clone(), }) } /// Synchronous `import.meta`-style resolution from prepared metadata. pub fn resolve_import_meta(&self, specifier: &str) -> Result { self.resolve_import_meta_parsed(&parse_npm_request(specifier)?) } pub fn resolve_import_meta_parsed( &self, request: &NpmRequest, ) -> Result { let request_key = request.request_key(); let state = self.state.lock().unwrap(); let instance = self .lookup_root_locked(&state, &request_key)? .ok_or_else(|| ResolutionError::NpmPackageNotPrepared { request_key: request_key.to_owned(), })?; let node = self.node_locked(&state, &instance)?; let export = match &request.subpath { Some(path) => subpath_to_export(path)?, None => ".".into(), }; Ok(self .resolve_in_node_locked(&node, &export, ResolutionMode::Import)? .path .to_string_lossy() .into_owned()) } /// Read one resolved file as text, rechecking execution authority and /// integrity at use time. pub fn npm_source(&self, path: &Path) -> Result { let (node, rel) = self.identify_locked(path)?; let url = npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, &rel, )?; super::check_npm_url(self.store.authority.as_ref(), &url)?; let bytes = node.read_verified(&self.store.options().project_root, &rel)?; String::from_utf8(bytes) .map_err(|_| ResolutionError::NpmModuleNotUtf8 { path: path.to_owned() }) } pub fn owning_instance(&self, path: &Path) -> Option { let state = self.state.lock().unwrap(); self.owning_instance_locked(&state, path).ok() } pub fn describe_instance(&self, id: &str) -> Result { let state = self.state.lock().unwrap(); let instance = state .nodes .get(id) .ok_or_else(|| ResolutionError::NpmInstanceNotPrepared { id: id.to_owned() })?; let (registry, name, version) = super::parse_npm_key(&instance.pin.package)?; Ok(NpmIdentity { registry, name, version }) } /// Authorize one reserved-tree file: ownership, kind, and virtual-URL /// execution authority. Local project grants never satisfy this; only /// registry `registry/name/version[/file]` grants do. pub fn check_access( &self, path: &Path, ) -> Result { let (node, rel) = self.identify_locked(path)?; let url = super::npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, &rel, )?; super::check_npm_url(self.store.authority.as_ref(), &url)?; node.classify(&self.store.options().project_root, &rel) } /// Reserved tree membership, independent of prepared state: callers must /// never treat reserved-but-unowned paths as local files. pub fn is_reserved_npm_path(&self, path: &Path) -> bool { Self::reserved_npm_path(&self.store.options().project_root, path) } fn owning_instance_locked( &self, state: &ResolverState, path: &Path, ) -> Result { for directory in path.ancestors() { if let Some(id) = state.instances_by_dir.get(directory) { return Ok(id.clone()); } } if Self::reserved_npm_path(&self.store.options().project_root, path) { return Err(ResolutionError::NpmCachePathUnowned { path: path.to_owned() }); } Err(ResolutionError::NpmResolverNotOwner { path: path.to_owned() }) } fn lookup_root_locked( &self, state: &ResolverState, request_key: &str, ) -> Result, ResolutionError> { if let Some(instance) = state.roots.get(request_key) { return Ok(Some(instance.clone())); } // Distinct contexts that agree on one instance are not ambiguous. let mut instances = state .roots .keys() .filter(|key| { key.split_once('\0') .is_some_and(|(base, _)| base == request_key) }) .filter_map(|key| state.roots.get(key)) .collect::>(); instances.sort(); instances.dedup(); match instances.len() { 0 => Ok(None), 1 => Ok(Some(instances.into_iter().next().unwrap().clone())), _ => Err(ResolutionError::NpmRequestAmbiguousContexts { request_key: request_key.to_owned(), }), } } fn node_locked( &self, state: &ResolverState, id: &str, ) -> Result, ResolutionError> { let instance = state .nodes .get(id) .ok_or_else(|| ResolutionError::NpmInstanceNotPrepared { id: id.to_owned() })?; let mut loaded = instance.loaded.lock().unwrap(); if let Some(node) = &*loaded { return Ok(node.clone()); } let (registry, name, version) = super::parse_npm_key(&instance.pin.package)?; let base = super::npm_version_base(®istry, &name, &version)?; Access::Package(base) .check(self.store.authority.as_ref()) .map_err(super::NpmError::from)?; let pin = self .store .lock .lock() .unwrap() .npm .packages .get(&instance.pin.package) .cloned() .ok_or_else(|| super::NpmError::LockfileMissingNpmPackage { package: instance.pin.package.clone(), })?; let files = match pin.files { Some(files) => files, None => storage::load_manifest_map( self.store.options(), pin.manifest .as_deref() .ok_or(storage::StorageError::NpmPinMissingManifest)?, )? .ok_or(storage::StorageError::NpmManifestMissing)?, }; super::check_npm_exec( self.store.authority.as_ref(), ®istry, &name, &version, &files, )?; let package = storage::verify_npm_metadata( &self.store.options().project_root, &instance.dir, &files, )? .ok_or(storage::StorageError::NpmManifestMissing)?; let manifest = parse_npm_manifest(&name, &version, &package)?; if !platform_satisfied(manifest.os.as_ref(), super::package::npm_host_os()) || !platform_satisfied(manifest.cpu.as_ref(), super::package::npm_host_arch()) { return Err(ResolutionError::NpmPackagePlatformUnsupported { name, version }); } let node = Arc::new(ResolvedNode { instance: id.to_owned(), identity: NpmIdentity { registry, name, version }, dir: instance.dir.clone(), files, manifest, scopes: Mutex::new(BTreeMap::new()), deps: instance.pin.deps.clone(), peers: instance.pin.peers.clone(), }); *loaded = Some(node.clone()); Ok(node) } fn identify_locked(&self, path: &Path) -> Result<(Arc, String), ResolutionError> { let state = self.state.lock().unwrap(); let id = self.owning_instance_locked(&state, path)?; let node = self.node_locked(&state, &id)?; let rel = path .strip_prefix(&node.dir) .map_err(|_| ResolutionError::NpmPathEscapesInstance { path: path.to_owned() })?; let rel = rel .to_str() .ok_or_else(|| ResolutionError::NpmPathNotUtf8 { path: path.to_owned() })? .replace('\\', "/"); super::validate_npm_relpath(&rel)?; if !node.files.contains_key(&rel) { return Err(ResolutionError::NpmFileNotInManifest { path: path.display().to_string() }); } Ok((node, rel)) } fn resolve_in_instance( &self, instance: &str, export: &str, mode: ResolutionMode, ) -> Result { let state = self.state.lock().unwrap(); let node = self.node_locked(&state, instance)?; self.resolve_in_node_locked(&node, export, mode) } fn resolve_in_node_locked( &self, node: &ResolvedNode, export: &str, mode: ResolutionMode, ) -> Result { let target = if let Some(exports) = &node.manifest.exports_raw { resolve_exports_target(exports, export, self.conditions(mode))?.ok_or_else(|| { ResolutionError::NpmPackageMissingExport { name: node.manifest.name.clone(), export: export.to_owned(), } })? } else { if mode.is_type() && let Some(mapped) = self.resolve_types_versions_locked(node, export, mode)? { return Ok(mapped); } match self.legacy_entry(node, export, mode) { Err(ResolutionError::NpmPackageNoEntryPoint { .. }) if mode.is_type() && node.files.contains_key("index.d.ts") => { "./index.d.ts".into() } result => result?, } }; if mode.is_type() { self.resolve_types_target_locked(node, &target, export, mode) } else { self.resolve_target_locked(node, &target, mode) } } fn resolve_types_versions_locked( &self, node: &ResolvedNode, export: &str, mode: ResolutionMode, ) -> Result, ResolutionError> { let Some(raw) = node.manifest.types_versions.as_ref() else { return Ok(None); }; let Some(ranges) = raw.as_object() else { return Ok(None); }; let mut valid_ranges = Vec::new(); for (range, mappings) in ranges { let Ok(requirement) = deno_semver::VersionReq::parse_from_npm(range) else { continue; }; if requirement.range().is_none() { continue; } let Some(mappings) = mappings.as_object() else { continue; }; if !mappings.iter().all(|(pattern, targets)| { valid_types_versions_pattern(pattern) && targets.as_array().is_some_and(|targets| { targets .iter() .all(|target| target.as_str().is_some_and(valid_types_versions_target)) }) }) { continue; } valid_ranges.push((range.as_str(), mappings)); } if valid_ranges.is_empty() { return Ok(None); } let version = self .typescript_version .lock() .unwrap() .clone() .ok_or_else(|| ResolutionError::TypeScriptVersionRequired { name: node.manifest.name.clone(), })?; let Some((_, mappings)) = valid_ranges .into_iter() .find(|(range, _)| typescript_range_matches(range, &version)) else { return Ok(None); }; let module_name = if export == "." { node.manifest .types .as_ref() .and_then(serde_json::Value::as_str) .or_else(|| { node.manifest .typings .as_ref() .and_then(serde_json::Value::as_str) }) .or(node.manifest.module.as_deref()) .or(node.manifest.main.as_deref()) .map(|path| path.trim_start_matches("./")) .unwrap_or("index") } else { export.strip_prefix("./").unwrap_or(export) }; let Some((pattern, matched)) = match_types_versions_pattern(mappings, module_name) else { return Ok(None); }; let Some(targets) = mappings.get(pattern).and_then(serde_json::Value::as_array) else { return Ok(None); }; for target in targets.iter().filter_map(serde_json::Value::as_str) { let target = target.replacen('*', matched, 1); let target = target.strip_prefix("./").unwrap_or(&target); let target = format!("./{target}"); match self.resolve_types_target_locked(node, &target, export, mode) { Ok(module) => return Ok(Some(module)), Err(ResolutionError::NpmExportTargetNotInManifest { .. }) => {} Err(error) => return Err(error), } } Ok(None) } fn resolve_types_target_locked( &self, node: &ResolvedNode, target: &str, export: &str, mode: ResolutionMode, ) -> Result { let rel = target .strip_prefix("./") .ok_or_else(|| ResolutionError::NpmExportTargetEscapes { target: target.into() })?; super::validate_npm_relpath(rel)?; if rel.split('/').any(|part| part == "node_modules") { return Err(ResolutionError::NpmExportTargetEscapes { target: target.into() }); } if let Some(found) = declaration_candidates(rel) .iter() .find(|candidate| node.files.contains_key(*candidate)) { return self.finish_file_locked(node, found, mode); } if export == "." && node.manifest.exports_raw.is_none() && node.manifest.types.is_none() && node.manifest.typings.is_none() && node.files.contains_key("index.d.ts") { return self.finish_file_locked(node, "index.d.ts", mode); } if node.files.contains_key(rel) { return self.finish_file_locked(node, rel, mode); } Err(ResolutionError::NpmExportTargetNotInManifest { target: target.into() }) } fn legacy_entry( &self, node: &ResolvedNode, export: &str, mode: ResolutionMode, ) -> Result { if mode.is_type() { if export != "." { return Ok(format!("./{}", export.strip_prefix("./").unwrap_or(export))); } if let Some((key, entry)) = node .manifest .types .as_ref() .map(|entry| ("types", entry)) .or_else(|| { node.manifest .typings .as_ref() .map(|entry| ("typings", entry)) }) { let entry = entry.as_str().ok_or_else(|| { super::package::PackageError::NpmMustBePathString { key: key.into() } })?; if entry.starts_with('/') || entry.contains('\\') { return Err(ResolutionError::NpmExportTargetEscapes { target: entry.into() }); } return Ok(format!("./{}", entry.strip_prefix("./").unwrap_or(entry))); } } if let Some(entry) = node .manifest .browser .as_ref() .and_then(|entry| entry.as_str()) && self.browser && export == "." { match self.require_lookup_rel(node, entry.trim_start_matches("./"), 0) { Ok(found) => return Ok(format!("./{found}")), Err(RequireLookup::Err(error)) => return Err(error), Err(RequireLookup::Missing) => {} } } if mode.is_require() { return self.legacy_require_entry(node, export); } if export == "." { let entries = [&node.manifest.module, &node.manifest.main]; for entry in entries.into_iter().flatten() { let rel = entry.strip_prefix("./").unwrap_or(entry); if node.files.contains_key(rel) { return Ok(format!("./{rel}")); } } if node.files.contains_key("index.js") { return Ok("./index.js".into()); } return Err(ResolutionError::NpmPackageNoEntryPoint { name: node.manifest.name.clone(), }); } let rel = export.strip_prefix("./").unwrap_or(export); if node.files.contains_key(rel) { return Ok(export.to_owned()); } Err(ResolutionError::NpmPackageMissingFile { name: node.manifest.name.clone(), export: export.to_owned(), }) } fn legacy_require_entry( &self, node: &ResolvedNode, export: &str, ) -> Result { if export == "." { match self.require_lookup_rel(node, "", 0) { Ok(found) => return Ok(format!("./{found}")), Err(RequireLookup::Missing) => { return Err(ResolutionError::NpmPackageNoEntryPoint { name: node.manifest.name.clone(), }); } Err(RequireLookup::Err(error)) => return Err(error), } } let rel = export.strip_prefix("./").unwrap_or(export); match self.require_lookup_rel(node, rel, 0) { Ok(found) => Ok(format!("./{found}")), Err(RequireLookup::Missing) => Err(ResolutionError::NpmPackageMissingFile { name: node.manifest.name.clone(), export: export.to_owned(), }), Err(RequireLookup::Err(error)) => Err(error), } } fn require_lookup_rel( &self, node: &ResolvedNode, rel: &str, depth: usize, ) -> Result { if depth > 5 { return Err(RequireLookup::Err(ResolutionError::NpmRequireLookupTooDeep)); } let rel = rel.trim_end_matches('/'); if rel.is_empty() { if let Some(main) = &node.manifest.main { let main = main.strip_prefix("./").unwrap_or(main); if !matches!(main, "" | "." | "/") { match self.require_lookup_rel(node, main, depth + 1) { Ok(found) => return Ok(found), Err(RequireLookup::Missing) => {} Err(other) => return Err(other), } } } for candidate in ["index.js", "index.json", "index.cjs"] { if node.files.contains_key(candidate) { return Ok(candidate.to_owned()); } } if node.files.contains_key("index.node") { return Err(RequireLookup::Err( ResolutionError::NativeAddonUnsupported { rel: "index.node".to_owned() }, )); } return Err(RequireLookup::Missing); } super::validate_npm_relpath(rel).map_err(|error| RequireLookup::Err(error.into()))?; if rel.split('/').any(|part| part == "node_modules") { return Err(RequireLookup::Err(ResolutionError::NpmPathEscapesPackage { rel: rel.to_owned(), })); } if node.files.contains_key(rel) { return Ok(rel.to_owned()); } for candidate in [ format!("{rel}.js"), format!("{rel}.json"), format!("{rel}.node"), ] { if node.files.contains_key(&candidate) { return Ok(candidate); } } let prefix = format!("{rel}/"); if !node.files.keys().any(|file| file.starts_with(&prefix)) { return Err(RequireLookup::Missing); } let package_rel = format!("{rel}/package.json"); if node.files.contains_key(&package_rel) { let project_root = self.store.options().project_root.clone(); let url = super::npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, &package_rel, ) .map_err(|error| RequireLookup::Err(error.into()))?; super::check_npm_url(self.store.authority.as_ref(), &url) .map_err(|error| RequireLookup::Err(error.into()))?; let scope = node .scope(&project_root, &package_rel) .map_err(RequireLookup::Err)?; if let Some(main) = &scope.main { let main = main.strip_prefix("./").unwrap_or(main); if !matches!(main, "" | "." | "/") { let candidate = format!("{rel}/{main}"); match self.require_lookup_rel(node, &candidate, depth + 1) { Ok(found) => return Ok(found), Err(RequireLookup::Missing) => {} Err(other) => return Err(other), } } } } for candidate in [ format!("{rel}/index.js"), format!("{rel}/index.json"), format!("{rel}/index.cjs"), ] { if node.files.contains_key(&candidate) { return Ok(candidate); } } if node.files.contains_key(&format!("{rel}/index.node")) { return Err(RequireLookup::Err( ResolutionError::NativeAddonUnsupported { rel: rel.to_owned() }, )); } Err(RequireLookup::Missing) } fn resolve_relative_locked( &self, node: &ResolvedNode, referrer: &Path, specifier: &str, mode: ResolutionMode, ) -> Result { if specifier.contains('\\') || specifier.contains('\0') || specifier.bytes().any(|byte| byte.is_ascii_control()) { return Err(ResolutionError::InvalidNpmRelativeImport { specifier: specifier.to_owned(), }); } let lower = specifier.to_ascii_lowercase(); if lower.contains("%2f") || lower.contains("%5c") { return Err(ResolutionError::InvalidNpmRelativeImport { specifier: specifier.to_owned(), }); } let base = url::Url::from_file_path(referrer) .map_err(|_| ResolutionError::NpmReferrerNotAbsolute)?; let joined = base.join(specifier) .map_err(|_| ResolutionError::InvalidNpmRelativeImport { specifier: specifier.to_owned(), })?; if joined.scheme() != "file" || !joined.host_str().is_none_or(|host| host.is_empty()) { return Err(ResolutionError::InvalidNpmRelativeImport { specifier: specifier.to_owned(), }); } let current = joined .to_file_path() .map_err(|_| ResolutionError::InvalidNpmRelativeImport { specifier: specifier.to_owned(), })?; if mode.is_require() && current == node.dir { if mode.is_type() { return self.resolve_in_node_locked(node, ".", mode); } return self.resolve_relative_require(node, "", specifier, 0); } if current == node.dir { return Err(ResolutionError::NpmRelativeImportMustNameFile); } let rel = current .strip_prefix(&node.dir) .map_err(|_| ResolutionError::NpmRelativeImportEscapes)? .to_str() .ok_or(ResolutionError::NpmRelativeImportNotUtf8)? .replace('\\', "/"); match mode { ResolutionMode::Import | ResolutionMode::TypeImport | ResolutionMode::TypeRequire => { super::validate_npm_relpath(&rel)?; if mode.is_type() && let Some(found) = declaration_candidates(&rel) .iter() .find(|candidate| node.files.contains_key(*candidate)) { return self.finish_file_locked(node, found, mode); } if !node.files.contains_key(&rel) { return Err(ResolutionError::NpmRelativeImportNotInManifest { specifier: specifier.to_owned(), }); } self.finish_file_locked(node, &rel, mode) } ResolutionMode::Require => { let trimmed = rel.trim_end_matches('/'); self.resolve_relative_require(node, trimmed, specifier, 0) } } } /// Executable `require` of a package-relative path with Node-style /// extension and directory inference. fn resolve_relative_require( &self, node: &ResolvedNode, rel: &str, specifier: &str, depth: usize, ) -> Result { if depth > 5 { return Err(ResolutionError::NpmRequireLookupTooDeepSpecifier { specifier: specifier.to_owned(), }); } // Empty rel is the package root directory (`require('./')`). let trimmed = rel.trim_end_matches('/'); match self.require_lookup_rel(node, trimmed, depth) { Ok(found) => self.finish_file_locked(node, &found, ResolutionMode::Require), Err(RequireLookup::Missing) => Err(ResolutionError::NpmRelativeImportNotInManifest { specifier: specifier.to_owned(), }), Err(RequireLookup::Err(error)) => Err(error), } } fn resolve_hash_locked( &self, state: &ResolverState, node: &ResolvedNode, specifier: &str, mode: ResolutionMode, ) -> Result { let imports = node.manifest.imports_raw.clone().ok_or_else(|| { ResolutionError::NpmPackageMissingImport { name: node.manifest.name.clone(), specifier: specifier.to_owned(), } })?; let declared: BTreeSet = node.deps.keys().chain(node.peers.keys()).cloned().collect(); let conditions = self.conditions(mode); match resolve_imports_target(&imports, specifier, conditions, &declared)? { None => Err(ResolutionError::NpmPackageMissingImport { name: node.manifest.name.clone(), specifier: specifier.to_owned(), }), Some(NpmImportsTarget::File { path }) => { super::validate_npm_relpath(&path)?; if mode.is_type() { self.resolve_types_target_locked(node, &format!("./{path}"), specifier, mode) } else if !node.files.contains_key(&path) { Err(ResolutionError::NpmImportTargetNotInManifest { specifier: specifier.to_owned(), }) } else { self.finish_file_locked(node, &path, mode) } } Some(NpmImportsTarget::External { request }) => { let (name, sub) = split_bare(request.trim_start_matches("npm:")); let provider = node .deps .get(name) .or_else(|| node.peers.get(name)) .cloned() .ok_or_else(|| ResolutionError::NpmImportsTargetUndeclared { target: request.to_owned(), })?; let provider = self.node_locked(state, &provider)?; let export = match sub { Some(path) => subpath_to_export(path)?, None => ".".into(), }; self.resolve_in_node_locked(&provider, &export, mode) } } } fn resolve_target_locked( &self, node: &ResolvedNode, target: &str, mode: ResolutionMode, ) -> Result { let rel = target .strip_prefix("./") .ok_or_else(|| ResolutionError::NpmExportTargetEscapes { target: target.to_owned() })?; super::validate_npm_relpath(rel)?; if rel.split('/').any(|part| part == "node_modules") { return Err(ResolutionError::NpmExportTargetEscapes { target: target.to_owned() }); } if !node.files.contains_key(rel) { return Err(ResolutionError::NpmExportTargetNotInManifest { target: target.to_owned(), }); } // Export targets stay exact in both modes: no extension inference. self.finish_file_locked(node, rel, mode) } fn browser_map<'a>( &self, node: &'a ResolvedNode, ) -> Option<&'a serde_json::Map> { node.manifest .browser .as_ref()? .as_object() .filter(|_| self.browser) } fn finish_file_locked( &self, node: &ResolvedNode, rel: &str, mode: ResolutionMode, ) -> Result { // Exact `browser` file keys win over extensionless ones. let base = [".js", ".json", ".mjs", ".cjs"] .iter() .find_map(|extension| rel.strip_suffix(extension)); let remap = self.browser_map(node).and_then(|map| { [Some(rel), base] .into_iter() .flatten() .find_map(|key| map.get(&format!("./{key}")).or_else(|| map.get(key))) }); let rel = match remap { Some(serde_json::Value::Bool(false)) => return Ok(excluded(node, rel)), Some(serde_json::Value::String(target)) => { let target = target.trim_start_matches("./"); super::validate_npm_relpath(target)?; if !node.files.contains_key(target) { return Err(ResolutionError::NpmBrowserRemapTargetNotInManifest { target: target.to_owned(), }); } target } _ => rel, }; let url = npm_virtual_module( &node.identity.registry, &node.identity.name, &node.identity.version, rel, )?; super::check_npm_url(self.store.authority.as_ref(), &url)?; let kind = node.classify(&self.store.options().project_root, rel)?; if !mode.is_type() && kind == NpmModuleKind::Declaration { return Err(ResolutionError::NpmDeclarationNoCode { rel: rel.to_owned() }); } // ESM `import` of JSON stays unsupported; `require` allows JSON and // CJS, while ESM `import` of CJS resolves for synthetic wrappers. if !mode.is_require() && kind == NpmModuleKind::Json && !self.browser { return Err(ResolutionError::JsonModuleUnsupported { rel: rel.to_owned() }); } Ok(ResolvedNpmModule { path: node.dir.join(rel), format: kind, instance: node.instance.clone(), }) } async fn build_roots( &self, walk: &mut Walk<'_>, handle: &mut PreparedNpmRoots, normalized: &[(String, BTreeMap)], preselected: &BTreeMap, ) -> Result<(), ResolutionError> { for (request_key, context) in normalized { let effective = effective_context(preselected, context); let key = root_lock_key(request_key, &effective.merged()); if let Some(instance) = self.state.lock().unwrap().roots.get(&key) { handle.roots.insert(key, instance.clone()); continue; } let idx = self .build_occurrence(walk, request_key, &effective, false) .await? .ok_or_else(|| ResolutionError::NpmPackageNotSupportedHere { request_key: request_key.to_owned(), })?; walk.build.roots.insert(key, idx); } Ok(()) } /// Build one occurrence: memoize, acquire (cached per prepare), /// then descend. Ancestor occurrences with a satisfying version are /// reused so shadowing binds the exact provider instance instead of /// re-instantiating it. Returns `None` for skippable (optional + /// platform-mismatched) packages only. async fn build_occurrence( &self, walk: &mut Walk<'_>, request_key: &str, ctx: &ScopeCtx, skippable: bool, ) -> Result, ResolutionError> { let key = (request_key.to_owned(), scopeprint(ctx)); if let Some(idx) = walk.build.index.get(&key) { return Ok(Some(*idx)); } if walk.stack.len() >= MAX_NPM_GRAPH_DEPTH { return Err(ResolutionError::NpmDependencyGraphTooDeep); } if walk.build.occs.len() >= MAX_NPM_GRAPH_NODES { return Err(ResolutionError::NpmDependencyGraphTooLarge); } let artifact = self.acquire_cached(walk.acq, request_key).await?; let manifest = parse_npm_manifest( &artifact.identity.name, &artifact.identity.version, &artifact.package, )?; if !platform_satisfied(manifest.os.as_ref(), super::package::npm_host_os()) || !platform_satisfied(manifest.cpu.as_ref(), super::package::npm_host_arch()) { if skippable { return Ok(None); } return Err(ResolutionError::NpmPackagePlatformUnsupported { name: manifest.name.clone(), version: manifest.version.clone(), }); } let idx = walk.build.occs.len(); walk.build.occs.push(Occurrence { identity: artifact.identity.clone(), artifact, manifest: manifest.clone(), dep_edges: BTreeMap::new(), peer_edges: BTreeMap::new(), }); walk.build.index.insert(key, idx); walk.stack.push(idx); let mut bindings: BTreeMap = BTreeMap::new(); let mut missing_peers: Vec<(String, String)> = Vec::new(); for (peer, spec) in manifest.peers.clone() { if let Some(provider) = ctx.strict.get(&peer) { if !peer_version_satisfies(&spec.range, provider)? { if spec.optional { continue; } return Err(ResolutionError::NpmPeerWantsMismatch { peer: peer.to_owned(), wanted: spec.range.clone(), provided: peer_identity_version(provider)?, }); } bindings.insert( peer.clone(), (provider.clone(), peer_exact_request(provider)?), ); continue; } if let Some(provider) = ctx.flexible.get(&peer) && peer_version_satisfies(&spec.range, provider)? { bindings.insert( peer.clone(), (provider.clone(), peer_exact_request(provider)?), ); continue; } if spec.optional { continue; } let request = format!("npm:{}@{}", peer, spec.range); super::parse_npm_request(&request)?; missing_peers.push((peer.clone(), request)); } let missing: Vec = missing_peers .iter() .map(|(_, request)| request.clone()) .collect(); self.acquire_many(walk.acq, &missing).await?; for (peer, request) in &missing_peers { let provider = walk .acq .get(request) .ok_or_else(|| ResolutionError::NpmPackageNotPreparedRequest { request: request.to_owned(), })? .identity .key(); bindings.insert(peer.clone(), (provider, request.clone())); } let mut dep_requests: BTreeMap = BTreeMap::new(); let mut missing_deps: Vec<(String, String)> = Vec::new(); for (alias, dep) in manifest.dependencies.clone() { let target = match &dep.kind { super::package::NpmDepKind::Registry { request } => request.clone(), super::package::NpmDepKind::Alias { target } => target.clone(), super::package::NpmDepKind::Unsupported { protocol } => { return Err(ResolutionError::NpmDependencyUnsupportedProtocol { alias: alias.to_owned(), protocol: protocol.to_owned(), }); } }; super::parse_npm_request(&target)?; missing_deps.push((alias, target)); } let missing: Vec = missing_deps .iter() .map(|(_, target)| target.clone()) .collect(); self.acquire_many(walk.acq, &missing).await?; for (alias, target) in &missing_deps { let provider = walk .acq .get(target) .ok_or_else(|| ResolutionError::NpmPackageNotPreparedTarget { target: target.to_owned(), })? .identity .key(); dep_requests.insert(alias.clone(), (provider, target.clone())); } let mut child = ScopeCtx { strict: ctx.strict.clone(), flexible: ctx.flexible.clone(), }; for (provider, _) in dep_requests.values() { let (_, name, _) = super::parse_npm_key(provider)?; child.flexible.insert(name, provider.clone()); } for (peer, (provider, _)) in &bindings { child.flexible.insert(peer.clone(), provider.clone()); } // Peers resolve from the importer's context, not its private // dependencies: otherwise a sibling dep (x2) would rebind an // already-selected provider's peer (x1) into a fresh instance // instead of sharing the established one. let mut peer_child = ScopeCtx { strict: ctx.strict.clone(), flexible: ctx.flexible.clone(), }; for (peer, (provider, _)) in &bindings { peer_child.flexible.insert(peer.clone(), provider.clone()); } let mut dep_edges: BTreeMap = BTreeMap::new(); for (alias, (provider, request)) in &dep_requests { let optional = manifest .dependencies .get(alias) .is_some_and(|dep| dep.optional); let (_, name, _) = super::parse_npm_key(provider)?; match Box::pin(self.child_occurrence(walk, &name, request, &child, optional)).await? { Some(target) => { dep_edges.insert(alias.clone(), target); } None if optional => {} None => { return Err(ResolutionError::NpmDependencyUnresolvable { alias: alias.to_owned(), }); } } } let mut peer_edges: BTreeMap = BTreeMap::new(); for (peer, (_, request)) in &bindings { let optional = manifest.peers.get(peer).is_some_and(|entry| entry.optional); match Box::pin(self.child_occurrence(walk, peer, request, &peer_child, optional)) .await? { Some(target) => { peer_edges.insert(peer.clone(), target); } None if optional => {} None => { return Err(ResolutionError::NpmPeerUnresolvable { peer: peer.to_owned() }); } } } walk.build.occs[idx].dep_edges = dep_edges; walk.build.occs[idx].peer_edges = peer_edges; walk.stack.pop(); Ok(Some(idx)) } /// Concurrent downloads across package names; same-name requests stay /// sequential so later ranges reuse earlier pins, and the first input /// failure wins once started groups finish. async fn acquire_many( &self, acq: &mut BTreeMap, keys: &[String], ) -> Result<(), ResolutionError> { let mut ordered: Vec = Vec::new(); let mut seen: BTreeSet = BTreeSet::new(); for key in keys { if acq.contains_key(key) || !seen.insert(key.clone()) { continue; } ordered.push(key.clone()); } let mut names: Vec = Vec::new(); let mut groups: BTreeMap> = BTreeMap::new(); for (index, key) in ordered.iter().enumerate() { let name = super::parse_npm_request(key)?.name; if !groups.contains_key(&name) { names.push(name.clone()); } groups.entry(name).or_default().push((index, key.clone())); } let outcomes = stream::iter(names.into_iter().map(|name| { let entries = groups.remove(&name).expect("grouped package name present"); let store = self.store.clone(); async move { let mut done: Vec<(String, NpmArtifact)> = Vec::new(); for (index, key) in entries { match store.npm_acquire_for_resolution(&key).await { Ok(artifact) => done.push((key, artifact)), Err(error) => return (done, Some((index, error))), } } (done, None) } })) .buffer_unordered(MAX_NPM_CONCURRENT_DOWNLOADS) .collect::>() .await; let mut failure: Option<(usize, ResolutionError)> = None; for (done, failed) in outcomes { for (key, artifact) in done { acq.insert(key, artifact); } if let Some((index, error)) = failed && failure.as_ref().is_none_or(|current| index < current.0) { failure = Some((index, error.into())); } } if let Some((_, error)) = failure { return Err(error); } Ok(()) } /// One acquisition per request per prepare; hits skip re-verification. /// Artifacts stay local to the build: nothing here bypasses the /// store's verification on first fetch. async fn acquire_cached( &self, acq: &mut BTreeMap, request_key: &str, ) -> Result { if let Some(artifact) = acq.get(request_key) { return Ok(artifact.clone()); } let artifact = self.store.npm_acquire_for_resolution(request_key).await?; acq.insert(request_key.to_owned(), artifact.clone()); Ok(artifact) } /// Reuse matching ancestor occurrences to close package cycles. async fn child_occurrence( &self, walk: &mut Walk<'_>, name: &str, request: &str, ctx: &ScopeCtx, skippable: bool, ) -> Result, ResolutionError> { let parsed = super::parse_npm_request(request)?; for ancestor in walk.stack.iter().rev() { let occ = &walk.build.occs[*ancestor]; if occ.identity.name != name { continue; } if request_matches_version(&parsed, &occ.identity.version)? { return Ok(Some(*ancestor)); } } self.build_occurrence(walk, request, ctx, skippable).await } fn canonicalize(build: &OccGraph) -> BTreeMap { let identities: BTreeSet<_> = build.occs.iter().map(|occ| occ.identity.key()).collect(); let initial: BTreeMap<_, _> = identities .into_iter() .enumerate() .map(|(i, key)| (key, i)) .collect(); let mut labels: Vec = build .occs .iter() .map(|occ| initial[&occ.identity.key()]) .collect(); loop { let signatures: Vec<_> = build .occs .iter() .enumerate() .map(|(i, occ)| { let edges = |edges: &BTreeMap| { edges .iter() .map(|(name, target)| (name.clone(), labels[*target])) .collect::>() }; (labels[i], edges(&occ.dep_edges), edges(&occ.peer_edges)) }) .collect(); let classes: BTreeMap<_, _> = signatures .iter() .cloned() .collect::>() .into_iter() .enumerate() .map(|(i, signature)| (signature, i)) .collect(); let next: Vec<_> = signatures .iter() .map(|signature| classes[signature]) .collect(); let stable = classes.len() == labels.iter().copied().collect::>().len(); labels = next; if stable { break; } } let representatives: BTreeMap<_, _> = labels .iter() .copied() .enumerate() .map(|(i, label)| (label, i)) .collect(); let ids: BTreeMap<_, _> = representatives .iter() .map(|(label, representative)| { let root = &build.occs[*representative]; if root.dep_edges.is_empty() && root.peer_edges.is_empty() { return (*label, root.identity.key()); } // Number only the reachable quotient graph, so unrelated roots and // traversal order cannot change an instance's identity, even in cycles. let mut numbers = BTreeMap::from([(*label, 0usize)]); let mut pending = vec![*label]; let mut graph = Vec::new(); let mut cursor = 0; while cursor < pending.len() { let occ = &build.occs[representatives[&pending[cursor]]]; let mut edges = |edges: &BTreeMap| { edges .iter() .map(|(name, target)| { let target = labels[*target]; let next = numbers.len(); let number = *numbers.entry(target).or_insert_with(|| { pending.push(target); next }); (name.clone(), number) }) .collect::>() }; let deps = edges(&occ.dep_edges); let peers = edges(&occ.peer_edges); graph.push((occ.identity.key(), deps, peers)); cursor += 1; } let encoded = serde_json::to_string(&graph).unwrap(); ( *label, format!("p-{}", &super::artifact_key(&encoded)[..62]), ) }) .collect(); labels .into_iter() .enumerate() .map(|(i, label)| (i, ids[&label].clone())) .collect() } async fn finalize_build(&self, build: OccGraph) -> Result { let ids = Self::canonicalize(&build); let mut order: BTreeMap = BTreeMap::new(); for (idx, _) in build.occs.iter().enumerate() { order.entry(ids[&idx].clone()).or_insert(idx); } let mut nodes = BTreeMap::new(); for (id, rep) in &order { let occ = &build.occs[*rep]; let deps = occ .dep_edges .iter() .map(|(alias, target)| (alias.clone(), ids[target].clone())) .collect(); let peers = occ .peer_edges .iter() .map(|(peer, target)| (peer.clone(), ids[target].clone())) .collect(); let mut artifact = occ.artifact.clone(); let package_key = artifact.identity.key(); let disk_instance = storage::npm_disk_instance(id, &package_key); artifact.dir = storage::ensure_npm_instance(self.store.options(), &artifact, disk_instance)?; let node = Arc::new(ResolvedNode { instance: id.clone(), identity: artifact.identity, dir: artifact.dir, files: artifact.files, manifest: occ.manifest.clone(), scopes: Mutex::new(BTreeMap::new()), deps, peers, }); nodes.insert( id.clone(), Arc::new(PreparedInstance { pin: NpmInstancePin { package: package_key, deps: node.deps.clone(), peers: node.peers.clone(), }, dir: node.dir.clone(), loaded: Mutex::new(Some(node)), }), ); } let roots = build .roots .iter() .map(|(key, idx)| (key.clone(), ids[idx].clone())) .collect(); Ok(FinalizedGraph { roots, nodes }) } pub fn reserved_npm_path(project_root: &Path, path: &Path) -> bool { path.strip_prefix(project_root.join(".avril").join("npm")) .is_ok() } /// Staged bindings must still match the lock at publication. New resolutions /// cannot overwrite established instances; frozen never fills missing entries. fn record_graph_locked(&self, finalized: &FinalizedGraph) -> Result<(), ResolutionError> { let frozen = self.store.options().frozen; let pins: BTreeMap<&String, NpmInstancePin> = finalized .nodes .iter() .map(|(id, node)| (id, node.pin.clone())) .collect(); let mut lock = self.store.lock.lock().unwrap(); for (request, instance) in &finalized.roots { match lock.npm.roots.get(request) { Some(existing) if existing == instance => {} Some(_) => { return Err(ResolutionError::LockfileChanged { key: request.to_owned() }); } None if frozen => { return Err(ResolutionError::FrozenLockfileMissing { name: request.to_owned(), }); } None => {} } } for (id, pin) in &pins { match lock.npm.instances.get(*id) { Some(existing) if existing == pin => {} Some(_) => { return Err(ResolutionError::LockedNpmInstanceChanged { id: id.to_string() }); } None if frozen => { return Err(ResolutionError::NpmFrozenLockfileMissingInstance { id: id.to_string(), }); } None => {} } } let mut used = self.store.used.lock().unwrap(); let packages: BTreeSet<_> = pins.values().map(|pin| pin.package.clone()).collect(); for (request, version) in &lock.npm.specifiers { let name = parse_npm_request(request)?.name; let package = format!("{}|{name}|{version}", self.store.options().npm_registry); if packages.contains(&package) || finalized .roots .keys() .any(|key| root_request_part(key) == request) { used.npm_specifiers.insert(request.clone()); } } used.npm_packages.extend(packages); for (request, instance) in &finalized.roots { used.npm_roots.insert(request.clone()); if lock.npm.roots.get(request) != Some(instance) { used.pending_npm_roots.insert(request.clone()); } lock.npm.roots.insert(request.clone(), instance.clone()); } for (id, pin) in &pins { used.npm_instances.insert((*id).clone()); if lock.npm.instances.get(*id) != Some(pin) { used.pending_npm_instances.insert((*id).clone()); } lock.npm.instances.insert((*id).clone(), pin.clone()); } Ok(()) } } fn excluded(node: &ResolvedNode, key: &str) -> ResolvedNpmModule { ResolvedNpmModule { path: node.dir.join(format!("{key}{BROWSER_EXCLUDED}")), format: NpmModuleKind::CommonJs, instance: node.instance.clone(), } } fn peer_identity_version(identity: &str) -> Result { let parts: Vec<&str> = identity.split('|').collect(); if parts.len() != 3 { return Err(ResolutionError::InvalidNpmLockIdentity); } Ok(parts[2].to_owned()) } fn peer_version_satisfies(range: &str, identity: &str) -> Result { request_matches_text(range, &peer_identity_version(identity)?) } fn request_matches_version( request: &super::NpmRequest, version: &str, ) -> Result { if request.version_req.tag().is_some() || request.version_req.version_text() == "*" { return Ok(true); } let parsed = super::validate_npm_version(version)?; Ok(request.version_req.matches(&parsed)) } fn request_matches_text(range: &str, version: &str) -> Result { if range.is_empty() || range == "*" { return Ok(true); } let requirement = deno_semver::VersionReq::parse_from_npm(range).map_err(|detail| { ResolutionError::InvalidNpmVersionRange { range: range.to_owned(), detail: detail.to_string(), } })?; if requirement.tag().is_some() { return Ok(true); } let parsed = super::validate_npm_version(version)?; Ok(requirement.matches(&parsed)) } fn peer_exact_request(identity: &str) -> Result { let parts: Vec<&str> = identity.split('|').collect(); if parts.len() != 3 { return Err(ResolutionError::InvalidNpmLockIdentity); } Ok(format!("npm:{}@{}", parts[1], parts[2])) } fn ctxprint(ctx: &BTreeMap) -> String { ctx.iter() .map(|(name, identity)| format!("{name}={identity}")) .collect::>() .join(",") } /// Traversal environments. `strict` holds caller-preselected choices whose /// violation is an explicit error; `flexible` holds inherited selections /// that a contradictory local range silently shadows with a nested copy. #[derive(Clone, Debug, Default)] struct ScopeCtx { strict: BTreeMap, flexible: BTreeMap, } fn scopeprint(ctx: &ScopeCtx) -> String { format!("{}|{}", ctxprint(&ctx.strict), ctxprint(&ctx.flexible)) } fn root_lock_key(request_key: &str, context: &BTreeMap) -> String { if context.is_empty() { return request_key.to_owned(); } format!("{request_key}\0ctx:{}", ctxprint(context)) } fn root_request_part(key: &str) -> &str { key.split('\0').next().unwrap_or(key) } fn effective_context( preselected: &BTreeMap, caller: &BTreeMap, ) -> ScopeCtx { ScopeCtx { strict: caller.clone(), flexible: preselected.clone(), } } impl ScopeCtx { fn merged(&self) -> BTreeMap { let mut merged = self.flexible.clone(); for (peer, provider) in &self.strict { merged.insert(peer.clone(), provider.clone()); } merged } } /// Whether an export subpath falls beneath a warm scope: the scope /// itself, anything beneath it, or everything for a whole-package scope. fn under_export_scope(sub: &str, scope: &str) -> bool { scope.is_empty() || sub == scope || sub.starts_with(&format!("{scope}/")) } /// Every wildcard target pattern a pattern value can select, recursing /// through fallback arrays and condition objects. Literal trailers cannot /// capture and contribute nothing to inversion. fn pattern_targets(value: &serde_json::Value, out: &mut Vec) { match value { serde_json::Value::String(target) => { if target.contains('*') { out.push(target.clone()); } } serde_json::Value::Array(targets) => { for target in targets { pattern_targets(target, out); } } serde_json::Value::Object(map) => { for target in map.values() { pattern_targets(target, out); } } _ => {} } } fn split_npm_request(specifier: &str) -> Result<(String, Option), ResolutionError> { let request = super::parse_npm_request(specifier)?; Ok((request.request_key(), request.subpath)) } fn typescript_range_matches(text: &str, version: &deno_semver::Version) -> bool { use deno_semver::{RangeBound, Version, VersionBoundKind, VersionReq}; let matches = |term: &str| { let Ok(requirement) = VersionReq::parse_from_npm(term) else { return false; }; let shorthand = term.starts_with(['~', '^']) || term.contains(" - "); let operand = term.trim_start_matches(['>', '<', '=']); let partial = !shorthand && Version::parse_from_npm(operand).is_err(); if partial && operand.starts_with(['v', 'V']) { return false; } requirement.range().is_some_and(|ranges| { ranges.0.iter().any(|range| { let mut range = range.clone(); for (bound, lower) in [(&mut range.start, true), (&mut range.end, false)] { if let RangeBound::Version(bound) = bound { if partial { bound.version.pre = ["0".into()].into(); } else if shorthand && !lower && bound.kind == VersionBoundKind::Exclusive { // TS shorthand upper bounds include the next release's prereleases. bound.version.pre = Default::default(); } } } // Compiler ranges include prereleases; npm package selection deliberately does not. range.intersects_version(version) }) }) }; text.split("||").any(|alternative| { if alternative.contains(" - ") { return matches(alternative.trim()); } let mut terms = alternative.split_whitespace(); while let Some(term) = terms.next() { let matched = if matches!(term, ">" | ">=" | "<" | "<=" | "=" | "~" | "^") { terms .next() .is_some_and(|operand| matches(&format!("{term}{operand}"))) } else { matches(term) }; if !matched { return false; } } true }) } fn valid_types_versions_pattern(pattern: &str) -> bool { pattern.matches('*').count() <= 1 && valid_types_versions_target(&pattern.replace('*', "x")) } fn valid_types_versions_target(target: &str) -> bool { let path = target.strip_prefix("./").unwrap_or(target); super::validate_npm_relpath(path).is_ok() && !path .split('/') .any(|part| part.eq_ignore_ascii_case("node_modules")) } fn match_types_versions_pattern<'m, 'c>( mappings: &'m serde_json::Map, candidate: &'c str, ) -> Option<(&'m str, &'c str)> { if let Some((pattern, _)) = mappings.get_key_value(candidate) { return Some((pattern, "")); } // Equal-prefix patterns retain manifest order, unlike export patterns. mappings .keys() .enumerate() .filter_map(|(order, pattern)| { let (prefix, suffix) = pattern.split_once('*')?; let matched = candidate.strip_prefix(prefix)?.strip_suffix(suffix)?; Some((pattern.as_str(), matched, prefix.len(), order)) }) .max_by_key(|(_, _, prefix, order)| (*prefix, std::cmp::Reverse(*order))) .map(|(pattern, matched, _, _)| (pattern, matched)) } fn declaration_candidates(rel: &str) -> Vec { if rel.ends_with(".d.ts") || rel.ends_with(".d.mts") || rel.ends_with(".d.cts") { return vec![rel.to_owned()]; } let substitutions: &[(&str, &[&str])] = &[ (".js", &[".ts", ".tsx", ".d.ts"]), (".mjs", &[".mts", ".d.mts"]), (".cjs", &[".cts", ".d.cts"]), (".tsx", &[".tsx", ".ts", ".d.ts"]), (".ts", &[".ts", ".tsx", ".d.ts"]), (".mts", &[".mts", ".d.mts"]), (".cts", &[".cts", ".d.cts"]), ]; let (stem, suffixes) = substitutions .iter() .find_map(|(extension, suffixes)| rel.strip_suffix(extension).map(|stem| (stem, *suffixes))) .unwrap_or((rel, &[".ts", ".tsx", ".d.ts", "/index.ts", "/index.d.ts"])); suffixes .iter() .map(|suffix| format!("{stem}{suffix}")) .collect() } fn subpath_to_export(subpath: &str) -> Result { if let Some(path) = subpath.strip_prefix("./") { if path.is_empty() { return Err(ResolutionError::InvalidNpmExportSubpath { subpath: subpath.to_owned() }); } return Ok(subpath.to_owned()); } if subpath == "." || subpath.is_empty() { return Ok(".".into()); } Ok(format!("./{subpath}")) } impl ResolvedNode { fn classify(&self, project_root: &Path, rel: &str) -> Result { classify_npm_module(rel, || { // The module hint belongs to this file, regardless of how it was reached. if let Some(entry) = &self.manifest.module && entry.strip_prefix("./").unwrap_or(entry) == rel { return Ok(NpmPackageType::Module); } let mut directory = rel.rsplit_once('/').map_or("", |(parent, _)| parent); while !directory.is_empty() { let metadata = format!("{directory}/package.json"); if self.files.contains_key(&metadata) && let Some(package_type) = self.scope(project_root, &metadata)?.package_type { return Ok(package_type); } directory = directory.rsplit_once('/').map_or("", |(parent, _)| parent); } Ok(self.manifest.package_type) }) } fn scope( &self, project_root: &Path, rel: &str, ) -> Result, ResolutionError> { let mut scopes = self.scopes.lock().unwrap(); if let Some(scope) = scopes.get(rel) { return Ok(scope.clone()); } let bytes = self.read_verified(project_root, rel)?; let scope = Arc::new(parse_npm_scope(&bytes, rel)?); scopes.insert(rel.to_owned(), scope.clone()); Ok(scope) } fn read_verified(&self, project_root: &Path, rel: &str) -> Result, ResolutionError> { let expected = self .files .get(rel) .ok_or_else(|| ResolutionError::NpmFileNotInManifest { path: rel.to_owned() })?; let bytes = storage::read_project_file(project_root, &self.dir, rel, super::MAX_NPM_FILE_BYTES)?; if crate::registry::checksum(&bytes) != *expected { return Err(ResolutionError::NpmFileIntegrityMismatch { rel: rel.to_owned() }); } Ok(bytes) } }