char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somraise npm tarball entry limit98acb46

main
78.6 KiB2115 linesraw
1use std::collections::BTreeMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6
7use bytes::Bytes;
8use deno_semver::npm::NpmPackageReqReference;
9use http_body_util::{BodyExt, Empty};
10use hyper::Request;
11use hyper_rustls::HttpsConnector;
12use hyper_util::client::legacy::{Client, connect::HttpConnector};
13use serde::Deserialize;
14use url::Url;
15
16use super::{
17    Access, NpmAcquisition, NpmLock, NpmPin, Options, PackageStore, artifact_key, checksum,
18};
19use storage::NpmArchive;
20/// Failures resolving, downloading, and verifying npm packages.
21#[derive(Clone, Debug, thiserror::Error)]
22pub enum NpmError {
23    #[error("Locked npm package changed for {key}")]
24    LockedNpmPackageChanged { key: String },
25    #[error("npm file integrity mismatch: {rel}")]
26    NpmFileIntegrityMismatch { rel: String },
27    #[error("Frozen lockfile is missing {name}")]
28    FrozenLockfileMissing { name: String },
29    #[error("Invalid npm package.json: {source}")]
30    InvalidNpmPackageJson { source: Arc<serde_json::Error> },
31    #[error("npm manifest does not match its locked files")]
32    NpmManifestFilesMismatch,
33    #[error("npm archive changed for {name}@{version}")]
34    NpmArchiveChangedFor { name: String, version: String },
35    #[error("npm pin is missing its manifest")]
36    NpmPinMissingManifest,
37    #[error("npm package.json is missing its name")]
38    NpmPackageJsonMissingName,
39    #[error("npm package.json is missing its version")]
40    NpmPackageJsonMissingVersion,
41    #[error("npm package.json does not match its archive identity")]
42    NpmPackageJsonIdentityMismatch,
43    #[error("npm file is not in the package manifest: {path}")]
44    NpmFileNotInManifest { path: String },
45    #[error("Expected an npm: request, not {specifier:?}")]
46    ExpectedNpmRequest { specifier: String },
47    #[error("Invalid npm request: {specifier:?}")]
48    InvalidNpmRequest { specifier: String },
49    #[error("{detail}")]
50    NpmVersionReq { detail: String },
51    #[error("{detail}")]
52    NpmPackageReqReference { detail: String },
53    #[error("npm subpaths resolve later; acquire the package first")]
54    NpmSubpathNeedsAcquire,
55    #[error("Invalid npm package name: {name}")]
56    InvalidNpmPackageName { name: String },
57    #[error("Invalid npm version: {version}")]
58    InvalidNpmVersion { version: String },
59    #[error("Non-canonical npm version: {version}")]
60    NonCanonicalNpmVersion { version: String },
61    #[error("Invalid npm registry URL")]
62    InvalidNpmRegistryUrl,
63    #[error("Invalid locked npm request: {specifier}")]
64    InvalidLockedNpmRequest { specifier: String },
65    #[error("Invalid locked npm root: {key}")]
66    InvalidLockedNpmRoot { key: String },
67    #[error("Lockfile is missing npm instance {instance}")]
68    LockfileMissingNpmInstance { instance: String },
69    #[error("Lockfile is missing npm package {}", package)]
70    LockfileMissingNpmPackage { package: String },
71    #[error("Lockfile is missing npm instance {edge}")]
72    LockfileMissingNpmEdge { edge: String },
73    #[error("Invalid npm instance: {instance}")]
74    InvalidNpmInstance { instance: String },
75    #[error("Invalid npm lock identity")]
76    InvalidNpmLockIdentity,
77    #[error("Invalid automatic type association for {identity}")]
78    InvalidNpmTypesAssociation { identity: String },
79    #[error("Invalid npm manifest digest")]
80    InvalidNpmManifestDigest,
81    #[error("Invalid npm manifest")]
82    InvalidNpmManifest,
83    #[error("Invalid npm file checksum: {path}")]
84    InvalidNpmFileChecksum { path: String },
85    #[error("npm pin is missing package.json")]
86    NpmPinMissingPackageJson,
87    #[error("Invalid npm file path: {path}")]
88    InvalidNpmFilePath { path: String },
89    #[error("npm tarball path must be UTF-8")]
90    NpmTarballPathNotUtf8,
91    #[error("npm tarball mixes archive roots: {raw}")]
92    NpmTarballMixedRoots { raw: String },
93    #[error("Invalid npm tarball URL: {raw}")]
94    InvalidNpmTarballUrl { raw: String },
95    #[error("npm tarball credentials are not supported: {raw}")]
96    NpmTarballCredentials { raw: String },
97    #[error("npm URLs require HTTPS: {url}")]
98    NpmUrlRequiresHttps { url: String },
99    #[error("Unsupported npm integrity algorithm: {algorithm}")]
100    UnsupportedNpmIntegrityAlgorithm { algorithm: String },
101    #[error("Invalid npm integrity encoding")]
102    InvalidNpmIntegrityEncoding,
103    #[error("Invalid npm integrity length")]
104    InvalidNpmIntegrityLength,
105    #[error("Invalid npm integrity")]
106    InvalidNpmIntegrity,
107    #[error("Invalid npm shasum")]
108    InvalidNpmShasum,
109    #[error("npm tarball integrity mismatch")]
110    NpmTarballIntegrityMismatch,
111    #[error("npm tag {tag:?} does not exist for {}", name)]
112    NpmTagMissing { tag: String, name: String },
113    #[error("npm tag {tag:?} points at invalid version {version}")]
114    NpmTagInvalidVersion { tag: String, version: String },
115    #[error("npm tag {tag:?} points at missing version {version}")]
116    NpmTagMissingVersion { tag: String, version: String },
117    #[error("npm package {name} has no latest tag")]
118    NpmPackageNoLatestTag { name: String },
119    #[error("npm latest tag points at invalid version {version}")]
120    NpmLatestTagInvalidVersion { version: String },
121    #[error("npm latest tag points at missing version {version}")]
122    NpmLatestTagMissingVersion { version: String },
123    #[error("No npm version satisfies {}", version_text)]
124    NoNpmVersionSatisfies { version_text: String },
125    #[error("npm origin is outside the registry: {url}")]
126    NpmOriginOutsideRegistry { url: String },
127    #[error("{detail}")]
128    NpmHttp { detail: String },
129    #[error("npm redirect has no valid Location: {url}")]
130    NpmRedirectNoLocation { url: String },
131    #[error("npm redirect escapes the registry: {next}")]
132    NpmRedirectEscapesRegistry { next: String },
133    #[error("npm server returned HTML instead of {what}: {url}")]
134    NpmServerHtml { what: String, url: String },
135    #[error("npm {what} is too large: {url}")]
136    NpmResponseTooLarge { what: String, url: String },
137    #[error("Invalid npm tarball")]
138    InvalidNpmTarball,
139    #[error("Invalid npm tarball path")]
140    InvalidNpmTarballPath,
141    #[error("Invalid npm tarball entry")]
142    InvalidNpmTarballEntry,
143    #[error("npm tarball has too many entries")]
144    NpmTarballTooManyEntries,
145    #[error("npm file is too large: {path}")]
146    NpmFileTooLarge { path: String },
147    #[error("npm tarball expands to too many bytes")]
148    NpmTarballExpandsTooMuch,
149    #[error("npm tarball has a duplicate file: {path}")]
150    NpmTarballDuplicate { path: String },
151    #[error("npm tarball has too many files")]
152    NpmTarballTooManyFiles,
153    #[error("npm tarball is empty")]
154    NpmTarballEmpty,
155    #[error("npm download worker stopped")]
156    NpmDownloadWorkerStopped,
157    #[error("npm metadata worker stopped")]
158    NpmMetadataWorkerStopped,
159    #[error("npm acquisition worker stopped")]
160    NpmAcquisitionWorkerStopped,
161    #[error("Locked version {version} does not satisfy {specifier}")]
162    NpmLockedVersionUnsatisfied { version: String, specifier: String },
163    #[error("Too many npm redirects: {url}")]
164    NpmTooManyRedirects { url: String },
165    #[error("npm redirect credentials are not supported: {next}")]
166    NpmRedirectCredentials { next: String },
167    #[error("npm server returned {status} for {url}")]
168    NpmServerStatus { status: u16, url: String },
169    #[error(
170        "Type packages are not prepared for {identity}; run `avril cache` or configure `types.imports`"
171    )]
172    NpmTypesNotPrepared { identity: String },
173    #[error("npm request timed out: {url}")]
174    NpmRequestTimeout { url: String },
175    #[error("npm tarball is missing package.json")]
176    NpmTarballMissingPackageJson,
177    #[error("npm tarball is too large")]
178    NpmTarballTooLargeBare,
179    #[error("npm tarball escapes the registry: {tarball}")]
180    NpmTarballEscapesRegistry { tarball: String },
181    #[error("Offline npm cache miss: {name}")]
182    NpmOfflineCacheMiss { name: String },
183    #[error("Offline npm metadata miss: {name}")]
184    NpmOfflineMetadataMiss { name: String },
185    #[error("Invalid npm packument for {name}: {source}")]
186    InvalidNpmPackument {
187        name: String,
188        source: Arc<serde_json::Error>,
189    },
190    #[error("npm packument name mismatch for {name}")]
191    NpmPackumentNameMismatch { name: String },
192    #[error("Conflicting npm version selections for {request_key}")]
193    NpmConflictingVersionSelections { request_key: String },
194    #[error("npm version is missing tarball integrity")]
195    NpmVersionMissingIntegrity,
196    #[error("npm tarball must contain only regular files and directories")]
197    NpmTarballMustContainRegularFiles,
198    #[error(transparent)]
199    Storage(#[from] storage::StorageError),
200    #[error(transparent)]
201    Package(#[from] package::PackageError),
202    #[error(transparent)]
203    Url(#[from] url::ParseError),
204    #[error(transparent)]
205    Caps(#[from] crate::caps::CapabilitiesError),
206}
207
208mod fs;
209pub mod package;
210pub mod resolution;
211pub(crate) mod storage;
212
213pub(super) type SharedPackument = futures::future::Shared<
214    futures::future::BoxFuture<'static, Result<Arc<NpmPackument>, NpmError>>,
215>;
216
217pub const NPM_DEFAULT_INSTANCE: &str = "default";
218
219pub(super) const MAX_NPM_CONCURRENT_DOWNLOADS: usize = 8;
220
221const MAX_NPM_PACKUMENT_BYTES: usize = 8 * 1024 * 1024;
222const MAX_NPM_TARBALL_COMPRESSED: usize = 32 * 1024 * 1024;
223const MAX_NPM_TARBALL_EXPANDED: usize = 128 * 1024 * 1024;
224const MAX_NPM_FILES: usize = 65_536;
225const MAX_NPM_FILE_BYTES: usize = 32 * 1024 * 1024;
226const MAX_NPM_TAR_STREAM: usize = MAX_NPM_TARBALL_EXPANDED + MAX_NPM_FILES * 512 + 8192;
227const NPM_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
228
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230enum NpmVerification {
231    Full,
232    OnUse,
233}
234
235#[derive(Clone, Debug, PartialEq, Eq)]
236pub struct NpmIdentity {
237    pub registry: Url,
238    pub name: String,
239    pub version: String,
240}
241
242impl NpmIdentity {
243    pub fn key(&self) -> String {
244        format!("{}|{}|{}", self.registry.as_str(), self.name, self.version)
245    }
246}
247
248#[derive(Clone, Debug)]
249pub struct NpmArtifact {
250    pub identity: NpmIdentity,
251    pub tarball: String,
252    pub integrity: String,
253    pub dir: PathBuf,
254    pub files: BTreeMap<String, String>,
255    pub package: serde_json::Value,
256}
257
258impl NpmArtifact {
259    /// Read one verified package file. This is the loader-facing primitive:
260    /// callers must use these bytes rather than reading the tree directly, so
261    /// verification cannot be bypassed between check and use.
262    pub fn read_file(&self, project_root: &Path, path: &str) -> Result<Vec<u8>, NpmError> {
263        let expected = self
264            .files
265            .get(path)
266            .ok_or_else(|| NpmError::NpmFileNotInManifest { path: path.to_owned() })?;
267        let bytes = storage::read_project_file(project_root, &self.dir, path, MAX_NPM_FILE_BYTES)?;
268        if checksum(&bytes) != *expected {
269            return Err(NpmError::NpmFileIntegrityMismatch { rel: path.to_owned() });
270        }
271        Ok(bytes)
272    }
273}
274
275#[derive(Deserialize, Clone)]
276pub(super) struct NpmPackument {
277    name: String,
278    #[serde(default, rename = "dist-tags")]
279    dist_tags: BTreeMap<String, String>,
280    versions: BTreeMap<String, NpmVersionEntry>,
281}
282
283#[derive(Deserialize, Default, Clone)]
284struct NpmVersionEntry {
285    #[serde(default)]
286    dist: NpmDist,
287}
288
289#[derive(Deserialize, Default, Clone)]
290struct NpmDist {
291    #[serde(default)]
292    tarball: String,
293    #[serde(default)]
294    integrity: Option<String>,
295    #[serde(default)]
296    shasum: Option<String>,
297}
298
299/// A parsed `npm:` request using manifest range grammar (unions, spaces,
300/// comparators), which URL-grammar parsers reject. `request_key` preserves
301/// the declared range text so lock entries stay stable.
302#[derive(Clone, Debug, PartialEq, Eq)]
303pub struct NpmRequest {
304    pub name: String,
305    pub range: Option<String>,
306    pub version_req: deno_semver::VersionReq,
307    pub subpath: Option<String>,
308}
309
310impl NpmRequest {
311    pub fn request_key(&self) -> String {
312        match &self.range {
313            None => format!("npm:{}", self.name),
314            Some(range) => format!("npm:{}@{range}", self.name),
315        }
316    }
317}
318
319pub fn parse_npm_request(specifier: &str) -> Result<NpmRequest, NpmError> {
320    let text = specifier
321        .strip_prefix("npm:")
322        .ok_or_else(|| NpmError::ExpectedNpmRequest { specifier: specifier.to_owned() })?;
323    if text.is_empty() {
324        return Err(NpmError::ExpectedNpmRequest { specifier: specifier.to_owned() });
325    }
326    let (head, subpath): (String, Option<String>) = if let Some(rest) = text.strip_prefix('@') {
327        match rest.split_once('/') {
328            None => (text.to_owned(), None),
329            Some((_, tail)) => match tail.split_once('/') {
330                None => (text.to_owned(), None),
331                Some((_, sub)) => (
332                    text[..text.len() - sub.len() - 1].to_owned(),
333                    Some(sub.to_owned()),
334                ),
335            },
336        }
337    } else {
338        match text.split_once('/') {
339            None => (text.to_owned(), None),
340            Some((name, sub)) => (name.to_owned(), Some(sub.to_owned())),
341        }
342    };
343    let (name, range): (String, Option<String>) = if let Some(rest) = head.strip_prefix('@') {
344        match rest.rsplit_once('@') {
345            None => (head.clone(), None),
346            Some((scope_name, range)) => {
347                if range.is_empty() {
348                    return Err(NpmError::InvalidNpmRequest { specifier: specifier.to_owned() });
349                }
350                (format!("@{scope_name}"), Some(range.to_owned()))
351            }
352        }
353    } else {
354        match head.rsplit_once('@') {
355            None => (head.clone(), None),
356            Some((name, range)) => {
357                if range.is_empty() {
358                    return Err(NpmError::InvalidNpmRequest { specifier: specifier.to_owned() });
359                }
360                (name.to_owned(), Some(range.to_owned()))
361            }
362        }
363    };
364    validate_npm_name(&name)?;
365    let version_req = match &range {
366        None => deno_semver::VersionReq::parse_from_npm("*")
367            .map_err(|detail| NpmError::NpmVersionReq { detail: detail.to_string() })?,
368        Some(range) => deno_semver::VersionReq::parse_from_npm(range)
369            .map_err(|detail| NpmError::NpmVersionReq { detail: detail.to_string() })?,
370    };
371    Ok(NpmRequest {
372        name,
373        range,
374        version_req,
375        subpath,
376    })
377}
378
379pub fn parse_npm_specifier(specifier: &str) -> Result<NpmPackageReqReference, NpmError> {
380    let request = parse_npm_request(specifier)?;
381    if request.subpath.is_some() {
382        return Err(NpmError::NpmSubpathNeedsAcquire);
383    }
384    NpmPackageReqReference::from_str(&request.request_key())
385        .map_err(|detail| NpmError::NpmPackageReqReference { detail: detail.to_string() })
386}
387
388pub fn validate_npm_name(name: &str) -> Result<(), NpmError> {
389    if name.is_empty() || name.len() > 214 {
390        return Err(NpmError::InvalidNpmPackageName { name: name.to_owned() });
391    }
392    if let Some(rest) = name.strip_prefix('@') {
393        let Some((scope, leaf)) = rest.split_once('/') else {
394            return Err(NpmError::InvalidNpmPackageName { name: name.to_owned() });
395        };
396        validate_npm_part(scope)
397            .map_err(|_| NpmError::InvalidNpmPackageName { name: name.to_owned() })?;
398        validate_npm_part(leaf)
399            .map_err(|_| NpmError::InvalidNpmPackageName { name: name.to_owned() })?;
400        if name.split('/').count() != 2 {
401            return Err(NpmError::InvalidNpmPackageName { name: name.to_owned() });
402        }
403    } else {
404        if name.contains('/') {
405            return Err(NpmError::InvalidNpmPackageName { name: name.to_owned() });
406        }
407        validate_npm_part(name)
408            .map_err(|_| NpmError::InvalidNpmPackageName { name: name.to_owned() })?;
409    }
410    Ok(())
411}
412
413fn validate_npm_part(part: &str) -> Result<(), ()> {
414    if part.is_empty()
415        || part.len() > 214
416        || part.starts_with('.')
417        || part.starts_with('_')
418        || part
419            .bytes()
420            .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.' | b'~')))
421    {
422        return Err(());
423    }
424    Ok(())
425}
426
427fn validate_npm_version(version: &str) -> Result<deno_semver::Version, NpmError> {
428    let parsed = deno_semver::Version::parse_from_npm(version)
429        .map_err(|_| NpmError::InvalidNpmVersion { version: version.to_owned() })?;
430    if parsed.to_string() != version {
431        return Err(NpmError::NonCanonicalNpmVersion { version: version.to_owned() });
432    }
433    Ok(parsed)
434}
435
436/// Provenance base for acquisition checks: always slash-terminated so sibling
437/// names cannot collide through string-prefix grants.
438pub fn npm_package_base(registry: &Url, name: &str) -> Result<Url, NpmError> {
439    validate_npm_name(name)?;
440    let mut base = registry.join(&format!("{name}/"))?;
441    base.set_query(None);
442    base.set_fragment(None);
443    Ok(base)
444}
445
446/// Version base for execution checks and virtual file URLs.
447pub fn npm_version_base(registry: &Url, name: &str, version: &str) -> Result<Url, NpmError> {
448    validate_npm_version(version)?;
449    let package = npm_package_base(registry, name)?;
450    Ok(package.join(&format!("{version}/"))?)
451}
452
453/// Canonical virtual URL of one package file, for capability grants and the
454/// next loader chunk. Transport never fetches these; they name authority.
455pub fn npm_virtual_module(
456    registry: &Url,
457    name: &str,
458    version: &str,
459    path: &str,
460) -> Result<Url, NpmError> {
461    validate_npm_relpath(path)?;
462    let mut url = npm_version_base(registry, name, version)?;
463    url.path_segments_mut()
464        .map_err(|_| NpmError::InvalidNpmRegistryUrl)?
465        .pop_if_empty()
466        .extend(path.split('/'));
467    Ok(url)
468}
469
470pub(super) fn validate_lock_npm(npm: &NpmLock, expected_registry: &Url) -> Result<(), NpmError> {
471    for (identity, request) in &npm.types {
472        let (registry, name, version) = parse_npm_key(identity)
473            .map_err(|_| NpmError::InvalidNpmTypesAssociation { identity: identity.clone() })?;
474        let _ = npm_package_base(&registry, &name)?;
475        if &registry != expected_registry || !npm.packages.contains_key(identity) {
476            return Err(NpmError::InvalidNpmTypesAssociation { identity: identity.clone() });
477        }
478        let source_version = validate_npm_version(&version)?;
479        if let Some(request) = request {
480            let parsed = parse_npm_request(request)
481                .map_err(|_| NpmError::InvalidNpmTypesAssociation { identity: identity.clone() })?;
482            let expected = package::types_package_name(&name);
483            let next_major = source_version.major.checked_add(1).ok_or_else(|| {
484                NpmError::InvalidNpmTypesAssociation { identity: identity.clone() }
485            })?;
486            let expected_range = format!(">={}.0.0 <{next_major}.0.0", source_version.major);
487            let type_version = npm.specifiers.get(request).ok_or_else(|| {
488                NpmError::InvalidNpmTypesAssociation { identity: identity.clone() }
489            })?;
490            let type_key = format!("{}|{}|{type_version}", expected_registry, parsed.name);
491            if name.starts_with("@types/")
492                || parsed.name != expected
493                || parsed.subpath.is_some()
494                || parsed.range.as_deref() != Some(expected_range.as_str())
495                || parsed.request_key() != *request
496                || !npm.packages.contains_key(&type_key)
497            {
498                return Err(NpmError::InvalidNpmTypesAssociation { identity: identity.clone() });
499            }
500        }
501    }
502    for (key, pin) in &npm.packages {
503        let (registry, name, version) = parse_npm_key(key)?;
504        let _ = npm_package_base(&registry, &name)?;
505        validate_npm_version(&version)?;
506        validate_npm_pin(pin)?;
507        let tarball = npm_tarball_url(&pin.tarball)?;
508        if !same_origin(&registry, &tarball) {
509            return Err(NpmError::NpmTarballEscapesRegistry { tarball: pin.tarball.clone() });
510        }
511        check_https(&tarball)?;
512    }
513    for (specifier, version) in &npm.specifiers {
514        let request = parse_npm_request(specifier)?;
515        if request.subpath.is_some() {
516            return Err(NpmError::InvalidLockedNpmRequest { specifier: specifier.to_owned() });
517        }
518        let locked = validate_npm_version(version)?;
519        if !locked_satisfies(request.version_req.clone(), &locked) {
520            return Err(NpmError::NpmLockedVersionUnsatisfied {
521                version: version.clone(),
522                specifier: specifier.to_owned(),
523            });
524        }
525    }
526    for (key, instance) in &npm.roots {
527        let (request, context) = match key.split_once('\0') {
528            None => (key.as_str(), None),
529            Some((request, suffix)) => {
530                let context = suffix
531                    .strip_prefix("ctx:")
532                    .ok_or_else(|| NpmError::InvalidLockedNpmRoot { key: key.to_owned() })?;
533                (request, Some(context))
534            }
535        };
536        let parsed = parse_npm_request(request)?;
537        if parsed.subpath.is_some() {
538            return Err(NpmError::InvalidLockedNpmRoot { key: key.to_owned() });
539        }
540        if let Some(context) = context {
541            if context.is_empty() {
542                return Err(NpmError::InvalidLockedNpmRoot { key: key.to_owned() });
543            }
544            for entry in context.split(',') {
545                let (peer, provider) = entry
546                    .split_once('=')
547                    .ok_or_else(|| NpmError::InvalidLockedNpmRoot { key: key.to_owned() })?;
548                validate_npm_name(peer)?;
549                parse_npm_key(provider)?;
550            }
551        }
552        validate_npm_instance_id(instance)?;
553        if !npm.instances.contains_key(instance) {
554            return Err(NpmError::LockfileMissingNpmInstance { instance: instance.to_owned() });
555        }
556    }
557    for (instance, pin) in &npm.instances {
558        validate_npm_instance_id(instance)?;
559        let (registry, name, version) = parse_npm_key(&pin.package)?;
560        let _ = npm_package_base(&registry, &name)?;
561        validate_npm_version(&version)?;
562        if !npm.packages.contains_key(&pin.package) {
563            return Err(NpmError::LockfileMissingNpmPackage { package: pin.package.clone() });
564        }
565        if instance != &pin.package && !instance.starts_with("p-") {
566            return Err(NpmError::InvalidNpmInstance { instance: instance.to_owned() });
567        }
568        for (alias, edge) in pin.deps.iter().chain(pin.peers.iter()) {
569            validate_npm_name(alias)?;
570            validate_npm_instance_id(edge)?;
571            if !npm.instances.contains_key(edge) {
572                return Err(NpmError::LockfileMissingNpmEdge { edge: edge.to_owned() });
573            }
574        }
575    }
576    Ok(())
577}
578
579fn validate_npm_instance_id(instance: &str) -> Result<(), NpmError> {
580    if instance == NPM_DEFAULT_INSTANCE {
581        return Ok(());
582    }
583    if let Some(rest) = instance.strip_prefix("p-") {
584        if (16..=64).contains(&rest.len()) && rest.bytes().all(|ch| ch.is_ascii_hexdigit()) {
585            return Ok(());
586        }
587        return Err(NpmError::InvalidNpmInstance { instance: instance.to_owned() });
588    }
589    parse_npm_key(instance)?;
590    Ok(())
591}
592
593pub(super) fn parse_npm_key(key: &str) -> Result<(Url, String, String), NpmError> {
594    let mut parts = key.splitn(3, '|');
595    let (Some(registry), Some(name), Some(version)) = (parts.next(), parts.next(), parts.next())
596    else {
597        return Err(NpmError::InvalidNpmLockIdentity);
598    };
599    if parts.next().is_some() {
600        return Err(NpmError::InvalidNpmLockIdentity);
601    }
602    let url = Url::parse(registry).map_err(|_| NpmError::InvalidNpmLockIdentity)?;
603    if url.as_str() != registry {
604        return Err(NpmError::InvalidNpmLockIdentity);
605    }
606    validate_npm_name(name)?;
607    validate_npm_version(version)?;
608    Ok((url, name.to_owned(), version.to_owned()))
609}
610
611#[cfg(test)]
612mod types_lock_tests {
613    use super::{NpmLock, NpmPin, parse_npm_request, validate_lock_npm};
614    use std::collections::BTreeMap;
615
616    #[test]
617    fn validates_automatic_type_associations() {
618        let source = "https://registry.npmjs.org/|@scope/library|0.7.4";
619        let request = "npm:@types/scope__library@>=0.0.0 <1.0.0";
620        let lock = NpmLock {
621            specifiers: BTreeMap::from([(request.to_owned(), "0.9.0".to_owned())]),
622            packages: BTreeMap::from([
623                (source.to_owned(), test_pin()),
624                (
625                    "https://registry.npmjs.org/|@types/scope__library|0.9.0".to_owned(),
626                    test_pin(),
627                ),
628            ]),
629            types: BTreeMap::from([(source.to_owned(), Some(request.to_owned()))]),
630            ..NpmLock::default()
631        };
632        validate_lock_npm(
633            &lock,
634            &url::Url::parse("https://registry.npmjs.org/").unwrap(),
635        )
636        .unwrap();
637        assert_eq!(
638            parse_npm_request(request).unwrap().name,
639            "@types/scope__library"
640        );
641
642        let registry = url::Url::parse("https://registry.npmjs.org/").unwrap();
643        let mut invalid = lock.clone();
644        invalid
645            .types
646            .insert(source.to_owned(), Some("npm:@types/other@*".to_owned()));
647        assert!(validate_lock_npm(&invalid, &registry).is_err());
648
649        let mut dangling = lock.clone();
650        dangling
651            .packages
652            .remove("https://registry.npmjs.org/|@types/scope__library|0.9.0");
653        assert!(validate_lock_npm(&dangling, &registry).is_err());
654        let mut missing_source = lock.clone();
655        missing_source.packages.remove(source);
656        assert!(validate_lock_npm(&missing_source, &registry).is_err());
657        assert!(
658            validate_lock_npm(&lock, &url::Url::parse("https://elsewhere.test/").unwrap()).is_err()
659        );
660    }
661
662    #[test]
663    fn type_packages_can_have_negative_discovery_records() {
664        let identity = "https://registry.npmjs.org/|@types/node|22.0.0";
665        let lock = NpmLock {
666            packages: BTreeMap::from([(identity.to_owned(), test_pin())]),
667            types: BTreeMap::from([(identity.to_owned(), None)]),
668            ..NpmLock::default()
669        };
670        validate_lock_npm(
671            &lock,
672            &url::Url::parse("https://registry.npmjs.org/").unwrap(),
673        )
674        .unwrap();
675    }
676
677    fn test_pin() -> NpmPin {
678        NpmPin {
679            tarball: "https://registry.npmjs.org/package.tgz".to_owned(),
680            integrity: "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==".to_owned(),
681            manifest: Some(format!("sha256-{}", "0".repeat(64))),
682            files: None,
683        }
684    }
685}
686
687fn locked_satisfies(version_req: deno_semver::VersionReq, version: &deno_semver::Version) -> bool {
688    if version_req.tag().is_some() || version_req.version_text() == "*" {
689        return true;
690    }
691    version_req.matches(version)
692}
693
694fn validate_npm_pin(pin: &NpmPin) -> Result<(), NpmError> {
695    npm_tarball_url(&pin.tarball)?;
696    select_sri(&pin.integrity)?;
697    match (&pin.manifest, &pin.files) {
698        (Some(manifest), Some(files)) => {
699            manifest_file_name(manifest)?;
700            validate_files_map(files)?;
701            if storage::manifest_digest(files)? != *manifest {
702                return Err(NpmError::NpmManifestFilesMismatch);
703            }
704        }
705        (Some(manifest), None) => {
706            manifest_file_name(manifest)?;
707        }
708        (None, Some(files)) => validate_files_map(files)?,
709        (None, None) => return Err(NpmError::NpmPinMissingManifest),
710    }
711    Ok(())
712}
713
714pub(crate) fn manifest_file_name(digest: &str) -> Result<String, NpmError> {
715    let hex = digest
716        .strip_prefix("sha256-")
717        .ok_or(NpmError::InvalidNpmManifestDigest)?;
718    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
719        return Err(NpmError::InvalidNpmManifestDigest);
720    }
721    Ok(format!("{hex}.json"))
722}
723
724pub(crate) fn validate_files_map(files: &BTreeMap<String, String>) -> Result<(), NpmError> {
725    if files.is_empty() || files.len() > MAX_NPM_FILES {
726        return Err(NpmError::InvalidNpmManifest);
727    }
728    for (path, hash) in files {
729        validate_npm_relpath(path)?;
730        if !valid_npm_checksum(hash) {
731            return Err(NpmError::InvalidNpmFileChecksum { path: path.to_owned() });
732        }
733    }
734    if !files.contains_key("package.json") {
735        return Err(NpmError::NpmPinMissingPackageJson);
736    }
737    Ok(())
738}
739
740fn valid_npm_checksum(hash: &str) -> bool {
741    hash.strip_prefix("sha256-")
742        .is_some_and(|hex| hex.len() == 64 && hex.bytes().all(|ch| ch.is_ascii_hexdigit()))
743}
744
745fn validate_npm_relpath(path: &str) -> Result<(), NpmError> {
746    if path.is_empty() || path.len() > 1024 {
747        return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
748    }
749    if path.starts_with('/') || path.contains('\\') || path.contains('\0') {
750        return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
751    }
752    if path.bytes().any(|byte| byte.is_ascii_control()) {
753        return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
754    }
755    let lower = path.to_ascii_lowercase();
756    if lower.contains("%2f") || lower.contains("%5c") {
757        return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
758    }
759    for part in path.split('/') {
760        if part.is_empty() || part == "." || part == ".." {
761            return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
762        }
763        if part.len() > 255 || part.contains(':') {
764            return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
765        }
766        if part.ends_with('.') || part.ends_with(' ') {
767            return Err(NpmError::InvalidNpmFilePath { path: path.to_owned() });
768        }
769    }
770    Ok(())
771}
772
773/// Tar entry names arrive as platform paths: one consistent safe top-level
774/// directory is accepted for the whole archive and stripped, harmless `.`
775/// segments beneath it normalize away, while anything else must already be
776/// a clean relative posix path. Returns empty only for ignored root directories.
777fn npm_relpath_from_tar(
778    path: &std::path::Path,
779    is_dir: bool,
780    root: &mut Option<String>,
781) -> Result<String, NpmError> {
782    let bytes = path.as_os_str().as_encoded_bytes();
783    let raw = std::str::from_utf8(bytes).map_err(|_| NpmError::NpmTarballPathNotUtf8)?;
784    if raw.contains('\\') {
785        return Err(NpmError::InvalidNpmFilePath { path: raw.to_owned() });
786    }
787    let (candidate, stripped) = match raw.find('/') {
788        Some(slash) => (&raw[..slash], &raw[slash + 1..]),
789        None => (raw, ""),
790    };
791    validate_npm_relpath(candidate)
792        .map_err(|_| NpmError::InvalidNpmFilePath { path: raw.to_owned() })?;
793    match root {
794        Some(bound) if *bound != candidate => {
795            return Err(NpmError::NpmTarballMixedRoots { raw: raw.to_owned() });
796        }
797        Some(_) => {}
798        None => *root = Some(candidate.to_owned()),
799    }
800    if stripped.len() > 1024 {
801        return Err(NpmError::InvalidNpmFilePath { path: stripped.to_owned() });
802    }
803    if is_dir {
804        if stripped.is_empty() {
805            return Ok(String::new());
806        }
807        let inner = stripped.strip_suffix('/').unwrap_or(stripped);
808        if inner.is_empty() || inner.split('/').any(|part| part.is_empty()) {
809            return Err(NpmError::InvalidNpmFilePath { path: stripped.to_owned() });
810        }
811        let canonical: Vec<&str> = inner.split('/').filter(|part| *part != ".").collect();
812        let canonical = canonical.join("/");
813        if canonical.is_empty() {
814            return Ok(String::new());
815        }
816        validate_npm_relpath(&canonical)?;
817        return Ok(canonical);
818    }
819    let canonical: Vec<&str> = stripped.split('/').filter(|part| *part != ".").collect();
820    let canonical = canonical.join("/");
821    validate_npm_relpath(&canonical)?;
822    Ok(canonical)
823}
824
825fn npm_tarball_url(raw: &str) -> Result<Url, NpmError> {
826    let url =
827        Url::parse(raw).map_err(|_| NpmError::InvalidNpmTarballUrl { raw: raw.to_owned() })?;
828    if !matches!(url.scheme(), "http" | "https") || url.host().is_none() {
829        return Err(NpmError::InvalidNpmTarballUrl { raw: raw.to_owned() });
830    }
831    if !url.username().is_empty() || url.password().is_some() {
832        return Err(NpmError::NpmTarballCredentials { raw: raw.to_owned() });
833    }
834    if url.query().is_some() || url.fragment().is_some() {
835        return Err(NpmError::InvalidNpmTarballUrl { raw: raw.to_owned() });
836    }
837    Ok(url)
838}
839
840fn same_origin(left: &Url, right: &Url) -> bool {
841    left.scheme() == right.scheme()
842        && left.host_str() == right.host_str()
843        && left.port_or_known_default() == right.port_or_known_default()
844}
845
846fn is_loopback(url: &Url) -> bool {
847    matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
848}
849
850fn check_https(url: &Url) -> Result<(), NpmError> {
851    if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback(url)) {
852        return Err(NpmError::NpmUrlRequiresHttps { url: url.to_string() });
853    }
854    Ok(())
855}
856
857fn packument_url(registry: &Url, name: &str) -> Result<Url, NpmError> {
858    validate_npm_name(name)?;
859    let mut url = registry.join(name)?;
860    url.set_query(None);
861    url.set_fragment(None);
862    Ok(url)
863}
864
865fn validate_instance(instance: &str) -> Result<(), NpmError> {
866    if instance.is_empty()
867        || instance.len() > 64
868        || instance.starts_with('.')
869        || instance.bytes().any(|byte| {
870            byte == b'/'
871                || byte == b'\\'
872                || byte == b':'
873                || byte == b'\0'
874                || byte.is_ascii_control()
875        })
876    {
877        return Err(NpmError::InvalidNpmInstance { instance: instance.to_owned() });
878    }
879    Ok(())
880}
881
882fn select_sri(sri: &str) -> Result<(String, Vec<u8>), NpmError> {
883    let mut best: Option<(u8, String, Vec<u8>)> = None;
884    for token in sri.split_whitespace() {
885        let (algorithm, encoded) = token.split_once('-').ok_or(NpmError::InvalidNpmIntegrity)?;
886        let rank: u8 = match algorithm {
887            "sha512" => 3,
888            "sha256" => 2,
889            "sha1" => 1,
890            _ => {
891                return Err(NpmError::UnsupportedNpmIntegrityAlgorithm {
892                    algorithm: algorithm.to_owned(),
893                });
894            }
895        };
896        use base64::Engine;
897        let digest = base64::engine::general_purpose::STANDARD
898            .decode(encoded)
899            .map_err(|_| NpmError::InvalidNpmIntegrityEncoding)?;
900        let expected = match rank {
901            3 => 64,
902            2 => 32,
903            _ => 20,
904        };
905        if digest.len() != expected {
906            return Err(NpmError::InvalidNpmIntegrityLength);
907        }
908        if best
909            .as_ref()
910            .is_some_and(|(current, _, _)| *current >= rank)
911        {
912            continue;
913        }
914        let normalized = format!(
915            "{algorithm}-{}",
916            base64::engine::general_purpose::STANDARD.encode(&digest)
917        );
918        best = Some((rank, normalized, digest));
919    }
920    best.map(|(_, normalized, digest)| (normalized, digest))
921        .ok_or(NpmError::InvalidNpmIntegrity)
922}
923
924fn verify_sri(bytes: &[u8], sri: &str) -> Result<(), NpmError> {
925    let (normalized, expected) = select_sri(sri)?;
926    let algorithm = normalized.split_once('-').unwrap().0;
927    let actual: Vec<u8> = match algorithm {
928        "sha512" => {
929            use sha2::Digest;
930            sha2::Sha512::digest(bytes).to_vec()
931        }
932        "sha256" => {
933            use sha2::Digest;
934            sha2::Sha256::digest(bytes).to_vec()
935        }
936        _ => {
937            use sha1::Digest as _;
938            sha1::Sha1::digest(bytes).to_vec()
939        }
940    };
941    if actual != expected {
942        return Err(NpmError::NpmTarballIntegrityMismatch);
943    }
944    Ok(())
945}
946
947fn normalized_sri(sri: &str) -> Result<String, NpmError> {
948    let (normalized, _) = select_sri(sri)?;
949    Ok(normalized)
950}
951
952fn sri_from_shasum(shasum: &str) -> Result<String, NpmError> {
953    if shasum.len() != 40 || !shasum.bytes().all(|ch| ch.is_ascii_hexdigit()) {
954        return Err(NpmError::InvalidNpmShasum);
955    }
956    let mut raw = Vec::with_capacity(20);
957    for chunk in 0..20 {
958        let byte = u8::from_str_radix(&shasum[chunk * 2..chunk * 2 + 2], 16)
959            .map_err(|_| NpmError::InvalidNpmShasum)?;
960        raw.push(byte);
961    }
962    use base64::Engine;
963    Ok(format!(
964        "sha1-{}",
965        base64::engine::general_purpose::STANDARD.encode(raw)
966    ))
967}
968
969fn pin_integrity(dist: &NpmDist) -> Result<String, NpmError> {
970    if let Some(integrity) = &dist.integrity {
971        normalized_sri(integrity)
972    } else if let Some(shasum) = &dist.shasum {
973        sri_from_shasum(shasum)
974    } else {
975        Err(NpmError::NpmVersionMissingIntegrity)
976    }
977}
978
979fn select_npm_version(
980    packument: &NpmPackument,
981    version_req: &deno_semver::VersionReq,
982) -> Result<(String, NpmDist), NpmError> {
983    if let Some(tag) = version_req.tag() {
984        let version = packument
985            .dist_tags
986            .get(tag)
987            .ok_or_else(|| NpmError::NpmTagMissing {
988                tag: tag.to_owned(),
989                name: packument.name.clone(),
990            })?;
991        validate_npm_version(version).map_err(|_| NpmError::NpmTagInvalidVersion {
992            tag: tag.to_owned(),
993            version: version.to_owned(),
994        })?;
995        let entry =
996            packument
997                .versions
998                .get(version)
999                .ok_or_else(|| NpmError::NpmTagMissingVersion {
1000                    tag: tag.to_owned(),
1001                    version: version.to_owned(),
1002                })?;
1003        return Ok((version.clone(), entry.dist.clone()));
1004    }
1005    if version_req.version_text() == "*" {
1006        let version = packument
1007            .dist_tags
1008            .get("latest")
1009            .ok_or_else(|| NpmError::NpmPackageNoLatestTag { name: packument.name.clone() })?;
1010        validate_npm_version(version)
1011            .map_err(|_| NpmError::NpmLatestTagInvalidVersion { version: version.to_owned() })?;
1012        let entry = packument
1013            .versions
1014            .get(version)
1015            .ok_or_else(|| NpmError::NpmLatestTagMissingVersion { version: version.to_owned() })?;
1016        return Ok((version.clone(), entry.dist.clone()));
1017    }
1018    let mut best: Option<(deno_semver::Version, String)> = None;
1019    for version_text in packument.versions.keys() {
1020        let Ok(version) = deno_semver::Version::parse_from_npm(version_text) else {
1021            continue;
1022        };
1023        if !version_req.matches(&version) {
1024            continue;
1025        }
1026        if best
1027            .as_ref()
1028            .is_some_and(|(current, _)| current >= &version)
1029        {
1030            continue;
1031        }
1032        best = Some((version, version_text.clone()));
1033    }
1034    let (_, version_text) = best.ok_or_else(|| NpmError::NoNpmVersionSatisfies {
1035        version_text: version_req.version_text().to_owned(),
1036    })?;
1037    let entry = &packument.versions[&version_text];
1038    Ok((version_text, entry.dist.clone()))
1039}
1040
1041/// A pinned package already satisfying a new range is reused without
1042/// consulting mutable packument metadata.
1043fn find_pinned_satisfying(
1044    lock: &super::Lockfile,
1045    registry: &Url,
1046    name: &str,
1047    version_req: &deno_semver::VersionReq,
1048) -> Option<(String, NpmPin)> {
1049    if version_req.tag().is_some() || version_req.version_text() == "*" {
1050        return None;
1051    }
1052    let mut best: Option<(deno_semver::Version, String)> = None;
1053    for key in lock.npm.packages.keys() {
1054        let Ok((key_registry, key_name, key_version)) = parse_npm_key(key) else {
1055            continue;
1056        };
1057        if key_registry != *registry || key_name != name {
1058            continue;
1059        }
1060        let Ok(version) = deno_semver::Version::parse_from_npm(&key_version) else {
1061            continue;
1062        };
1063        if !version_req.matches(&version) {
1064            continue;
1065        }
1066        if best
1067            .as_ref()
1068            .is_some_and(|(current, _)| current >= &version)
1069        {
1070            continue;
1071        }
1072        best = Some((version, key_version));
1073    }
1074    best.and_then(|(_, version)| {
1075        let key = format!("{}|{name}|{version}", registry.as_str());
1076        lock.npm
1077            .packages
1078            .get(&key)
1079            .map(|pin| (version, pin.clone()))
1080    })
1081}
1082
1083struct BoundedTarReader<R> {
1084    inner: R,
1085    remaining: usize,
1086}
1087
1088impl<R: std::io::Read> std::io::Read for BoundedTarReader<R> {
1089    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1090        if self.remaining == 0 {
1091            return Err(std::io::Error::new(
1092                std::io::ErrorKind::FileTooLarge,
1093                "npm tarball expands to too many bytes",
1094            ));
1095        }
1096        let capped = buf.len().min(self.remaining);
1097        let read = self.inner.read(&mut buf[..capped])?;
1098        self.remaining -= read;
1099        Ok(read)
1100    }
1101}
1102
1103fn collect_npm_files(tarball: &[u8]) -> Result<BTreeMap<String, Vec<u8>>, NpmError> {
1104    use flate2::read::GzDecoder;
1105    let decoder = GzDecoder::new(tarball);
1106    let bounded = BoundedTarReader {
1107        inner: decoder,
1108        remaining: MAX_NPM_TAR_STREAM,
1109    };
1110    let mut archive = tar::Archive::new(bounded);
1111    let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1112    let mut root: Option<String> = None;
1113    let mut entries = 0usize;
1114    let mut expanded: usize = 0;
1115    let entries_iter = archive.entries().map_err(|_| NpmError::InvalidNpmTarball)?;
1116    for entry in entries_iter {
1117        let entry = entry.map_err(|_| NpmError::InvalidNpmTarball)?;
1118        entries += 1;
1119        if entries > MAX_NPM_FILES {
1120            return Err(NpmError::NpmTarballTooManyEntries);
1121        }
1122        let kind = entry.header().entry_type();
1123        if kind.is_dir() {
1124            let path = entry.path().map_err(|_| NpmError::InvalidNpmTarballPath)?;
1125            npm_relpath_from_tar(&path, true, &mut root)?;
1126            continue;
1127        }
1128        if !kind.is_file() {
1129            return Err(NpmError::NpmTarballMustContainRegularFiles);
1130        }
1131        let path = entry.path().map_err(|_| NpmError::InvalidNpmTarballPath)?;
1132        let stripped = npm_relpath_from_tar(&path, false, &mut root)?;
1133        let mut bytes = Vec::new();
1134        entry
1135            .take(MAX_NPM_FILE_BYTES as u64 + 1)
1136            .read_to_end(&mut bytes)
1137            .map_err(|_| NpmError::InvalidNpmTarballEntry)?;
1138        if bytes.len() > MAX_NPM_FILE_BYTES {
1139            return Err(NpmError::NpmFileTooLarge { path: stripped.to_owned() });
1140        }
1141        expanded = expanded
1142            .checked_add(bytes.len())
1143            .ok_or(NpmError::NpmTarballTooLargeBare)?;
1144        if expanded > MAX_NPM_TARBALL_EXPANDED {
1145            return Err(NpmError::NpmTarballExpandsTooMuch);
1146        }
1147        // Some published archives repeat the same file under a `./` alias.
1148        if let Some(previous) = files.get(&stripped) {
1149            if *previous != bytes {
1150                return Err(NpmError::NpmTarballDuplicate { path: stripped.to_owned() });
1151            }
1152            continue;
1153        }
1154        if files.len() >= MAX_NPM_FILES {
1155            return Err(NpmError::NpmTarballTooManyFiles);
1156        }
1157        files.insert(stripped, bytes);
1158    }
1159    if files.is_empty() {
1160        return Err(NpmError::NpmTarballEmpty);
1161    }
1162    Ok(files)
1163}
1164
1165fn bind_npm_files(
1166    data: &BTreeMap<String, Vec<u8>>,
1167    name: &str,
1168    version: &str,
1169) -> Result<(BTreeMap<String, String>, serde_json::Value), NpmError> {
1170    let package_bytes = data
1171        .get("package.json")
1172        .ok_or(NpmError::NpmTarballMissingPackageJson)?;
1173    let package: serde_json::Value = serde_json::from_slice(package_bytes)
1174        .map_err(|source| NpmError::InvalidNpmPackageJson { source: Arc::new(source) })?;
1175    let published_name = package
1176        .get("name")
1177        .and_then(|value| value.as_str())
1178        .ok_or(NpmError::NpmPackageJsonMissingName)?;
1179    let published_version = package
1180        .get("version")
1181        .and_then(|value| value.as_str())
1182        .ok_or(NpmError::NpmPackageJsonMissingVersion)?;
1183    if published_name != name || published_version != version {
1184        return Err(NpmError::NpmPackageJsonIdentityMismatch);
1185    }
1186    let mut hashes = BTreeMap::new();
1187    for (path, bytes) in data {
1188        hashes.insert(path.clone(), checksum(bytes));
1189    }
1190    Ok((hashes, package))
1191}
1192
1193pub(super) fn check_npm_url(
1194    authority: Option<&crate::authority::SourceAuthority>,
1195    url: &Url,
1196) -> Result<(), NpmError> {
1197    Ok(Access::Module(url.clone()).check(authority)?)
1198}
1199
1200fn check_npm_exec(
1201    authority: Option<&crate::authority::SourceAuthority>,
1202    registry: &Url,
1203    name: &str,
1204    version: &str,
1205    files: &BTreeMap<String, String>,
1206) -> Result<(), NpmError> {
1207    let version_base = npm_version_base(registry, name, version)?;
1208    if Access::Module(version_base).check(authority).is_ok() {
1209        return Ok(());
1210    }
1211    for path in files.keys() {
1212        let url = npm_virtual_module(registry, name, version, path)?;
1213        if Access::Module(url).check(authority).is_ok() {
1214            return Ok(());
1215        }
1216    }
1217    let version_base = npm_version_base(registry, name, version)?;
1218    Ok(Access::Module(version_base).check(authority)?)
1219}
1220
1221/// Network and policy context for one registry request. Owned, because
1222/// spawned workers carry their own copy rather than borrowing the store.
1223#[derive(Clone)]
1224struct Fetcher {
1225    options: Options,
1226    client: Client<HttpsConnector<HttpConnector>, Empty<Bytes>>,
1227    authority: Option<crate::authority::SourceAuthority>,
1228    progress: Option<super::ProgressSink>,
1229    registry: Url,
1230}
1231
1232/// Packument dedupe and download admission shared by every acquisition.
1233#[derive(Clone)]
1234struct Workers {
1235    packuments: Arc<Mutex<BTreeMap<String, SharedPackument>>>,
1236    downloads: Arc<tokio::sync::Semaphore>,
1237    runtime: tokio::runtime::Handle,
1238}
1239
1240impl Fetcher {
1241    fn archive<'a>(
1242        &'a self,
1243        name: &'a str,
1244        version: &'a str,
1245        tarball: &'a str,
1246        integrity: &'a str,
1247    ) -> NpmArchive<'a> {
1248        NpmArchive {
1249            registry: &self.registry,
1250            name,
1251            version,
1252            tarball,
1253            integrity,
1254        }
1255    }
1256
1257    async fn fetch_bytes(
1258        &self,
1259        mut url: Url,
1260        accept: &'static str,
1261        max_bytes: usize,
1262        what: &str,
1263        access: Access,
1264    ) -> Result<Vec<u8>, NpmError> {
1265        let authority = &self.authority;
1266        let registry = &self.registry;
1267        let client = &self.client;
1268        let download = async {
1269            access.check(authority.as_ref())?;
1270            check_https(&url)?;
1271            if !same_origin(&url, registry) {
1272                return Err(NpmError::NpmOriginOutsideRegistry { url: url.to_string() });
1273            }
1274            for _ in 0..=10 {
1275                access.check(authority.as_ref())?;
1276                let mut request_url = url.clone();
1277                request_url.set_fragment(None);
1278                let request = Request::get(request_url.as_str())
1279                    .header("Accept", accept)
1280                    .header("User-Agent", concat!("avril/", env!("CARGO_PKG_VERSION")))
1281                    .body(Empty::new())
1282                    .map_err(|detail| NpmError::NpmHttp { detail: detail.to_string() })?;
1283                let response = client
1284                    .request(request)
1285                    .await
1286                    .map_err(|detail| NpmError::NpmHttp { detail: detail.to_string() })?;
1287                access.check(authority.as_ref())?;
1288                if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
1289                    let location = response
1290                        .headers()
1291                        .get("location")
1292                        .and_then(|value| value.to_str().ok())
1293                        .ok_or_else(|| NpmError::NpmRedirectNoLocation { url: url.to_string() })?;
1294                    let next = url.join(location)?;
1295                    if !next.username().is_empty() || next.password().is_some() {
1296                        return Err(NpmError::NpmRedirectCredentials { next: next.to_string() });
1297                    }
1298                    check_https(&next)?;
1299                    if !same_origin(&next, registry) {
1300                        return Err(NpmError::NpmRedirectEscapesRegistry {
1301                            next: next.to_string(),
1302                        });
1303                    }
1304                    url = next;
1305                    continue;
1306                }
1307                if !response.status().is_success() {
1308                    return Err(NpmError::NpmServerStatus {
1309                        status: response.status().as_u16(),
1310                        url: url.to_string(),
1311                    });
1312                }
1313                if response
1314                    .headers()
1315                    .get("content-type")
1316                    .and_then(|value| value.to_str().ok())
1317                    .is_some_and(|value| value.starts_with("text/html"))
1318                {
1319                    return Err(NpmError::NpmServerHtml {
1320                        what: what.to_owned(),
1321                        url: url.to_string(),
1322                    });
1323                }
1324                let mut body = response.into_body();
1325                let mut bytes = Vec::new();
1326                while let Some(frame) = body.frame().await {
1327                    if let Ok(data) = frame
1328                        .map_err(|detail| NpmError::NpmHttp { detail: detail.to_string() })?
1329                        .into_data()
1330                    {
1331                        if bytes.len() + data.len() > max_bytes {
1332                            return Err(NpmError::NpmResponseTooLarge {
1333                                what: what.to_owned(),
1334                                url: url.to_string(),
1335                            });
1336                        }
1337                        bytes.extend_from_slice(&data);
1338                    }
1339                }
1340                return Ok(bytes);
1341            }
1342            Err(NpmError::NpmTooManyRedirects { url: url.to_string() })
1343        };
1344        tokio::time::timeout(NPM_FETCH_TIMEOUT, download)
1345            .await
1346            .map_err(|_| NpmError::NpmRequestTimeout { url: url.to_string() })?
1347    }
1348
1349    async fn npm_acquire_task(
1350        &self,
1351        workers: &Workers,
1352        lock_snapshot: super::Lockfile,
1353        instance: String,
1354        request: NpmRequest,
1355        verification: NpmVerification,
1356    ) -> Result<NpmArtifact, NpmError> {
1357        let options = &self.options;
1358        let authority = &self.authority;
1359        let npm_registry = &self.registry;
1360        let request_key = request.request_key();
1361        let name = request.name;
1362        let version_req = request.version_req;
1363        let package_base = npm_package_base(npm_registry, &name)?;
1364        Access::Package(package_base.clone()).check(authority.as_ref())?;
1365        // One permit per executing worker; waiter clones only await the shared
1366        // result. Metadata fetching takes no permit, so holding one across the
1367        // shared packument wait cannot deadlock. Recheck caps after admission
1368        // for revocation while queued; later validation rechecks versions.
1369        let _download_permit = workers
1370            .downloads
1371            .clone()
1372            .acquire_owned()
1373            .await
1374            .map_err(|_| NpmError::NpmDownloadWorkerStopped)?;
1375        Access::Package(package_base.clone()).check(authority.as_ref())?;
1376        if let Some(version_text) = lock_snapshot.npm.specifiers.get(&request_key) {
1377            let version = validate_npm_version(version_text)?;
1378            if !locked_satisfies(version_req.clone(), &version) {
1379                return Err(NpmError::NpmLockedVersionUnsatisfied {
1380                    version: version_text.clone(),
1381                    specifier: request_key.clone(),
1382                });
1383            }
1384            let identity_key = format!("{}|{name}|{version_text}", npm_registry.as_str());
1385            if let Some(pin) = lock_snapshot.npm.packages.get(&identity_key) {
1386                return self
1387                    .pinned_npm(&instance, &name, version_text, pin, verification)
1388                    .await;
1389            }
1390        } else if !options.update
1391            && !options.frozen
1392            && let Some((version_text, pin)) =
1393                find_pinned_satisfying(&lock_snapshot, npm_registry, &name, &version_req)
1394        {
1395            return self
1396                .pinned_npm(&instance, &name, &version_text, &pin, verification)
1397                .await;
1398        }
1399        if options.frozen {
1400            return Err(NpmError::FrozenLockfileMissing { name: request_key.clone() });
1401        }
1402        let packument = self.shared_packument(&name, workers).await?;
1403        let (version_text, dist) = select_npm_version(&packument, &version_req)?;
1404        validate_npm_version(&version_text)?;
1405        Access::Package(npm_version_base(npm_registry, &name, &version_text)?)
1406            .check(authority.as_ref())?;
1407        let tarball_url = npm_tarball_url(&dist.tarball)?;
1408        if !same_origin(&tarball_url, npm_registry) {
1409            return Err(NpmError::NpmTarballEscapesRegistry { tarball: dist.tarball.clone() });
1410        }
1411        check_https(&tarball_url)?;
1412        Access::Package(package_base.clone()).check(authority.as_ref())?;
1413        let integrity = pin_integrity(&dist)?;
1414        if options.offline {
1415            let cached =
1416                storage::read_tarball_cache(options, &dist.tarball, MAX_NPM_TARBALL_COMPRESSED)?
1417                    .ok_or_else(|| NpmError::NpmOfflineCacheMiss { name: request_key.clone() })?;
1418            verify_sri(&cached, &integrity)?;
1419            return self
1420                .spawn_materialize(
1421                    &instance,
1422                    self.archive(&name, &version_text, &dist.tarball, &integrity),
1423                    cached,
1424                    None,
1425                )
1426                .await;
1427        }
1428        let bytes = self
1429            .fetch_npm_tarball(&name, &version_text, &dist.tarball, &integrity)
1430            .await?;
1431        self.spawn_materialize(
1432            &instance,
1433            self.archive(&name, &version_text, &dist.tarball, &integrity),
1434            bytes,
1435            None,
1436        )
1437        .await
1438    }
1439
1440    /// Run the blocking stage-publish-verify sequence off the async workers.
1441    fn spawn_materialize(
1442        &self,
1443        instance: &str,
1444        archive: NpmArchive<'_>,
1445        bytes: Vec<u8>,
1446        expected: Option<NpmPin>,
1447    ) -> impl std::future::Future<Output = Result<NpmArtifact, NpmError>> {
1448        let options = self.options.clone();
1449        let authority = self.authority.clone();
1450        let progress = self.progress.clone();
1451        let instance = instance.to_owned();
1452        let registry = archive.registry.clone();
1453        let name = archive.name.to_owned();
1454        let version = archive.version.to_owned();
1455        let tarball = archive.tarball.to_owned();
1456        let integrity = archive.integrity.to_owned();
1457        async move {
1458            let access = Access::Package(npm_package_base(&registry, &name)?);
1459            access.check(authority.as_ref())?;
1460            if let Some(sink) = &progress {
1461                sink(&super::ProgressEvent::NpmMaterializing {
1462                    name: name.clone(),
1463                    version: version.clone(),
1464                });
1465                access.check(authority.as_ref())?;
1466            }
1467            let artifact = tokio::task::spawn_blocking(move || {
1468                storage::materialize_npm(
1469                    &options,
1470                    &authority,
1471                    NpmArchive {
1472                        registry: &registry,
1473                        name: &name,
1474                        version: &version,
1475                        tarball: &tarball,
1476                        integrity: &integrity,
1477                    },
1478                    &instance,
1479                    bytes,
1480                    expected.as_ref(),
1481                )
1482            })
1483            .await
1484            .map_err(|detail| NpmError::NpmHttp { detail: detail.to_string() })??;
1485            if let Some(sink) = &progress {
1486                sink(&super::ProgressEvent::NpmMaterialized {
1487                    name: artifact.identity.name.clone(),
1488                    version: artifact.identity.version.clone(),
1489                });
1490            }
1491            Ok(artifact)
1492        }
1493    }
1494
1495    /// Authenticated file map behind a pin: legacy inline maps directly,
1496    /// compact pins through the project sidecar, the vendor mirror sidecar,
1497    /// or an SRI-verified tarball. Recovery republishes the sidecar; a warm
1498    /// tree alone never authenticates.
1499    async fn resolve_pin_files(
1500        &self,
1501        name: &str,
1502        version: &str,
1503        pin: &NpmPin,
1504    ) -> Result<BTreeMap<String, String>, NpmError> {
1505        let options = &self.options;
1506        if let Some(files) = &pin.files {
1507            return Ok(files.clone());
1508        }
1509        let manifest = pin
1510            .manifest
1511            .as_ref()
1512            .ok_or(NpmError::NpmPinMissingManifest)?;
1513        if let Some(map) = storage::load_manifest_map(options, manifest)? {
1514            return Ok(map);
1515        }
1516        if let Some(map) = storage::load_vendor_map(options, manifest)? {
1517            storage::ensure_manifest_sidecar(options, &map)?;
1518            return Ok(map);
1519        }
1520        let bytes =
1521            match storage::read_tarball_cache(options, &pin.tarball, MAX_NPM_TARBALL_COMPRESSED)? {
1522                Some(bytes) => {
1523                    verify_sri(&bytes, &pin.integrity)?;
1524                    bytes
1525                }
1526                None if options.offline => {
1527                    return Err(NpmError::NpmOfflineCacheMiss {
1528                        name: format!("{name}@{version}"),
1529                    });
1530                }
1531                None => {
1532                    self.fetch_npm_tarball(name, version, &pin.tarball, &pin.integrity)
1533                        .await?
1534                }
1535            };
1536        let data = collect_npm_files(&bytes)?;
1537        let (derived, _) = bind_npm_files(&data, name, version)?;
1538        if storage::manifest_digest(&derived)? != *manifest {
1539            return Err(NpmError::NpmArchiveChangedFor {
1540                name: name.to_owned(),
1541                version: version.to_owned(),
1542            });
1543        }
1544        storage::ensure_manifest_sidecar(options, &derived)?;
1545        Ok(derived)
1546    }
1547
1548    async fn pinned_npm(
1549        &self,
1550        instance: &str,
1551        name: &str,
1552        version: &str,
1553        pin: &NpmPin,
1554        verification: NpmVerification,
1555    ) -> Result<NpmArtifact, NpmError> {
1556        let options = &self.options;
1557        let authority = &self.authority;
1558        let npm_registry = &self.registry;
1559        validate_npm_version(version)?;
1560        validate_npm_pin(pin)?;
1561        let package_base = npm_package_base(npm_registry, name)?;
1562        let tarball_url = npm_tarball_url(&pin.tarball)?;
1563        if !same_origin(&tarball_url, npm_registry) {
1564            return Err(NpmError::NpmTarballEscapesRegistry { tarball: pin.tarball.clone() });
1565        }
1566        check_https(&tarball_url)?;
1567        Access::Package(package_base).check(authority.as_ref())?;
1568        let files = self.resolve_pin_files(name, version, pin).await?;
1569        check_npm_exec(authority.as_ref(), npm_registry, name, version, &files)?;
1570        let dir = storage::npm_package_dir(options, instance, name, version);
1571        let package = match verification {
1572            NpmVerification::Full => {
1573                match storage::npm_dir_state(&options.project_root, &dir, &files)? {
1574                    storage::NpmDirState::Verified(package) => Some(package),
1575                    storage::NpmDirState::Absent => None,
1576                }
1577            }
1578            NpmVerification::OnUse => {
1579                storage::verify_npm_metadata(&options.project_root, &dir, &files)?
1580            }
1581        };
1582        if let Some(package) = package {
1583            if pin.is_legacy() && !options.frozen {
1584                storage::ensure_manifest_sidecar(options, &files)?;
1585            }
1586            return Ok(NpmArtifact {
1587                identity: NpmIdentity {
1588                    registry: npm_registry.clone(),
1589                    name: name.to_owned(),
1590                    version: version.to_owned(),
1591                },
1592                tarball: pin.tarball.clone(),
1593                integrity: pin.integrity.clone(),
1594                dir,
1595                files,
1596                package,
1597            });
1598        }
1599        if let Some(seeded) = storage::seed_npm_from_vendor(
1600            options,
1601            npm_registry,
1602            instance,
1603            name,
1604            version,
1605            pin,
1606            &files,
1607        )? {
1608            return Ok(seeded);
1609        }
1610        let cached =
1611            storage::read_tarball_cache(options, &pin.tarball, MAX_NPM_TARBALL_COMPRESSED)?;
1612        if let Some(bytes) = cached {
1613            verify_sri(&bytes, &pin.integrity)?;
1614            return self
1615                .spawn_materialize(
1616                    instance,
1617                    self.archive(name, version, &pin.tarball, &pin.integrity),
1618                    bytes,
1619                    Some(pin.clone()),
1620                )
1621                .await;
1622        }
1623        if options.offline {
1624            return Err(NpmError::NpmOfflineCacheMiss {
1625                name: format!("{name}@{version}"),
1626            });
1627        }
1628        let bytes = self
1629            .fetch_npm_tarball(name, version, &pin.tarball, &pin.integrity)
1630            .await?;
1631        self.spawn_materialize(
1632            instance,
1633            self.archive(name, version, &pin.tarball, &pin.integrity),
1634            bytes,
1635            Some(pin.clone()),
1636        )
1637        .await
1638    }
1639
1640    /// One packument per package shared by selection and acquisition. Completed
1641    /// successes stay for this store; malformed fetches evict so retries refetch.
1642    async fn shared_packument(
1643        &self,
1644        name: &str,
1645        workers: &Workers,
1646    ) -> Result<Arc<NpmPackument>, NpmError> {
1647        use futures::FutureExt;
1648        let name = name.to_owned();
1649        let future = {
1650            let mut guard = workers.packuments.lock().unwrap();
1651            if let Some(current) = guard.get(&name) {
1652                current.clone()
1653            } else {
1654                let fetcher = self.clone();
1655                let task_name = name.clone();
1656                let (send, receive) = futures::channel::oneshot::channel();
1657                workers.runtime.spawn(async move {
1658                    let result = async {
1659                        let bytes = fetcher.fetch_npm_packument(&task_name).await?;
1660                        let packument: NpmPackument =
1661                            serde_json::from_slice(&bytes).map_err(|error| {
1662                                NpmError::InvalidNpmPackument {
1663                                    name: task_name.clone(),
1664                                    source: Arc::new(error),
1665                                }
1666                            })?;
1667                        if packument.name != task_name {
1668                            return Err(NpmError::NpmPackumentNameMismatch {
1669                                name: task_name.clone(),
1670                            });
1671                        }
1672                        Ok(Arc::new(packument))
1673                    }
1674                    .await;
1675                    let _ = send.send(result);
1676                });
1677                let shared: SharedPackument = async move {
1678                    receive
1679                        .await
1680                        .map_err(|_| NpmError::NpmMetadataWorkerStopped)?
1681                }
1682                .boxed()
1683                .shared();
1684                guard.insert(name.clone(), shared.clone());
1685                shared
1686            }
1687        };
1688        let result = future.clone().await;
1689        if result.is_err() {
1690            let mut guard = workers.packuments.lock().unwrap();
1691            if guard
1692                .get(&name)
1693                .is_some_and(|current| current.ptr_eq(&future))
1694            {
1695                guard.remove(&name);
1696            }
1697        }
1698        result
1699    }
1700
1701    async fn fetch_npm_packument(&self, name: &str) -> Result<Vec<u8>, NpmError> {
1702        let options = &self.options;
1703        let npm_registry = &self.registry;
1704        let base = npm_package_base(npm_registry, name)?;
1705        Access::Package(base.clone()).check(self.authority.as_ref())?;
1706        if options.offline {
1707            return storage::read_packument_cache(options, name, MAX_NPM_PACKUMENT_BYTES)?
1708                .ok_or(NpmError::NpmOfflineMetadataMiss { name: name.to_owned() });
1709        }
1710        if let Some(sink) = &self.progress {
1711            sink(&super::ProgressEvent::NpmMetadata { name: name.to_owned() });
1712        }
1713        let url = packument_url(npm_registry, name)?;
1714        let bytes = self
1715            .fetch_bytes(
1716                url,
1717                "application/vnd.npm.install-v1+json",
1718                MAX_NPM_PACKUMENT_BYTES,
1719                "metadata",
1720                Access::Package(base),
1721            )
1722            .await?;
1723        serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|source| {
1724            NpmError::InvalidNpmPackument {
1725                name: name.to_owned(),
1726                source: Arc::new(source),
1727            }
1728        })?;
1729        storage::write_packument_cache(options, name, &bytes)?;
1730        Ok(bytes)
1731    }
1732
1733    async fn fetch_npm_tarball(
1734        &self,
1735        name: &str,
1736        version: &str,
1737        tarball: &str,
1738        expected: &str,
1739    ) -> Result<Vec<u8>, NpmError> {
1740        let options = &self.options;
1741        let npm_registry = &self.registry;
1742        let url = npm_tarball_url(tarball)?;
1743        if !same_origin(&url, npm_registry) {
1744            return Err(NpmError::NpmTarballEscapesRegistry { tarball: tarball.to_owned() });
1745        }
1746        check_https(&url)?;
1747        let access = Access::Package(npm_package_base(npm_registry, name)?);
1748        access.check(self.authority.as_ref())?;
1749        if let Some(bytes) =
1750            storage::read_tarball_cache(options, tarball, MAX_NPM_TARBALL_COMPRESSED)?
1751        {
1752            verify_sri(&bytes, expected)?;
1753            return Ok(bytes);
1754        }
1755        if let Some(sink) = &self.progress {
1756            sink(&super::ProgressEvent::NpmArchive {
1757                name: name.to_owned(),
1758                version: version.to_owned(),
1759            });
1760        }
1761        let bytes = self
1762            .fetch_bytes(
1763                url,
1764                "application/octet-stream",
1765                MAX_NPM_TARBALL_COMPRESSED,
1766                "tarball",
1767                access,
1768            )
1769            .await?;
1770        verify_sri(&bytes, expected)?;
1771        storage::write_tarball_cache(options, tarball, &bytes)?;
1772        Ok(bytes)
1773    }
1774}
1775
1776impl PackageStore {
1777    fn fetcher(&self) -> Fetcher {
1778        Fetcher {
1779            options: self.options.clone(),
1780            client: self.client.clone(),
1781            authority: self.authority.clone(),
1782            progress: self.progress.clone(),
1783            registry: self.options.npm_registry.clone(),
1784        }
1785    }
1786
1787    fn workers(&self) -> Workers {
1788        Workers {
1789            packuments: self.npm_packuments.clone(),
1790            downloads: self.npm_downloads.clone(),
1791            runtime: self.runtime.clone(),
1792        }
1793    }
1794
1795    /// Select and lock automatic DefinitelyTyped discovery for one resolved package.
1796    pub async fn npm_types_request(
1797        &self,
1798        identity: &NpmIdentity,
1799        discover: bool,
1800    ) -> Result<Option<String>, NpmError> {
1801        let key = identity.key();
1802        if identity.registry != self.options.npm_registry {
1803            return Err(NpmError::InvalidNpmTypesAssociation { identity: key });
1804        }
1805        if identity.name.starts_with("@types/") {
1806            return Ok(None);
1807        }
1808        let replay = || {
1809            let lock = self.lock.lock().unwrap();
1810            let mut used = self.used.lock().unwrap();
1811            if (!self.options.update || used.npm_types_discovered.contains(&key))
1812                && let Some(record) = lock.npm.types.get(&key)
1813            {
1814                used.npm_types.insert(key.clone());
1815                Some(record.clone())
1816            } else {
1817                None
1818            }
1819        };
1820        if let Some(record) = replay() {
1821            return Ok(record);
1822        }
1823        if !discover || self.options.frozen {
1824            return Err(NpmError::NpmTypesNotPrepared { identity: key });
1825        }
1826        let _gate = self.npm_types_gate.lock().await;
1827        if let Some(record) = replay() {
1828            return Ok(record);
1829        }
1830
1831        let request = package::types_package_name(&identity.name);
1832        let library_version = validate_npm_version(&identity.version)?;
1833        let major = library_version.major;
1834        let next_major = major
1835            .checked_add(1)
1836            .ok_or_else(|| NpmError::InvalidNpmVersion {
1837                version: identity.version.clone(),
1838            })?;
1839        let range = format!(">={major}.0.0 <{next_major}.0.0");
1840        let type_request = format!("npm:{request}@{range}");
1841        let result = match self.npm_select_version(&type_request).await {
1842            Ok(_) => Some(parse_npm_request(&type_request)?.request_key()),
1843            Err(NpmError::NpmServerStatus { status: 404, .. })
1844            | Err(NpmError::NoNpmVersionSatisfies { .. }) => None,
1845            Err(error) => return Err(error),
1846        };
1847
1848        let mut lock = self.lock.lock().unwrap();
1849        let mut used = self.used.lock().unwrap();
1850        used.npm_types.insert(key.clone());
1851        used.npm_types_discovered.insert(key.clone());
1852        if lock.npm.types.get(&key) != Some(&result) || self.options.update {
1853            used.pending_npm_types.insert(key.clone());
1854        }
1855        lock.npm.types.insert(key, result.clone());
1856        Ok(result)
1857    }
1858
1859    /// Metadata-only version selection: lock reuse or one shared packument
1860    /// fetch, never archives, graphs, pins, or usage marks.
1861    pub async fn npm_select_version(&self, specifier: &str) -> Result<String, NpmError> {
1862        let request = parse_npm_request(specifier)?;
1863        let package_base = npm_package_base(&self.options.npm_registry, &request.name)?;
1864        Access::Package(package_base.clone()).check(self.authority.as_ref())?;
1865        let lock_snapshot = self.lock.lock().unwrap().clone();
1866        let version_text =
1867            if let Some(locked) = lock_snapshot.npm.specifiers.get(&request.request_key()) {
1868                let version = validate_npm_version(locked)?;
1869                if !locked_satisfies(request.version_req.clone(), &version) {
1870                    return Err(NpmError::NpmLockedVersionUnsatisfied {
1871                        version: locked.clone(),
1872                        specifier: request.request_key(),
1873                    });
1874                }
1875                locked.clone()
1876            } else if !self.options.update
1877                && !self.options.frozen
1878                && let Some((pinned, _)) = find_pinned_satisfying(
1879                    &lock_snapshot,
1880                    &self.options.npm_registry,
1881                    &request.name,
1882                    &request.version_req,
1883                )
1884            {
1885                pinned
1886            } else {
1887                if self.options.frozen {
1888                    return Err(NpmError::FrozenLockfileMissing { name: request.request_key() });
1889                }
1890                let packument = self
1891                    .fetcher()
1892                    .shared_packument(&request.name, &self.workers())
1893                    .await?;
1894                select_npm_version(&packument, &request.version_req)?.0
1895            };
1896        validate_npm_version(&version_text)?;
1897        Access::Package(npm_version_base(
1898            &self.options.npm_registry,
1899            &request.name,
1900            &version_text,
1901        )?)
1902        .check(self.authority.as_ref())?;
1903        Access::Package(package_base).check(self.authority.as_ref())?;
1904        Ok(version_text)
1905    }
1906
1907    pub async fn npm_acquire(&self, specifier: &str) -> Result<NpmArtifact, NpmError> {
1908        self.npm_acquire_in(NPM_DEFAULT_INSTANCE, specifier).await
1909    }
1910
1911    pub async fn npm_acquire_in(
1912        &self,
1913        instance: &str,
1914        specifier: &str,
1915    ) -> Result<NpmArtifact, NpmError> {
1916        self.npm_acquire_mode(instance, specifier, NpmVerification::Full)
1917            .await
1918    }
1919
1920    pub(crate) async fn npm_acquire_for_resolution(
1921        &self,
1922        specifier: &str,
1923    ) -> Result<NpmArtifact, NpmError> {
1924        self.npm_acquire_mode(NPM_DEFAULT_INSTANCE, specifier, NpmVerification::OnUse)
1925            .await
1926    }
1927
1928    /// Materialize an already selected package without introducing a new request
1929    /// selection. In particular, frozen replay need not pin an exact-version alias.
1930    pub(crate) async fn npm_acquire_locked(
1931        &self,
1932        identity: &NpmIdentity,
1933        pin: &NpmPin,
1934    ) -> Result<NpmArtifact, NpmError> {
1935        if identity.registry != self.options.npm_registry {
1936            return Err(NpmError::NpmOriginOutsideRegistry {
1937                url: identity.registry.to_string(),
1938            });
1939        }
1940        let fetcher = self.fetcher();
1941        let downloads = self.npm_downloads.clone();
1942        let identity = identity.clone();
1943        let pin = pin.clone();
1944        let artifact = self
1945            .runtime
1946            .spawn(async move {
1947                let _permit = downloads
1948                    .acquire_owned()
1949                    .await
1950                    .map_err(|_| NpmError::NpmDownloadWorkerStopped)?;
1951                fetcher
1952                    .pinned_npm(
1953                        NPM_DEFAULT_INSTANCE,
1954                        &identity.name,
1955                        &identity.version,
1956                        &pin,
1957                        NpmVerification::OnUse,
1958                    )
1959                    .await
1960            })
1961            .await
1962            .map_err(|_| NpmError::NpmAcquisitionWorkerStopped)??;
1963        check_npm_exec(
1964            self.authority.as_ref(),
1965            &artifact.identity.registry,
1966            &artifact.identity.name,
1967            &artifact.identity.version,
1968            &artifact.files,
1969        )?;
1970        self.finish_npm_acquire(None, &artifact, NpmVerification::OnUse)
1971    }
1972
1973    async fn npm_acquire_mode(
1974        &self,
1975        instance: &str,
1976        specifier: &str,
1977        verification: NpmVerification,
1978    ) -> Result<NpmArtifact, NpmError> {
1979        use futures::FutureExt;
1980        validate_instance(instance)?;
1981        let request = parse_npm_request(specifier)?;
1982        if request.subpath.is_some() {
1983            return Err(NpmError::NpmSubpathNeedsAcquire);
1984        }
1985        let request_key = request.request_key();
1986        let package_base = npm_package_base(&self.options.npm_registry, &request.name)?;
1987        Access::Package(package_base.clone()).check(self.authority.as_ref())?;
1988        let key = format!("{instance}\0{request_key}\0{verification:?}");
1989        let future: NpmAcquisition = {
1990            let mut inflight = self.npm_inflight.lock().unwrap();
1991            inflight
1992                .entry(key.clone())
1993                .or_insert_with(|| {
1994                    let fetcher = self.fetcher();
1995                    let workers = self.workers();
1996                    let lock_snapshot = self.lock.lock().unwrap().clone();
1997                    let instance = instance.to_owned();
1998                    let request = request.clone();
1999                    let (send, receive) = futures::channel::oneshot::channel();
2000                    self.runtime.spawn(async move {
2001                        let result = fetcher
2002                            .npm_acquire_task(
2003                                &workers,
2004                                lock_snapshot,
2005                                instance,
2006                                request,
2007                                verification,
2008                            )
2009                            .await
2010                            .map(Arc::new);
2011                        let _ = send.send(result);
2012                    });
2013                    async move {
2014                        receive
2015                            .await
2016                            .map_err(|_| NpmError::NpmAcquisitionWorkerStopped)?
2017                    }
2018                    .boxed()
2019                    .shared()
2020                })
2021                .clone()
2022        };
2023        let result = future.clone().await;
2024        {
2025            let mut inflight = self.npm_inflight.lock().unwrap();
2026            if inflight
2027                .get(&key)
2028                .is_some_and(|current| current.ptr_eq(&future))
2029            {
2030                inflight.remove(&key);
2031            }
2032        }
2033        let artifact = result?;
2034        Access::Package(package_base).check(self.authority.as_ref())?;
2035        check_npm_exec(
2036            self.authority.as_ref(),
2037            &artifact.identity.registry,
2038            &artifact.identity.name,
2039            &artifact.identity.version,
2040            &artifact.files,
2041        )?;
2042        self.finish_npm_acquire(Some(&request_key), &artifact, verification)
2043    }
2044
2045    fn finish_npm_acquire(
2046        &self,
2047        request_key: Option<&str>,
2048        artifact: &NpmArtifact,
2049        verification: NpmVerification,
2050    ) -> Result<NpmArtifact, NpmError> {
2051        if verification == NpmVerification::Full {
2052            storage::verify_and_load_npm_dir(
2053                &self.options.project_root,
2054                &artifact.dir,
2055                &artifact.files,
2056            )?;
2057        } else {
2058            storage::verify_npm_metadata(
2059                &self.options.project_root,
2060                &artifact.dir,
2061                &artifact.files,
2062            )?
2063            .ok_or(storage::StorageError::NpmManifestMissing)?;
2064        }
2065        let mut lock = self.lock.lock().unwrap();
2066        let mut used = self.used.lock().unwrap();
2067        let identity_key = artifact.identity.key();
2068        if let Some(request_key) = request_key
2069            && let Some(existing) = lock.npm.specifiers.get(request_key)
2070            && existing != &artifact.identity.version
2071        {
2072            return Err(NpmError::NpmConflictingVersionSelections {
2073                request_key: request_key.to_owned(),
2074            });
2075        }
2076        let compact = NpmPin {
2077            tarball: artifact.tarball.clone(),
2078            integrity: artifact.integrity.clone(),
2079            manifest: Some(storage::manifest_digest(&artifact.files)?),
2080            files: None,
2081        };
2082        if let Some(existing) = lock.npm.packages.get(&identity_key)
2083            && !existing.same_content(&compact)
2084        {
2085            return Err(NpmError::LockedNpmPackageChanged { key: identity_key.clone() });
2086        }
2087        // A warm frozen legacy hit needs no sidecar; keep its inline map.
2088        let pin = if self.options.frozen {
2089            lock.npm
2090                .packages
2091                .get(&identity_key)
2092                .cloned()
2093                .unwrap_or(compact)
2094        } else {
2095            compact
2096        };
2097        used.npm_packages.insert(identity_key.clone());
2098        if let Some(request_key) = request_key {
2099            used.npm_specifiers.insert(request_key.to_owned());
2100            if lock.npm.specifiers.get(request_key) != Some(&artifact.identity.version)
2101                || self.options.update
2102            {
2103                used.pending_npm_specifiers.insert(request_key.to_owned());
2104            }
2105            lock.npm
2106                .specifiers
2107                .insert(request_key.to_owned(), artifact.identity.version.clone());
2108        }
2109        if lock.npm.packages.get(&identity_key) != Some(&pin) {
2110            used.pending_npm_packages.insert(identity_key.clone());
2111        }
2112        lock.npm.packages.insert(identity_key, pin);
2113        Ok((*artifact).clone())
2114    }
2115}