Skip to main content

yapi_core/packages/
npm.rs

1//! A built-in npm client, so packages install without Node: it resolves
2//! versions from the registry, verifies and unpacks tarballs into
3//! `node_modules` and installs dependencies, hoisting them where Node's
4//! resolution allows. It runs no lifecycle scripts, so native addons stay
5//! unbuilt, and an extension that loads one fails then. Installs return the
6//! tree they unpacked as pins, which [`replay`] reproduces without the
7//! registry.
8
9use std::collections::{HashMap, HashSet};
10use std::path::{Component, Path, PathBuf};
11
12use aws_lc_rs::digest::{SHA1_FOR_LEGACY_USE_ONLY as SHA1, SHA512, digest};
13use base64::Engine as _;
14use base64::engine::general_purpose::STANDARD;
15use futures_util::StreamExt as _;
16use futures_util::future::BoxFuture;
17use serde_json::{Map, Value, json};
18use tokio_util::sync::CancellationToken;
19use yapi_ai::auth::lock::{FileLock, LockError};
20
21/// Packages yapi provides to extensions itself, so installing them is wasted work.
22const HOST_PACKAGES: [&str; 10] = [
23    "@earendil-works/pi-coding-agent",
24    "@earendil-works/pi-ai",
25    "@earendil-works/pi-agent-core",
26    "@earendil-works/pi-tui",
27    "@mariozechner/pi-coding-agent",
28    "@mariozechner/pi-ai",
29    "@mariozechner/pi-agent-core",
30    "@mariozechner/pi-tui",
31    "typebox",
32    "@sinclair/typebox",
33];
34
35/// Why an npm operation failed. Its message escapes unprintable
36/// characters, which names and URLs from a lock or a registry may hold.
37#[derive(Debug, thiserror::Error)]
38pub enum NpmError {
39    /// The registry could not be reached or answered with an error.
40    #[error("{}", escaped(.0))]
41    Registry(String),
42    /// No published version matches.
43    #[error("No version of {} matches {}", escaped(.name), escaped(.range))]
44    NoMatch {
45        /// The package.
46        name: String,
47        /// The requested range.
48        range: String,
49    },
50    /// The tarball's digest differs from the registry's.
51    #[error("Integrity check failed for {}", escaped(.0))]
52    Integrity(String),
53    /// A package name or folder that would put files outside `node_modules`.
54    #[error("{}", escaped(.0))]
55    Refused(String),
56    /// A file operation failed.
57    #[error("{path}: {source}")]
58    Io {
59        /// Where.
60        path: PathBuf,
61        /// Why.
62        source: std::io::Error,
63    },
64}
65
66fn io(path: &Path) -> impl FnOnce(std::io::Error) -> NpmError + '_ {
67    move |source| NpmError::Io {
68        path: path.to_path_buf(),
69        source,
70    }
71}
72
73/// `text` with its unprintable characters escaped, as `escape_debug` writes
74/// them, so that it cannot drive the terminal an error is printed to.
75pub(crate) fn escaped(text: &str) -> String {
76    let mut escaped = String::with_capacity(text.len());
77    for c in text.chars() {
78        match c {
79            // Printable, though `escape_debug` escapes them.
80            '\\' | '\'' | '"' => escaped.push(c),
81            _ => escaped.extend(c.escape_debug()),
82        }
83    }
84    escaped
85}
86
87/// Fails unless `name` is an npm package name, `name` or `@scope/name`,
88/// which keeps it inside the `node_modules` it is joined to. As npm, it
89/// accepts only URL-safe characters and no leading `_`. No part may start
90/// with `.`, so that none is `.git` or another hidden folder. The name is
91/// ASCII, so folding its case needs no Unicode rules.
92fn check_name(name: &str) -> Result<(), NpmError> {
93    let segment = |part: &str| {
94        !part.is_empty()
95            && !part.starts_with('.')
96            && part
97                .bytes()
98                .all(|byte| byte.is_ascii_alphanumeric() || b"-._~!*'()".contains(&byte))
99    };
100    let valid = match name
101        .strip_prefix('@')
102        .and_then(|scoped| scoped.split_once('/'))
103    {
104        Some((scope, name)) => segment(scope) && segment(name),
105        None => segment(name) && !name.starts_with('_'),
106    };
107    if valid {
108        Ok(())
109    } else {
110        Err(NpmError::Refused(format!("Invalid package name: {name}")))
111    }
112}
113
114/// One package of an installed npm tree, as a lock pins it.
115#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116pub struct Pinned {
117    /// The package's folder below the install root: `node_modules/<name>`,
118    /// repeated for each level of nesting, with `/` separators.
119    pub path: String,
120    /// The name in the package's own manifest, which for an alias differs
121    /// from its folder's.
122    pub name: String,
123    /// The installed version.
124    pub version: String,
125    /// The URL of its tarball.
126    pub tarball: String,
127    /// The `sha512-<base64>` digest of its tarball, as Subresource Integrity
128    /// writes it.
129    pub integrity: String,
130}
131
132impl Pinned {
133    /// The pin at `path` of the package `manifest` describes, whose tarball
134    /// is `bytes`. The digest is always sha512, whatever the registry sent.
135    fn new(path: String, manifest: &Value, bytes: &[u8]) -> Pinned {
136        Pinned {
137            path,
138            name: text(&manifest["name"]),
139            version: text(&manifest["version"]),
140            tarball: text(&manifest["dist"]["tarball"]),
141            integrity: format!("sha512-{}", STANDARD.encode(digest(&SHA512, bytes))),
142        }
143    }
144}
145
146/// The package names along a pin's `path`, outermost first, which must be
147/// `node_modules/<name>` repeated, each `<name>` a valid package name.
148pub(crate) fn pin_names(path: &str) -> Result<Vec<String>, NpmError> {
149    let refused = || NpmError::Refused(format!("Invalid package path: {path}"));
150    let mut names = Vec::new();
151    let mut parts = path.split('/');
152    while let Some(part) = parts.next() {
153        let name = match (part, parts.next()) {
154            ("node_modules", Some(scope)) if scope.starts_with('@') => {
155                format!("{scope}/{}", parts.next().ok_or_else(refused)?)
156            }
157            ("node_modules", Some(name)) => name.to_owned(),
158            _ => return Err(refused()),
159        };
160        check_name(&name).map_err(|_| refused())?;
161        names.push(name);
162    }
163    Ok(names)
164}
165
166/// The 64-byte digest of an integrity that is exactly one `sha512-` value.
167fn sha512_digest(integrity: &str) -> Option<Vec<u8>> {
168    let digest = STANDARD.decode(integrity.strip_prefix("sha512-")?).ok()?;
169    (digest.len() == 64).then_some(digest)
170}
171
172/// Removes the file, link or folder at `path`, never following a link.
173pub(crate) fn remove_path(path: &Path) -> std::io::Result<()> {
174    match path.symlink_metadata() {
175        Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path),
176        Ok(_) => std::fs::remove_file(path),
177        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
178        Err(err) => Err(err),
179    }
180}
181
182fn is_link(path: &Path) -> bool {
183    path.symlink_metadata()
184        .is_ok_and(|metadata| metadata.file_type().is_symlink())
185}
186
187/// A new, empty folder beside `dir` in which to build its replacement.
188/// Refuses when `dir` is a link, which would put the result elsewhere.
189pub(crate) fn stage(dir: &Path) -> Result<PathBuf, NpmError> {
190    if is_link(dir) {
191        return Err(NpmError::Refused(format!(
192            "Refusing to install through the link {}",
193            dir.display()
194        )));
195    }
196    let name = dir.file_name().unwrap_or_default().to_string_lossy();
197    let staging = dir.with_file_name(format!(".{name}-staging"));
198    let parent = dir.parent().unwrap_or(dir);
199    std::fs::create_dir_all(parent).map_err(io(parent))?;
200    // Left over by a stopped install. The root's lock keeps other installs out.
201    remove_path(&staging).map_err(io(&staging))?;
202    std::fs::create_dir(&staging).map_err(io(&staging))?;
203    Ok(staging)
204}
205
206/// Puts `staging`, built by `built`, in place of `dir`, so that `dir` holds
207/// either what it held or all of `staging`. A failed build only removes
208/// `staging`.
209pub(crate) fn swap<E: From<NpmError>>(
210    staging: &Path,
211    dir: &Path,
212    built: Result<(), E>,
213) -> Result<(), E> {
214    if let Err(err) = built {
215        let _ = remove_path(staging);
216        return Err(err);
217    }
218    swap_with(staging, dir, |from, to| std::fs::rename(from, to)).map_err(E::from)
219}
220
221/// [`swap`], renaming with `rename`. The previous `dir` is deleted only
222/// once the new one is in place: when putting it back fails, it stays
223/// beside `dir`, and the error names where.
224fn swap_with(
225    staging: &Path,
226    dir: &Path,
227    rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
228) -> Result<(), NpmError> {
229    let name = dir.file_name().unwrap_or_default().to_string_lossy();
230    let old = dir.with_file_name(format!(".{name}-old"));
231    let replacing = dir.symlink_metadata().is_ok();
232    let moved = remove_path(&old)
233        .map_err(io(&old))
234        .and_then(|()| match replacing {
235            true => rename(dir, &old).map_err(io(dir)),
236            false => Ok(()),
237        });
238    if let Err(err) = moved {
239        let _ = remove_path(staging);
240        return Err(err);
241    }
242    if let Err(err) = rename(staging, dir) {
243        let _ = remove_path(staging);
244        if replacing && let Err(back) = rename(&old, dir) {
245            return Err(NpmError::Io {
246                path: dir.to_path_buf(),
247                source: std::io::Error::other(format!(
248                    "{err}, and restoring it failed ({back}): the previous version is at {}",
249                    old.display()
250                )),
251            });
252        }
253        return Err(io(dir)(err));
254    }
255    let _ = remove_path(&old);
256    Ok(())
257}
258
259/// npm's configuration as the `npm install` pi runs reads it: the user config
260/// file (`npm_config_userconfig`, else `~/.npmrc`) under `npm_config_*`
261/// environment variables, with `${VAR}` references expanded.
262pub fn config() -> HashMap<String, String> {
263    let env = |name: &str| std::env::var(name).ok();
264    let vars: HashMap<String, String> = std::env::vars_os()
265        .filter_map(|(name, value)| {
266            let key = env_key(name.to_str()?)?;
267            let value = expand(value.to_str()?, env);
268            (!value.is_empty()).then_some((key, value))
269        })
270        .collect();
271    let file = vars.get("userconfig").map_or_else(
272        || crate::tools::path::home_dir().join(".npmrc"),
273        |path| PathBuf::from(crate::tools::path::expand_home(path)),
274    );
275    let mut config = parse_config(&std::fs::read_to_string(file).unwrap_or_default(), env);
276    config.extend(vars);
277    config
278}
279
280/// The setting environment variable `name` holds, when it is an
281/// `npm_config_*` variable, named as npm names it.
282fn env_key(name: &str) -> Option<String> {
283    let key = name
284        .get(..11)
285        .filter(|prefix| prefix.eq_ignore_ascii_case("npm_config_"))
286        .and(name.get(11..))?;
287    // npm keeps `//host/:_authToken` keys as written.
288    if key.starts_with("//") {
289        return Some(key.to_owned());
290    }
291    let rest = key.get(1..)?.replace('_', "-");
292    Some(format!("{}{rest}", &key[..1]).to_lowercase())
293}
294
295/// The top-level settings of an npmrc file, read as npm's `ini` parser reads
296/// them, with `${VAR}` references in keys and values expanded from `env`.
297fn parse_config(text: &str, env: impl Fn(&str) -> Option<String>) -> HashMap<String, String> {
298    let mut config = HashMap::new();
299    for line in text.lines().map(str::trim) {
300        if line.starts_with('[') && line.ends_with(']') {
301            // What follows belongs to a section, which npm ignores.
302            break;
303        }
304        if line.starts_with([';', '#']) {
305            continue;
306        }
307        let Some((key, value)) = line.split_once('=') else {
308            continue;
309        };
310        let value = value.trim();
311        let quoted = value.len() >= 2
312            && (value.starts_with('"') && value.ends_with('"')
313                || value.starts_with('\'') && value.ends_with('\''));
314        let value = if quoted {
315            &value[1..value.len() - 1]
316        } else {
317            value.split([';', '#']).next().unwrap_or_default().trim()
318        };
319        let value = expand(value, &env);
320        if !value.is_empty() {
321            config.insert(expand(key.trim(), &env), value);
322        }
323    }
324    config
325}
326
327/// `text` with npm's `${VAR}` references replaced by `env`'s values. An
328/// undefined `${VAR}` stays as written and an undefined `${VAR?}` is empty.
329/// npm's backslash escapes of references are not supported.
330fn expand(text: &str, env: impl Fn(&str) -> Option<String>) -> String {
331    let mut expanded = String::new();
332    let mut rest = text;
333    while let Some(start) = rest.find("${") {
334        expanded.push_str(&rest[..start]);
335        rest = &rest[start..];
336        let reference = rest[2..].find('}').map(|end| &rest[2..end + 2]);
337        let Some(reference) = reference.filter(|name| {
338            let name = name.strip_suffix('?').unwrap_or(name);
339            !name.is_empty() && !name.contains(['$', '{', '?'])
340        }) else {
341            expanded.push_str("${");
342            rest = &rest[2..];
343            continue;
344        };
345        let (name, optional) = match reference.strip_suffix('?') {
346            Some(name) => (name, true),
347            None => (reference, false),
348        };
349        match env(name) {
350            Some(value) => expanded.push_str(&value),
351            None if optional => {}
352            None => expanded.push_str(&rest[..reference.len() + 3]),
353        }
354        rest = &rest[reference.len() + 3..];
355    }
356    expanded.push_str(rest);
357    expanded
358}
359
360/// `token` without the `v` node-semver allows before a version, as in
361/// `v1.2.3` or `^v1.2`.
362fn without_v(token: &str) -> String {
363    let version = token.trim_start_matches(['<', '>', '=', '~', '^']);
364    let operator = &token[..token.len() - version.len()];
365    match version.strip_prefix('v') {
366        Some(version) => format!("{operator}{version}"),
367        None => token.to_owned(),
368    }
369}
370
371/// npm range syntax as `semver` requirements: any alternative may match.
372fn requirements(range: &str) -> Option<Vec<semver::VersionReq>> {
373    range
374        .split("||")
375        .map(|alternative| {
376            let alternative = alternative.trim();
377            let text = if let Some((low, high)) = alternative.split_once(" - ") {
378                format!(">={}, <={}", without_v(low.trim()), without_v(high.trim()))
379            } else {
380                // npm separates comparators with spaces, and `semver` with commas.
381                let mut parts: Vec<String> = Vec::new();
382                for token in alternative.split_whitespace() {
383                    let token = without_v(token);
384                    match parts.last_mut() {
385                        Some(last)
386                            if matches!(
387                                last.as_str(),
388                                ">" | ">=" | "<" | "<=" | "=" | "~" | "^"
389                            ) =>
390                        {
391                            last.push_str(&token);
392                        }
393                        // A bare version is exact in npm and a caret range in
394                        // `semver`. Wildcards such as `1.x` mean the same in both.
395                        _ if token.starts_with(|c: char| c.is_ascii_digit())
396                            && !token.contains(['x', 'X', '*']) =>
397                        {
398                            parts.push(format!("={token}"));
399                        }
400                        _ => parts.push(token),
401                    }
402                }
403                if parts.is_empty() {
404                    "*".into()
405                } else {
406                    parts.join(", ")
407                }
408            };
409            semver::VersionReq::parse(&text).ok()
410        })
411        .collect()
412}
413
414/// Whether `range` is a version range, not a dist-tag.
415pub(crate) fn is_range(range: &str) -> bool {
416    requirements(range).is_some()
417}
418
419/// Whether installed `version` satisfies `range`.
420pub fn satisfies(version: &str, range: &str) -> bool {
421    let Ok(version) = semver::Version::parse(version) else {
422        return false;
423    };
424    requirements(range)
425        .is_some_and(|alternatives| alternatives.iter().any(|req| req.matches(&version)))
426}
427
428/// Whether version `candidate` is later than `current`, as node-semver's
429/// `gt` compares them, ignoring surrounding whitespace. `None` when either
430/// is not a version, where `gt` throws.
431pub fn newer(candidate: &str, current: &str) -> Option<bool> {
432    let candidate = semver::Version::parse(candidate.trim()).ok()?;
433    let current = semver::Version::parse(current.trim()).ok()?;
434    Some(candidate > current)
435}
436
437/// The highest of `versions`, each with an item, that `range` matches, or of
438/// all of them without a range.
439fn highest<'a, T>(
440    versions: impl IntoIterator<Item = (&'a str, T)>,
441    range: Option<&str>,
442) -> Option<(&'a str, T)> {
443    let alternatives = match range {
444        Some(range) => Some(requirements(range)?),
445        None => None,
446    };
447    versions
448        .into_iter()
449        .filter_map(|(version, item)| Some((semver::Version::parse(version).ok()?, version, item)))
450        .filter(|(parsed, ..)| {
451            alternatives
452                .as_ref()
453                .is_none_or(|alternatives| alternatives.iter().any(|req| req.matches(parsed)))
454        })
455        .max_by(|(a, ..), (b, ..)| a.cmp(b))
456        .map(|(_, version, item)| (version, item))
457}
458
459/// The newest version in `npm view <spec> version --json` output: the one
460/// version, else the highest in the source's `range` (pi's `maxSatisfying`),
461/// or of all without one.
462pub(crate) fn viewed_version(output: &str, range: Option<&str>) -> Option<String> {
463    match serde_json::from_str::<Value>(output.trim()).ok()? {
464        Value::String(version) => Some(version),
465        Value::Array(versions) => highest(
466            versions
467                .iter()
468                .filter_map(Value::as_str)
469                .filter(|version| !version.is_empty())
470                .map(|version| (version, ())),
471            range,
472        )
473        .map(|(version, ())| version.to_owned()),
474        _ => None,
475    }
476}
477
478/// What npm asks a registry for: the abbreviated packument, which holds only
479/// what installs need. Full packuments of long-lived packages run to tens of
480/// megabytes.
481const PACKUMENT_ACCEPT: &str =
482    "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*";
483
484/// npm's default `fetch-timeout`.
485const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
486
487/// How much one package may take, so that a registry or a lock can neither
488/// fill memory with a huge download nor the disk with a small gzip bomb.
489#[derive(Clone, Copy, Debug)]
490struct Caps {
491    /// The bytes of its packument, which are held in memory, with headroom
492    /// over the largest full packuments on npm.
493    packument: usize,
494    /// The bytes of its tarball, which are held in memory.
495    download: usize,
496    /// The bytes it unpacks to, tar headers included.
497    unpacked: u64,
498    /// Its tar entries.
499    entries: usize,
500}
501
502// shortcut: fixed caps far above any npm package yapi installs, raise them when a real package hits one.
503const CAPS: Caps = Caps {
504    packument: 256 << 20,
505    download: 512 << 20,
506    unpacked: 2 << 30,
507    entries: 200_000,
508};
509
510/// `bytes` in the largest binary unit that divides them.
511fn size(bytes: u64) -> String {
512    match bytes {
513        _ if bytes.is_multiple_of(1 << 30) => format!("{} GiB", bytes >> 30),
514        _ if bytes.is_multiple_of(1 << 20) => format!("{} MiB", bytes >> 20),
515        _ => format!("{bytes} bytes"),
516    }
517}
518
519/// A registry client with a per-run packument cache.
520#[derive(Clone)]
521pub struct Npm {
522    config: HashMap<String, String>,
523    packuments: HashMap<String, Value>,
524    caps: Caps,
525}
526
527impl Npm {
528    /// A client with npm's configuration `config`, as [`config`] reads it.
529    pub fn new(config: HashMap<String, String>) -> Npm {
530        Npm {
531            config,
532            packuments: HashMap::new(),
533            caps: CAPS,
534        }
535    }
536
537    /// The registry of package `name`, ending in `/`: its scope's
538    /// `@scope:registry`, else `registry`, else npm's default.
539    fn registry(&self, name: &str) -> String {
540        let scope = name.split_once('/').map(|(scope, _)| scope);
541        let registry = scope
542            .filter(|scope| scope.starts_with('@'))
543            .and_then(|scope| self.config.get(&format!("{scope}:registry")))
544            .or_else(|| self.config.get("registry"))
545            .map_or("https://registry.npmjs.org/", String::as_str);
546        format!("{}/", registry.strip_suffix('/').unwrap_or(registry))
547    }
548
549    /// The `Authorization` npm sends with a request for `url` from
550    /// `registry`: the credentials of the longest `//host/path` key that
551    /// covers `url`, else those of `registry` when it is on `url`'s host.
552    fn authorization(&self, url: &str, registry: &str) -> Option<String> {
553        let parsed = url::Url::parse(url).ok()?;
554        let host =
555            |url: &url::Url| url[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
556        let mut key = format!("//{}{}", host(&parsed), parsed.path());
557        while key.len() > 2 {
558            let field = |name: &str| self.config.get(&format!("{key}:{name}"));
559            if let Some(token) = field("_authToken") {
560                return Some(format!("Bearer {token}"));
561            }
562            if let Some(auth) = field("_auth") {
563                return Some(format!("Basic {auth}"));
564            }
565            if let (Some(username), Some(password)) = (field("username"), field("_password")) {
566                let mut credentials = format!("{username}:").into_bytes();
567                credentials.extend(STANDARD.decode(password).ok()?);
568                return Some(format!("Basic {}", STANDARD.encode(credentials)));
569            }
570            // Up one level: `//host/a/b/` to `//host/a/b`, then `//host/a/`.
571            let parent = match key.strip_suffix('/') {
572                Some(parent) => parent.len(),
573                None => key.trim_end_matches(|c| c != '/').len(),
574            };
575            key.truncate(parent);
576        }
577        let same_host =
578            url::Url::parse(registry).is_ok_and(|registry| host(&registry) == host(&parsed));
579        if url != registry && same_host {
580            self.authorization(registry, registry)
581        } else {
582            None
583        }
584    }
585
586    /// GETs `url` of a package from `registry`, asking for the `accept`
587    /// media types.
588    async fn get(
589        &self,
590        url: &str,
591        accept: &str,
592        registry: &str,
593    ) -> Result<reqwest::Response, NpmError> {
594        let mut request = yapi_ai::http::client()
595            .get(url)
596            .header(reqwest::header::ACCEPT, accept);
597        if let Some(authorization) = self.authorization(url, registry)
598            && let Ok(mut value) = reqwest::header::HeaderValue::from_str(&authorization)
599        {
600            value.set_sensitive(true);
601            request = request.header(reqwest::header::AUTHORIZATION, value);
602        }
603        // A request that never ends would hold the install, and the root's lock, for good.
604        if let Some(timeout) = self.timeout() {
605            request = request.timeout(timeout);
606        }
607        let response = request
608            .send()
609            .await
610            .map_err(|err| request_error(url, &err))?;
611        if !response.status().is_success() {
612            return Err(NpmError::Registry(format!(
613                "GET {url}: {}",
614                response.status()
615            )));
616        }
617        Ok(response)
618    }
619
620    async fn packument(&mut self, name: &str) -> Result<&Value, NpmError> {
621        if !self.packuments.contains_key(name) {
622            let registry = self.registry(name);
623            let url = format!("{registry}{}", name.replace('/', "%2f"));
624            let response = self.get(&url, PACKUMENT_ACCEPT, &registry).await?;
625            let what = format!("The packument of {name}");
626            let body = read_capped(response, &url, self.caps.packument, &what).await?;
627            let packument: Value = serde_json::from_slice(&body)
628                .map_err(|err| NpmError::Registry(format!("GET {url}: {err}")))?;
629            self.packuments.insert(name.to_owned(), packument);
630        }
631        // Present: found or inserted above.
632        Ok(&self.packuments[name])
633    }
634
635    /// The manifest of the version of `name` that `range` selects: a dist-tag,
636    /// else the latest tag when it matches, else the highest match.
637    async fn pick(&mut self, name: &str, range: &str) -> Result<Value, NpmError> {
638        let packument = self.packument(name).await?;
639        let no_versions = Map::new();
640        let versions = packument["versions"].as_object().unwrap_or(&no_versions);
641        let range = if range.is_empty() { "latest" } else { range };
642        let no_match = || NpmError::NoMatch {
643            name: name.to_owned(),
644            range: range.to_owned(),
645        };
646        if let Some(tagged) = packument["dist-tags"][range].as_str() {
647            return versions.get(tagged).cloned().ok_or_else(no_match);
648        }
649        if let Some(latest) = packument["dist-tags"]["latest"].as_str()
650            && satisfies(latest, range)
651            && let Some(manifest) = versions.get(latest)
652        {
653            return Ok(manifest.clone());
654        }
655        let versions = versions
656            .iter()
657            .map(|(version, manifest)| (version.as_str(), manifest));
658        highest(versions, Some(range))
659            .map(|(_, manifest)| manifest.clone())
660            .ok_or_else(no_match)
661    }
662
663    /// The version pi's update checks compare with the installed one, as
664    /// `npm view <name>@<range> version` reports it: the version a dist-tag
665    /// names, else the highest version in the range.
666    pub(crate) async fn latest(&mut self, name: &str, range: &str) -> Result<String, NpmError> {
667        let packument = self.packument(name).await?;
668        if let Some(tagged) = packument["dist-tags"][range].as_str() {
669            return Ok(tagged.to_owned());
670        }
671        packument["versions"]
672            .as_object()
673            .and_then(|versions| {
674                let versions = versions.keys().map(|version| (version.as_str(), ()));
675                highest(versions, Some(range))
676            })
677            .map(|(version, ())| version.to_owned())
678            .ok_or_else(|| NpmError::NoMatch {
679                name: name.to_owned(),
680                range: range.to_owned(),
681            })
682    }
683
684    /// npm's `fetch-timeout`: how long a request may take, `None` (`0`) for
685    /// no limit.
686    fn timeout(&self) -> Option<std::time::Duration> {
687        let millis = self.config.get("fetch-timeout");
688        match millis.and_then(|millis| millis.trim().parse::<u64>().ok()) {
689            Some(0) => None,
690            Some(millis) => Some(std::time::Duration::from_millis(millis)),
691            None => Some(FETCH_TIMEOUT),
692        }
693    }
694
695    /// The tarball at `url` of package `name`, sent with the credentials of
696    /// its registry. It is read in chunks, and the read stops at the cap.
697    async fn download(&self, name: &str, url: &str) -> Result<Vec<u8>, NpmError> {
698        let registry = self.registry(name);
699        let response = self.get(url, "*/*", &registry).await?;
700        let what = format!("The tarball of {name}");
701        read_capped(response, url, self.caps.download, &what).await
702    }
703
704    async fn tarball(&self, manifest: &Value) -> Result<Vec<u8>, NpmError> {
705        let id = format!("{}@{}", text(&manifest["name"]), text(&manifest["version"]));
706        let url = manifest["dist"]["tarball"].as_str().unwrap_or_default();
707        let bytes = self.download(&text(&manifest["name"]), url).await?;
708        if verify(&bytes, &manifest["dist"]) {
709            Ok(bytes)
710        } else {
711            Err(NpmError::Integrity(id))
712        }
713    }
714
715    /// Fails unless `pin` is safe to download: a valid name, an integrity
716    /// that is exactly one sha512 digest, and a tarball on the host and port
717    /// of the registry configured for its name, over https unless that
718    /// registry uses http. An edited lock can then neither send a download
719    /// elsewhere nor credentials in cleartext. chest also checks each pin
720    /// before it writes a lock, so that it never writes one replay refuses.
721    pub(crate) fn check_pin(&self, pin: &Pinned) -> Result<(), NpmError> {
722        check_name(&pin.name)?;
723        let id = format!("{}@{}", pin.name, pin.version);
724        if sha512_digest(&pin.integrity).is_none() {
725            return Err(NpmError::Refused(format!(
726                "Invalid integrity for {id}: expected one sha512 digest"
727            )));
728        }
729        self.pinned_url(pin).map(drop)
730    }
731
732    /// Where to download `pin`'s tarball: its URL when that lies on the
733    /// registry configured for its name, over https unless that registry
734    /// uses http, at the standard `<name>/-/<unscoped name>-<version>.tgz`
735    /// path for its name and version under the registry's own path. A
736    /// tarball pinned on registry.npmjs.org
737    /// comes from the configured registry when that differs, as npm's
738    /// `replace-registry-host` does. Anything else is refused, so an edited
739    /// lock can neither send a download elsewhere, nor credentials in
740    /// cleartext, nor put another package's tarball in its place.
741    fn pinned_url(&self, pin: &Pinned) -> Result<String, NpmError> {
742        let id = format!("{}@{}", pin.name, pin.version);
743        let refuse = |why: String| {
744            NpmError::Refused(format!(
745                "Refusing to download {id} from {}: {why}",
746                pin.tarball
747            ))
748        };
749        let registry = self.registry(&pin.name);
750        let unscoped = pin.name.rsplit('/').next().unwrap_or(&pin.name);
751        let file = format!("{}/-/{unscoped}-{}.tgz", pin.name, pin.version);
752        let url = url::Url::parse(&pin.tarball).map_err(|_| refuse("not a URL".into()))?;
753        let from =
754            url::Url::parse(&registry).map_err(|_| refuse(format!("{registry} is not a URL")))?;
755        let misplaced = |expected: String| {
756            refuse(format!(
757                "a pinned tarball must be at {expected}. Registries that keep tarballs elsewhere, such as GitHub Packages, cannot be replayed from a lock"
758            ))
759        };
760        const NPMJS: Option<&str> = Some("registry.npmjs.org");
761        if url.host_str() == NPMJS && from.host_str() != NPMJS {
762            if url.path() != format!("/{file}") {
763                return Err(misplaced(format!("/{file}")));
764            }
765            return Ok(format!("{registry}{file}"));
766        }
767        let scheme = url.scheme() == "https" || url.scheme() == "http" && from.scheme() == "http";
768        let same = url.host_str() == from.host_str()
769            && url.port_or_known_default() == from.port_or_known_default();
770        if !scheme || !same {
771            return Err(refuse(format!(
772                "a pinned tarball must come from {registry}, the registry for {}, over https unless that registry uses http",
773                pin.name
774            )));
775        }
776        // Under the registry's own path, which may be a subpath.
777        let expected = format!("{}{file}", from.path());
778        if url.path() != expected {
779            return Err(misplaced(expected));
780        }
781        Ok(pin.tarball.clone())
782    }
783
784    /// The tarball of `pin`, which [`Npm::check_pin`] passed, checked
785    /// against its sha512 digest alone.
786    async fn pinned_tarball(&self, pin: &Pinned) -> Result<Vec<u8>, NpmError> {
787        let url = self.pinned_url(pin)?;
788        let bytes = self.download(&pin.name, &url).await?;
789        if sha512_digest(&pin.integrity).as_deref() == Some(digest(&SHA512, &bytes).as_ref()) {
790            Ok(bytes)
791        } else {
792            Err(NpmError::Integrity(format!("{}@{}", pin.name, pin.version)))
793        }
794    }
795}
796
797/// The body of `response` for `url`, read in chunks and refused, as `what`,
798/// once it passes `cap` bytes, which stops the read.
799async fn read_capped(
800    mut response: reqwest::Response,
801    url: &str,
802    cap: usize,
803    what: &str,
804) -> Result<Vec<u8>, NpmError> {
805    let mut bytes = Vec::new();
806    while let Some(chunk) = response
807        .chunk()
808        .await
809        .map_err(|err| request_error(url, &err))?
810    {
811        if bytes.len() + chunk.len() > cap {
812            return Err(NpmError::Refused(format!(
813                "{what} at {url} is over the download cap of {}",
814                size(cap as u64)
815            )));
816        }
817        bytes.extend_from_slice(&chunk);
818    }
819    Ok(bytes)
820}
821
822fn text(value: &Value) -> String {
823    value.as_str().unwrap_or_default().to_owned()
824}
825
826/// A failed request for `url`. A timeout is worded as npm words it, since
827/// reqwest's message does not say so.
828fn request_error(url: &str, err: &reqwest::Error) -> NpmError {
829    if err.is_timeout() {
830        NpmError::Registry(format!("network timeout at: {url}"))
831    } else {
832        NpmError::Registry(format!("GET {url}: {err}"))
833    }
834}
835
836/// Checks `bytes` against the strongest algorithm of the registry's
837/// `integrity` that yapi knows (sha512, then sha1), as npm's ssri does, and
838/// against `shasum` when `integrity` has neither.
839fn verify(bytes: &[u8], dist: &Value) -> bool {
840    let integrity = dist["integrity"].as_str().unwrap_or_default();
841    let hashes = |prefix: &str| -> Vec<&str> {
842        integrity
843            .split_whitespace()
844            .filter_map(|entry| entry.strip_prefix(prefix))
845            .collect()
846    };
847    let sha512 = hashes("sha512-");
848    if !sha512.is_empty() {
849        return sha512.contains(&STANDARD.encode(digest(&SHA512, bytes)).as_str());
850    }
851    let sha1 = digest(&SHA1, bytes);
852    let sha1_hashes = hashes("sha1-");
853    if !sha1_hashes.is_empty() {
854        return sha1_hashes.contains(&STANDARD.encode(sha1).as_str());
855    }
856    dist["shasum"]
857        .as_str()
858        .is_some_and(|shasum| crate::time::hex(sha1.as_ref()).eq_ignore_ascii_case(shasum))
859}
860
861/// `inner`, failing once more than `left` bytes come out of it, which it
862/// then records in `over`.
863struct Capped<'a, R> {
864    inner: R,
865    left: u64,
866    over: &'a std::cell::Cell<bool>,
867}
868
869impl<R: std::io::Read> std::io::Read for Capped<'_, R> {
870    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
871        let read = self.inner.read(buf)?;
872        match self.left.checked_sub(read as u64) {
873            Some(left) => {
874                self.left = left;
875                Ok(read)
876            }
877            None => {
878                self.over.set(true);
879                Err(std::io::Error::other("over the cap"))
880            }
881        }
882    }
883}
884
885/// Unpacks the npm tarball of package `id` into `dir`, dropping each entry's
886/// first path component (`package/`). Links and entries leaving `dir` are
887/// skipped. Fails once the tarball unpacks to more than `caps` allow, with
888/// what it unpacked left in `dir` for the caller to remove.
889fn unpack(bytes: &[u8], dir: &Path, id: &str, caps: Caps) -> Result<(), NpmError> {
890    let over = std::cell::Cell::new(false);
891    let stream = Capped {
892        inner: flate2::read::GzDecoder::new(bytes),
893        left: caps.unpacked,
894        over: &over,
895    };
896    // A read past the cap fails whatever step made it, long names included.
897    unpack_entries(stream, dir, id, caps).map_err(|err| match over.get() {
898        true => NpmError::Refused(format!(
899            "The tarball of {id} unpacks to over the cap of {}",
900            size(caps.unpacked)
901        )),
902        false => err,
903    })
904}
905
906/// [`unpack`], from the tarball's decompressed `stream`.
907fn unpack_entries(
908    stream: impl std::io::Read,
909    dir: &Path,
910    id: &str,
911    caps: Caps,
912) -> Result<(), NpmError> {
913    let mut archive = tar::Archive::new(stream);
914    let entries = archive.entries().map_err(io(dir))?;
915    for (count, entry) in entries.enumerate() {
916        if count == caps.entries {
917            return Err(NpmError::Refused(format!(
918                "The tarball of {id} holds over the cap of {} entries",
919                caps.entries
920            )));
921        }
922        let mut entry = entry.map_err(io(dir))?;
923        let kind = entry.header().entry_type();
924        if !(kind.is_file() || kind.is_dir()) {
925            continue;
926        }
927        let path = entry.path().map_err(io(dir))?.into_owned();
928        let mut components = path.components();
929        components.next();
930        let relative: PathBuf = components.collect();
931        if relative.as_os_str().is_empty()
932            || relative
933                .components()
934                .any(|component| !matches!(component, Component::Normal(_)))
935        {
936            continue;
937        }
938        let target = dir.join(&relative);
939        if kind.is_dir() {
940            std::fs::create_dir_all(&target).map_err(io(&target))?;
941            continue;
942        }
943        if let Some(parent) = target.parent() {
944            std::fs::create_dir_all(parent).map_err(io(parent))?;
945        }
946        // Streamed: a small tarball can unpack to a file far larger than memory.
947        let mut file = std::fs::File::create(&target).map_err(io(&target))?;
948        std::io::copy(&mut entry, &mut file).map_err(io(&target))?;
949        #[cfg(unix)]
950        if let Ok(mode) = entry.header().mode() {
951            use std::os::unix::fs::PermissionsExt as _;
952            let _ = std::fs::set_permissions(
953                &target,
954                std::fs::Permissions::from_mode(mode & 0o755 | 0o644),
955            );
956        }
957    }
958    Ok(())
959}
960
961/// The `package.json` of the package at `dir`, when it reads and parses. A
962/// byte order mark is ignored, as npm ignores it.
963fn read_manifest(dir: &Path) -> Option<Value> {
964    yapi_types::json::parse(&std::fs::read_to_string(dir.join("package.json")).ok()?).ok()
965}
966
967/// The file in a package's folder while its install has not finished.
968const INCOMPLETE: &str = ".yapi-incomplete";
969
970/// The version in the `package.json` at `dir`.
971fn manifest_version(dir: &Path) -> Option<String> {
972    read_manifest(dir)?["version"].as_str().map(str::to_owned)
973}
974
975/// The version of the package installed at `dir`, if any. A package whose
976/// install never finished, as when the process was stopped, is not installed.
977pub(crate) fn installed_version(dir: &Path) -> Option<String> {
978    if dir.join(INCOMPLETE).exists() {
979        return None;
980    }
981    manifest_version(dir)
982}
983
984/// The `node_modules` folders in which Node looks for a dependency of the
985/// package in `dependent`, nearest first, up to `root_modules`.
986fn lookup_path<'a>(
987    dependent: &'a Path,
988    root_modules: &'a Path,
989) -> impl Iterator<Item = PathBuf> + 'a {
990    let ancestors = dependent.ancestors().filter(move |dir| {
991        dir.file_name() == Some("node_modules".as_ref()) && dir.starts_with(root_modules)
992    });
993    std::iter::once(dependent.join("node_modules")).chain(ancestors.map(Path::to_path_buf))
994}
995
996/// The dependencies of `manifest` that [`Installer`] installs, in its order:
997/// production ones not also optional, then optional ones, each with its
998/// spec, `*` when that is not a string, and whether it is optional.
999fn dependencies(manifest: &Value) -> impl Iterator<Item = (&str, &str, bool)> {
1000    fn listed<'a>(
1001        manifest: &'a Value,
1002        key: &'static str,
1003        optional: bool,
1004    ) -> impl Iterator<Item = (&'a str, &'a str, bool)> {
1005        let listed = manifest[key].as_object().into_iter().flatten();
1006        listed.map(move |(name, spec)| (name.as_str(), spec.as_str().unwrap_or("*"), optional))
1007    }
1008    let optional = &manifest["optionalDependencies"];
1009    listed(manifest, "dependencies", false)
1010        .filter(move |(name, ..)| optional.get(*name).is_none())
1011        .chain(listed(manifest, "optionalDependencies", true))
1012}
1013
1014/// The package and range that dependency `name` with `spec` installs: the
1015/// target of an `npm:<package>@<range>` alias, which installs under `name`,
1016/// else `name` itself. `None` for what yapi never installs: host packages,
1017/// `file:` and `workspace:` specs.
1018fn wanted(name: &str, spec: &str) -> Option<(String, String)> {
1019    if HOST_PACKAGES.contains(&name) || spec.starts_with("file:") || spec.starts_with("workspace:")
1020    {
1021        return None;
1022    }
1023    Some(match spec.strip_prefix("npm:") {
1024        Some(spec) => {
1025            let (package, range) = super::source::split_npm_spec(spec);
1026            (package, range.unwrap_or_else(|| "*".into()))
1027        }
1028        None => (name.to_owned(), spec.to_owned()),
1029    })
1030}
1031
1032/// Whether `version` of package `found` satisfies a dependency on `package`
1033/// at `range`. A dist-tag, which names no version, takes any version of the
1034/// package, as npm's arborist judges it, and an empty range is `latest`.
1035fn satisfied(found: &str, version: &str, package: &str, range: &str) -> bool {
1036    let tag = |range: &str| {
1037        range
1038            .bytes()
1039            .all(|byte| byte.is_ascii_alphanumeric() || b"-._".contains(&byte))
1040    };
1041    found == package
1042        && match range {
1043            "" => true,
1044            _ if is_range(range) => satisfies(version, range),
1045            _ => tag(range),
1046        }
1047}
1048
1049/// Fails unless the tree `pins` records, unpacked in `staging` to stand for
1050/// `root/node_modules`, is what its dependents ask for, as npm keeps a tree.
1051/// Each package's own `package.json` must name its pin's name and version.
1052/// Each required dependency, of a pinned package or of `root/package.json`,
1053/// that reaches a pinned folder by Node's lookup must be satisfied by it,
1054/// and each pinned folder must be reached by a dependency it satisfies. An
1055/// optional dependency counts where it is satisfied and is otherwise
1056/// ignored, as yapi skips one that fails. The dependencies yapi never
1057/// installs are not checked: host packages, `file:` and `workspace:` specs,
1058/// and those for another platform, which reach no folder.
1059fn check_tree(root: &Path, staging: &Path, pins: &[&Pinned]) -> Result<(), NpmError> {
1060    let modules = Path::new("node_modules");
1061    // Pin paths start with `node_modules`, which `staging` stands for.
1062    let folder = |path: &Path| staging.join(path.strip_prefix(modules).unwrap_or(path));
1063    // Folded, as `replay` refused paths that differ by case alone, so that a
1064    // lookup a case-insensitive file system answers finds its pin.
1065    let index: HashMap<String, usize> = pins
1066        .iter()
1067        .enumerate()
1068        .map(|(index, pin)| (pin.path.to_ascii_lowercase(), index))
1069        .collect();
1070    let mut dependents = Vec::new();
1071    for pin in pins {
1072        let manifest = read_manifest(&folder(Path::new(&pin.path))).unwrap_or_default();
1073        let (name, version) = (text(&manifest["name"]), text(&manifest["version"]));
1074        // npm publishes a `v1.0.0` manifest as version `1.0.0`.
1075        if name != pin.name || version.trim().trim_start_matches(['=', 'v']) != pin.version {
1076            return Err(NpmError::Refused(format!(
1077                "The package.json in {} names {name}@{version}, not the pinned {}@{}",
1078                pin.path, pin.name, pin.version
1079            )));
1080        }
1081        dependents.push((PathBuf::from(&pin.path), manifest));
1082    }
1083    dependents.extend(read_manifest(root).map(|manifest| (PathBuf::new(), manifest)));
1084    let mut reached = vec![false; pins.len()];
1085    for (dependent, manifest) in &dependents {
1086        for (name, spec, optional) in dependencies(manifest) {
1087            // As `place` reads it. An invalid name installs nothing.
1088            let Some((package, range)) = check_name(name).ok().and_then(|()| wanted(name, spec))
1089            else {
1090                continue;
1091            };
1092            let Some(found) = lookup_path(dependent, modules)
1093                .map(|dir| dir.join(name))
1094                .find(|path| folder(path).is_dir())
1095            else {
1096                continue;
1097            };
1098            // A folder without a pin came in its parent's tarball.
1099            let found = found.to_string_lossy().replace('\\', "/");
1100            let Some(&index) = index.get(&found.to_ascii_lowercase()) else {
1101                continue;
1102            };
1103            let pin = pins[index];
1104            if satisfied(&pin.name, &pin.version, &package, &range) {
1105                reached[index] = true;
1106            } else if !optional {
1107                let by = match dependent.as_os_str().is_empty() {
1108                    true => "package.json".to_owned(),
1109                    false => dependent.to_string_lossy().replace('\\', "/"),
1110                };
1111                return Err(NpmError::Refused(format!(
1112                    "{} holds {}@{}, but {by} needs {package}@{range} there",
1113                    pin.path, pin.name, pin.version
1114                )));
1115            }
1116        }
1117    }
1118    if let Some((pin, _)) = pins.iter().zip(reached).find(|(_, reached)| !reached) {
1119        return Err(NpmError::Refused(format!(
1120            "{} holds {}@{}, which nothing depends on",
1121            pin.path, pin.name, pin.version
1122        )));
1123    }
1124    Ok(())
1125}
1126
1127/// Installs into `root/node_modules`, hoisting dependencies there when Node's
1128/// resolution allows and nesting them otherwise.
1129struct Installer<'a> {
1130    npm: &'a mut Npm,
1131    root_modules: PathBuf,
1132    /// The packages whose dependencies are being installed, with their
1133    /// manifests. They count as installed so that cycles end.
1134    installing: Vec<(PathBuf, Value)>,
1135    /// A pin for each package installed so far.
1136    tree: Vec<Pinned>,
1137}
1138
1139impl Installer<'_> {
1140    /// Unpacks `manifest`'s package into `dir`, replacing what is there, then
1141    /// installs its dependencies. When either fails, `dir` holds what it held
1142    /// before.
1143    fn unpack_package<'b>(
1144        &'b mut self,
1145        dir: PathBuf,
1146        manifest: Value,
1147    ) -> BoxFuture<'b, Result<(), NpmError>> {
1148        Box::pin(async move {
1149            let bytes = self.npm.tarball(&manifest).await?;
1150            let start = self.tree.len();
1151            // Every package folder is below `root_modules`: `install` and `place` put it there.
1152            let relative = dir.strip_prefix(&self.root_modules).unwrap_or(&dir);
1153            let path = format!(
1154                "node_modules/{}",
1155                relative.to_string_lossy().replace('\\', "/")
1156            );
1157            let pin = Pinned::new(path, &manifest, &bytes);
1158            // The old version waits beside `dir` while the new one installs in
1159            // place, where its dependencies find it and cycles end.
1160            let name = dir.file_name().unwrap_or_default().to_string_lossy();
1161            let old = dir.with_file_name(format!(".{name}-old"));
1162            let _ = std::fs::remove_dir_all(&old);
1163            let replacing = dir.exists();
1164            if replacing {
1165                std::fs::rename(&dir, &old).map_err(io(&dir))?;
1166            }
1167            let installed = async {
1168                std::fs::create_dir_all(&dir).map_err(io(&dir))?;
1169                // Marks the package until its dependencies are in, so an
1170                // install that never returns is redone.
1171                let marker = dir.join(INCOMPLETE);
1172                std::fs::write(&marker, "").map_err(io(&marker))?;
1173                let id = format!("{}@{}", text(&manifest["name"]), text(&manifest["version"]));
1174                unpack(&bytes, &dir, &id, self.npm.caps)?;
1175                self.installing.push((dir.clone(), manifest.clone()));
1176                let dependencies = self.dependencies(&dir, &manifest).await;
1177                self.installing.pop();
1178                dependencies?;
1179                std::fs::remove_file(&marker).map_err(io(&marker))
1180            }
1181            .await;
1182            if installed.is_ok() {
1183                let _ = std::fs::remove_dir_all(&old);
1184                self.tree.push(pin);
1185            } else {
1186                // So do the packages its install put elsewhere, such as at the
1187                // root, which only it needed, along with those nested in `dir`.
1188                for installed in self.tree.drain(start..) {
1189                    // Every pin's path starts so, as above.
1190                    if let Some(relative) = installed.path.strip_prefix("node_modules/") {
1191                        let _ = std::fs::remove_dir_all(self.root_modules.join(relative));
1192                    }
1193                }
1194                let _ = std::fs::remove_dir_all(&dir);
1195                if replacing {
1196                    let _ = std::fs::rename(&old, &dir);
1197                }
1198            }
1199            installed
1200        })
1201    }
1202
1203    /// Installs the production and optional dependencies of the package in
1204    /// `dir`. Optional ones that fail or name a platform are skipped.
1205    async fn dependencies(&mut self, dir: &Path, manifest: &Value) -> Result<(), NpmError> {
1206        for (name, spec, optional) in dependencies(manifest) {
1207            let placed = self.place(dir, name, spec).await;
1208            if !optional {
1209                placed?;
1210            }
1211        }
1212        Ok(())
1213    }
1214
1215    /// Makes dependency `name@range` of the package in `dependent` resolvable
1216    /// from it. The copy it finds first by Node's lookup is kept when it is
1217    /// the package at a version in the range, and a new copy is hoisted to
1218    /// the root when nothing is in the way, else nested in `dependent`. Either
1219    /// needs every package that would see the copy to accept it, so that none
1220    /// of them nests another over it later and shadows it for `dependent`.
1221    fn place<'b>(
1222        &'b mut self,
1223        dependent: &'b Path,
1224        name: &'b str,
1225        range: &'b str,
1226    ) -> BoxFuture<'b, Result<(), NpmError>> {
1227        Box::pin(async move {
1228            check_name(name)?;
1229            let Some((package, range)) = wanted(name, range) else {
1230                return Ok(());
1231            };
1232            let levels: Vec<PathBuf> = lookup_path(dependent, &self.root_modules).collect();
1233            let resolved = levels.iter().enumerate().find_map(|(level, modules)| {
1234                let dir = modules.join(name);
1235                let installing = self.installing.iter().any(|(path, _)| *path == dir);
1236                if !installing && dir.join(INCOMPLETE).exists() {
1237                    return None;
1238                }
1239                let manifest = read_manifest(&dir)?;
1240                let version = manifest["version"].as_str()?.to_owned();
1241                Some((level, text(&manifest["name"]), version))
1242            });
1243            if let Some((level, found, version)) = &resolved
1244                && satisfied(found, version, &package, &range)
1245                && self.accepted(&levels[..=*level], name, found, version)
1246            {
1247                return Ok(());
1248            }
1249            let manifest = self.npm.pick(&package, &range).await?;
1250            if manifest.get("os").is_some() || manifest.get("cpu").is_some() {
1251                // Platform packages carry prebuilt binaries, never JS.
1252                return Ok(());
1253            }
1254            let (found, version) = (text(&manifest["name"]), text(&manifest["version"]));
1255            let target = match resolved {
1256                None if self.accepted(&levels, name, &found, &version) => {
1257                    self.root_modules.join(name)
1258                }
1259                // Nested, it shadows the other version for `dependent` alone.
1260                _ => dependent.join("node_modules").join(name),
1261            };
1262            self.unpack_package(target, manifest).await
1263        })
1264    }
1265
1266    /// Whether each package that owns one of the `node_modules` folders
1267    /// `levels` accepts `version` of package `found` as its dependency
1268    /// `name`, when it has one.
1269    fn accepted(&self, levels: &[PathBuf], name: &str, found: &str, version: &str) -> bool {
1270        levels.iter().all(|modules| {
1271            let Some(owner) = modules.parent() else {
1272                return true;
1273            };
1274            let read;
1275            let manifest = match self.installing.iter().find(|(dir, _)| dir == owner) {
1276                Some((_, manifest)) => manifest,
1277                None => match read_manifest(owner) {
1278                    Some(manifest) => {
1279                        read = manifest;
1280                        &read
1281                    }
1282                    None => return true,
1283                },
1284            };
1285            dependencies(manifest)
1286                .filter(|(dependency, ..)| *dependency == name)
1287                .all(|(dependency, spec, _)| {
1288                    wanted(dependency, spec)
1289                        .is_none_or(|(package, range)| satisfied(found, version, &package, &range))
1290                })
1291        })
1292    }
1293}
1294
1295/// pi's `ensureGitIgnore`: creates `dir` with a `.gitignore` that keeps
1296/// everything else in it out of git.
1297pub(crate) fn ensure_git_ignore(dir: &Path) -> Result<(), NpmError> {
1298    std::fs::create_dir_all(dir).map_err(io(dir))?;
1299    let ignore = dir.join(".gitignore");
1300    if !ignore.exists() {
1301        std::fs::write(&ignore, "*\n!.gitignore\n").map_err(io(&ignore))?;
1302    }
1303    Ok(())
1304}
1305
1306/// The `package.json` of an npm root, created as pi creates it.
1307fn root_manifest(root: &Path) -> Result<Map<String, Value>, NpmError> {
1308    ensure_git_ignore(root)?;
1309    let manifest = read_manifest(root)
1310        .and_then(|value| value.as_object().cloned())
1311        .unwrap_or_else(|| {
1312            let mut manifest = Map::new();
1313            manifest.insert("name".into(), json!("pi-extensions"));
1314            manifest.insert("private".into(), json!(true));
1315            manifest
1316        });
1317    Ok(manifest)
1318}
1319
1320fn write_manifest(root: &Path, manifest: Map<String, Value>) -> Result<(), NpmError> {
1321    let path = root.join("package.json");
1322    let text =
1323        yapi_types::json::to_string_pretty(&Value::Object(manifest), "  ").unwrap_or_default();
1324    yapi_ai::auth::lock::write_atomic(&path, format!("{text}\n").as_bytes(), 0o666)
1325        .map_err(io(&path))
1326}
1327
1328/// Locks the npm root `root`, creating it, against the installs and removals
1329/// of other processes until the lock is dropped. Waits as long as a live
1330/// holder keeps the lock, and takes over the lock of a holder that died.
1331pub(crate) async fn lock_root(root: &Path) -> Result<FileLock, NpmError> {
1332    std::fs::create_dir_all(root).map_err(io(root))?;
1333    let manifest = root.join("package.json");
1334    loop {
1335        match FileLock::acquire(&manifest, &CancellationToken::new()).await {
1336            // A lock still held after the stale period is fresh: its holder is alive.
1337            Err(LockError::Held) => continue,
1338            result => return result.map_err(|err| io(&manifest)(std::io::Error::other(err))),
1339        }
1340    }
1341}
1342
1343/// What [`install`] installed.
1344#[derive(Clone, Debug, PartialEq, Eq)]
1345pub struct Installed {
1346    /// The version of the requested package.
1347    pub version: String,
1348    /// A pin for each package the call unpacked, the requested one included,
1349    /// in no particular order. Packages it found already installed are not
1350    /// listed, so the tree is complete only when the root started empty: a
1351    /// tree recorded over a non-empty root must not be locked.
1352    pub tree: Vec<Pinned>,
1353}
1354
1355/// Installs `name` (at `range`, else the latest version) into the npm root
1356/// `root`, and records it in the root's `package.json`. The caller holds the
1357/// root's `lock_root`.
1358pub async fn install(
1359    npm: &mut Npm,
1360    root: &Path,
1361    name: &str,
1362    range: Option<&str>,
1363) -> Result<Installed, NpmError> {
1364    check_name(name)?;
1365    let mut manifest_file = root_manifest(root)?;
1366    let manifest = npm.pick(name, range.unwrap_or("latest")).await?;
1367    let version = text(&manifest["version"]);
1368    let modules = root.join("node_modules");
1369    let mut installer = Installer {
1370        npm,
1371        root_modules: modules.clone(),
1372        installing: Vec::new(),
1373        tree: Vec::new(),
1374    };
1375    installer
1376        .unpack_package(modules.join(name), manifest)
1377        .await?;
1378    let tree = installer.tree;
1379    let dependencies = manifest_file
1380        .entry("dependencies")
1381        .or_insert_with(|| Value::Object(Map::new()));
1382    if let Some(dependencies) = dependencies.as_object_mut() {
1383        let spec = range.map_or_else(|| format!("^{version}"), str::to_owned);
1384        dependencies.insert(name.to_owned(), Value::String(spec));
1385    }
1386    write_manifest(root, manifest_file)?;
1387    Ok(Installed { version, tree })
1388}
1389
1390/// Installs the dependencies of the package in `dir` (a git checkout) into
1391/// its own `node_modules`, and returns a pin for each package it unpacked,
1392/// with paths below `dir`, as [`Installed::tree`] lists them. Fails when the
1393/// checkout links `node_modules` or a scope folder in it elsewhere.
1394pub async fn install_dependencies(npm: &mut Npm, dir: &Path) -> Result<Vec<Pinned>, NpmError> {
1395    let Some(manifest) = read_manifest(dir) else {
1396        return Ok(Vec::new());
1397    };
1398    let modules = dir.join("node_modules");
1399    let scopes = std::fs::read_dir(&modules)
1400        .into_iter()
1401        .flatten()
1402        .flatten()
1403        .filter(|entry| entry.file_name().to_string_lossy().starts_with('@'))
1404        .map(|entry| entry.path());
1405    let is_link = |path: &PathBuf| {
1406        path.symlink_metadata()
1407            .is_ok_and(|metadata| metadata.file_type().is_symlink())
1408    };
1409    if let Some(link) = std::iter::once(modules.clone()).chain(scopes).find(is_link) {
1410        return Err(NpmError::Refused(format!(
1411            "Refusing to install dependencies through the link {}",
1412            link.display()
1413        )));
1414    }
1415    let mut installer = Installer {
1416        npm,
1417        root_modules: modules,
1418        installing: Vec::new(),
1419        tree: Vec::new(),
1420    };
1421    installer.dependencies(dir, &manifest).await?;
1422    Ok(installer.tree)
1423}
1424
1425/// Downloads `name` (at `range`, else the latest version), verifies its
1426/// tarball and unpacks it in place of `dir`, without its dependencies.
1427/// Returns its pin, whose `path` is empty: the package is `dir` itself.
1428pub async fn fetch(
1429    npm: &mut Npm,
1430    name: &str,
1431    range: Option<&str>,
1432    dir: &Path,
1433) -> Result<Pinned, NpmError> {
1434    check_name(name)?;
1435    let manifest = npm.pick(name, range.unwrap_or("latest")).await?;
1436    let bytes = npm.tarball(&manifest).await?;
1437    let staging = stage(dir)?;
1438    let id = format!("{name}@{}", text(&manifest["version"]));
1439    swap(&staging, dir, unpack(&bytes, &staging, &id, npm.caps))?;
1440    Ok(Pinned::new(String::new(), &manifest, &bytes))
1441}
1442
1443/// Reinstalls the package `pin` records, as [`fetch`] returned it, in place
1444/// of `dir`, under the download rules of [`replay`]. Its `path` is ignored.
1445pub async fn fetch_pinned(npm: &Npm, pin: &Pinned, dir: &Path) -> Result<(), NpmError> {
1446    npm.check_pin(pin)?;
1447    let bytes = npm.pinned_tarball(pin).await?;
1448    let staging = stage(dir)?;
1449    let id = format!("{}@{}", pin.name, pin.version);
1450    swap(&staging, dir, unpack(&bytes, &staging, &id, npm.caps))
1451}
1452
1453/// Fails unless the tree `pins` records, installed in `root/node_modules`
1454/// and declared by `root/package.json`, is one that [`replay`] accepts once
1455/// unpacked, so that a lock never pins a tree its replays refuse.
1456pub(crate) fn check_installed(root: &Path, pins: &[Pinned]) -> Result<(), NpmError> {
1457    let pins: Vec<&Pinned> = pins.iter().collect();
1458    check_tree(root, &root.join("node_modules"), &pins)
1459}
1460
1461/// How many tarballs [`replay`] downloads at once.
1462const REPLAY_DOWNLOADS: usize = 8;
1463
1464/// Replaces `root/node_modules` with exactly the tree `pins` records, as
1465/// [`Installed::tree`] lists it, without packuments or range resolution.
1466///
1467/// Before any request, it refuses a pin whose path is not
1468/// `node_modules/<name>` repeated, whose path collides with another's once
1469/// case is folded, whose parent package is not pinned, whose integrity is
1470/// not exactly one sha512 digest, or whose tarball URL is plain http while
1471/// its registry is not. It also refuses a `root` or `root/node_modules`
1472/// that is a link. Each tarball is downloaded with the credentials of its
1473/// name's registry and checked against its digest, eight at a time, and
1474/// unpacked only once it passes. Then each package's `package.json` must
1475/// name its pin, each required dependency, of the packages and of
1476/// `root/package.json`, that reaches a pinned folder must be satisfied by
1477/// it, and each pinned folder must be reached by a dependency it
1478/// satisfies, so that a lock can neither put one package in another's
1479/// place nor add one. A tree with a package at its top therefore needs a
1480/// `root/package.json` that depends on it. The tree is built beside
1481/// `root/node_modules` and swapped in only once complete, so a failure
1482/// leaves `root` as it was. The caller holds the root's `lock_root`.
1483pub async fn replay(npm: &Npm, root: &Path, pins: &[Pinned]) -> Result<(), NpmError> {
1484    let mut pins = pins
1485        .iter()
1486        .map(|pin| {
1487            npm.check_pin(pin)?;
1488            let names = pin_names(&pin.path)?;
1489            let parent = (names.len() > 1).then(|| {
1490                let outer = &names[..names.len() - 1];
1491                let parts: Vec<String> =
1492                    outer.iter().map(|n| format!("node_modules/{n}")).collect();
1493                parts.join("/")
1494            });
1495            Ok((pin.path.to_ascii_lowercase(), parent, pin))
1496        })
1497        .collect::<Result<Vec<_>, NpmError>>()?;
1498    // Parents first, so that nested packages unpack into their parent's folder.
1499    pins.sort_by(|a, b| a.0.cmp(&b.0));
1500    if let Some(pair) = pins.windows(2).find(|pair| pair[0].0 == pair[1].0) {
1501        return Err(NpmError::Refused(format!(
1502            "Package paths collide: {} and {}",
1503            pair[0].2.path, pair[1].2.path
1504        )));
1505    }
1506    let paths: HashSet<&str> = pins.iter().map(|(_, _, pin)| pin.path.as_str()).collect();
1507    if let Some((_, Some(parent), pin)) = pins.iter().find(|(_, parent, _)| {
1508        parent
1509            .as_deref()
1510            .is_some_and(|parent| !paths.contains(parent))
1511    }) {
1512        return Err(NpmError::Refused(format!(
1513            "Package path {} has no pinned parent {parent}",
1514            pin.path
1515        )));
1516    }
1517    if is_link(root) {
1518        return Err(NpmError::Refused(format!(
1519            "Refusing to install through the link {}",
1520            root.display()
1521        )));
1522    }
1523    let modules = root.join("node_modules");
1524    let staging = stage(&modules)?;
1525    let built = async {
1526        // Downloads overlap, and each tarball unpacks in order once checked.
1527        // The futures are built first: a closure the stream held across
1528        // these awaits would keep the caller's future from being `Send`.
1529        let downloads: Vec<_> = pins
1530            .iter()
1531            .map(|(_, _, pin)| {
1532                let pin: &Pinned = pin;
1533                async move { (pin, npm.pinned_tarball(pin).await) }
1534            })
1535            .collect();
1536        let mut downloads = futures_util::stream::iter(downloads).buffered(REPLAY_DOWNLOADS);
1537        while let Some((pin, bytes)) = downloads.next().await {
1538            let bytes = bytes?;
1539            // `pin_names` checked that the path starts so, and `staging` stands for it.
1540            let relative = pin.path.strip_prefix("node_modules/").unwrap_or(&pin.path);
1541            let dir = staging.join(relative);
1542            // A bundled copy in the parent's tarball gives way to the pinned one.
1543            remove_path(&dir).map_err(io(&dir))?;
1544            std::fs::create_dir_all(&dir).map_err(io(&dir))?;
1545            let id = format!("{}@{}", pin.name, pin.version);
1546            unpack(&bytes, &dir, &id, npm.caps)?;
1547        }
1548        let pins: Vec<&Pinned> = pins.iter().map(|(_, _, pin)| *pin).collect();
1549        check_tree(root, &staging, &pins)
1550    }
1551    .await;
1552    swap(&staging, &modules, built)
1553}
1554
1555/// Removes `name` from the npm root `root`, then the packages nothing
1556/// depends on any more, `name` included unless another package needs it.
1557pub async fn uninstall(root: &Path, name: &str) -> Result<(), NpmError> {
1558    if !root.exists() {
1559        return Ok(());
1560    }
1561    let _lock = lock_root(root).await?;
1562    let mut manifest = root_manifest(root)?;
1563    if let Some(dependencies) = manifest
1564        .get_mut("dependencies")
1565        .and_then(Value::as_object_mut)
1566    {
1567        dependencies.shift_remove(name);
1568    }
1569    let roots: Vec<String> = manifest
1570        .get("dependencies")
1571        .and_then(Value::as_object)
1572        .map(|dependencies| dependencies.keys().cloned().collect())
1573        .unwrap_or_default();
1574    write_manifest(root, manifest)?;
1575    prune(&root.join("node_modules"), &roots);
1576    Ok(())
1577}
1578
1579/// Removes top-level packages in `modules` that no package in `roots`
1580/// reaches through Node's resolution.
1581fn prune(modules: &Path, roots: &[String]) {
1582    let mut reached: Vec<PathBuf> = Vec::new();
1583    let mut queue: Vec<PathBuf> = roots.iter().map(|name| modules.join(name)).collect();
1584    while let Some(dir) = queue.pop() {
1585        if reached.contains(&dir) || !dir.exists() {
1586            continue;
1587        }
1588        reached.push(dir.clone());
1589        let manifest = read_manifest(&dir).unwrap_or(Value::Null);
1590        for key in ["dependencies", "optionalDependencies"] {
1591            for name in manifest[key]
1592                .as_object()
1593                .into_iter()
1594                .flatten()
1595                .map(|(name, _)| name)
1596            {
1597                let resolved = lookup_path(&dir, modules)
1598                    .map(|modules| modules.join(name))
1599                    .find(|path| path.exists());
1600                queue.extend(resolved);
1601            }
1602        }
1603    }
1604    let Ok(entries) = std::fs::read_dir(modules) else {
1605        return;
1606    };
1607    for entry in entries.flatten() {
1608        let path = entry.path();
1609        let name = entry.file_name().to_string_lossy().into_owned();
1610        let candidates: Vec<PathBuf> = if name.starts_with('@') {
1611            std::fs::read_dir(&path)
1612                .map(|scoped| scoped.flatten().map(|entry| entry.path()).collect())
1613                .unwrap_or_default()
1614        } else if name.starts_with('.') {
1615            Vec::new()
1616        } else {
1617            vec![path]
1618        };
1619        for candidate in candidates {
1620            if !reached.contains(&candidate) {
1621                let _ = std::fs::remove_dir_all(&candidate);
1622            }
1623        }
1624    }
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629    use super::*;
1630
1631    #[test]
1632    fn compares_versions_like_semver() {
1633        assert_eq!(newer("1.2.0", "1.0.0"), Some(true));
1634        assert_eq!(newer(" 1.0.0\n", "1.0.0"), Some(false));
1635        assert_eq!(newer("1.0.0-rc.1", "1.0.0"), Some(false));
1636        // `gt` throws, and pi's update goes ahead while its check reports none.
1637        assert_eq!(newer("1.0.0", "next"), None);
1638    }
1639
1640    #[test]
1641    fn reads_npm_view_output() {
1642        assert_eq!(
1643            viewed_version("\"2.0.0\"\n", Some("^1")).as_deref(),
1644            Some("2.0.0")
1645        );
1646        let several = r#"["1.0.0", "1.5.0", "2.0.0", ""]"#;
1647        assert_eq!(
1648            viewed_version(several, Some("^1")).as_deref(),
1649            Some("1.5.0")
1650        );
1651        assert_eq!(viewed_version(several, None).as_deref(), Some("2.0.0"));
1652        assert_eq!(viewed_version(several, Some("^3")), None);
1653        assert_eq!(viewed_version("", None), None);
1654    }
1655
1656    #[test]
1657    fn matches_npm_ranges() {
1658        assert!(satisfies("1.4.0", "^1.2.3"));
1659        assert!(!satisfies("2.0.0", "^1.2.3"));
1660        assert!(satisfies("1.2.9", "~1.2.0"));
1661        assert!(satisfies("1.5.0", ">=1.2 <2"));
1662        assert!(satisfies("3.0.0", "^1 || ^3"));
1663        assert!(satisfies("1.9.9", "1.x"));
1664        assert!(satisfies("0.1.0", "*"));
1665        assert!(satisfies("1.3.0", "1.2.0 - 1.4.0"));
1666        assert!(!satisfies("1.0.0-beta.1", "^1.0.0"));
1667        // A bare version is exact, and a partial one fixes what it names.
1668        assert!(satisfies("0.35.0", "0.35.0"));
1669        assert!(!satisfies("0.35.1", "0.35.0"));
1670        assert!(!satisfies("1.3.0", "1.2.3"));
1671        assert!(satisfies("1.2.9", "1.2"));
1672        assert!(!satisfies("1.3.0", "1.2"));
1673        assert!(satisfies("1.9.0", "1"));
1674        assert!(!satisfies("2.0.0", "1"));
1675        assert!(satisfies("1.2.3", "1.2.3 || 2"));
1676        assert!(!satisfies("1.2.4", "1.2.3 || 2"));
1677        assert!(satisfies("1.2.9", "1.2.x"));
1678        assert!(!satisfies("1.3.0", "1.2.x"));
1679    }
1680
1681    #[test]
1682    fn npmrc_environment_references() {
1683        let env = |name: &str| (name == "TOKEN").then(|| "secret".to_owned());
1684        let config = parse_config(
1685            "; comment\n\
1686             # comment\n\
1687             registry = https://registry.example/ \n\
1688             @acme:registry=\"https://npm.acme.test/\"\n\
1689             //npm.acme.test/:_authToken=${TOKEN}\n\
1690             //other.test/:_authToken=${MISSING}\n\
1691             //optional.test/:_authToken=${MISSING?}\n\
1692             //token.test/:_auth=dXNlcjpwYXNz ; comment\n\
1693             [section]\n\
1694             ignored=1\n",
1695            env,
1696        );
1697        let mut entries: Vec<_> = config.iter().collect();
1698        entries.sort();
1699        assert_eq!(
1700            entries,
1701            [
1702                (
1703                    &"//npm.acme.test/:_authToken".to_owned(),
1704                    &"secret".to_owned()
1705                ),
1706                (&"//other.test/:_authToken".into(), &"${MISSING}".into()),
1707                (&"//token.test/:_auth".into(), &"dXNlcjpwYXNz".into()),
1708                (&"@acme:registry".into(), &"https://npm.acme.test/".into()),
1709                (&"registry".into(), &"https://registry.example/".into()),
1710            ]
1711        );
1712        assert_eq!(
1713            expand("a${TOKEN}b${${TOKEN}}${}", env),
1714            "asecretb${secret}${}"
1715        );
1716    }
1717
1718    #[test]
1719    fn credentials_scoped_to_url() {
1720        let npm = Npm::new(HashMap::from(
1721            [
1722                ("@acme:registry", "https://npm.acme.test"),
1723                ("//npm.acme.test/:_authToken", "acme"),
1724                ("//npm.acme.test/private/:_authToken", "private"),
1725                ("//basic.test/:username", "user"),
1726                ("//basic.test/:_password", "cGFzcw=="),
1727                ("//legacy.test:8080/:_auth", "dXNlcjpwYXNz"),
1728                ("//gitlab.test/api/v4/packages/npm/:_authToken", "gitlab"),
1729            ]
1730            .map(|(key, value)| (key.to_owned(), value.to_owned())),
1731        ));
1732        assert_eq!(npm.registry("@acme/pkg"), "https://npm.acme.test/");
1733        assert_eq!(npm.registry("pkg"), "https://registry.npmjs.org/");
1734        let auth = |url: &str, registry: &str| npm.authorization(url, registry);
1735        let acme = "https://npm.acme.test/";
1736        assert_eq!(
1737            auth("https://npm.acme.test/@acme%2fpkg", acme).unwrap(),
1738            "Bearer acme"
1739        );
1740        assert_eq!(
1741            auth("https://npm.acme.test/private/pkg.tgz", acme).unwrap(),
1742            "Bearer private"
1743        );
1744        assert_eq!(
1745            auth("https://npm.acme.test/privateer/pkg.tgz", acme).unwrap(),
1746            "Bearer acme"
1747        );
1748        assert_eq!(
1749            auth("https://basic.test/pkg", "https://basic.test/").unwrap(),
1750            format!("Basic {}", STANDARD.encode("user:pass"))
1751        );
1752        assert_eq!(
1753            auth("http://legacy.test:8080/pkg", acme).unwrap(),
1754            "Basic dXNlcjpwYXNz"
1755        );
1756        // A tarball elsewhere on the registry's host gets the registry's.
1757        let gitlab = "https://gitlab.test/api/v4/packages/npm/";
1758        assert_eq!(
1759            auth(
1760                "https://gitlab.test/api/v4/projects/7/packages/npm/pkg.tgz",
1761                gitlab
1762            )
1763            .unwrap(),
1764            "Bearer gitlab"
1765        );
1766        // Other hosts and ports get nothing, tarballs included.
1767        assert_eq!(auth("http://legacy.test/pkg", acme), None);
1768        assert_eq!(auth("https://npm.acme.test.evil/pkg", acme), None);
1769        assert_eq!(auth("https://cdn.acme.test/pkg.tgz", acme), None);
1770        assert_eq!(
1771            auth(
1772                "https://registry.npmjs.org/pkg",
1773                "https://registry.npmjs.org/"
1774            ),
1775            None
1776        );
1777    }
1778
1779    #[tokio::test]
1780    async fn registry_timeout() {
1781        assert_eq!(Npm::new(HashMap::new()).timeout(), Some(FETCH_TIMEOUT));
1782        let unlimited = HashMap::from([("fetch-timeout".to_owned(), "0".to_owned())]);
1783        assert_eq!(Npm::new(unlimited).timeout(), None);
1784        // A registry that takes the connection and never answers.
1785        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1786        let registry = format!("http://{}/", listener.local_addr().unwrap());
1787        let mut npm = Npm::new(HashMap::from([
1788            ("registry".to_owned(), registry.clone()),
1789            ("fetch-timeout".to_owned(), "200".to_owned()),
1790        ]));
1791        let pick = npm.pick("pkg", "latest");
1792        let error = tokio::time::timeout(std::time::Duration::from_secs(10), pick)
1793            .await
1794            .expect("the request never timed out")
1795            .unwrap_err();
1796        assert_eq!(
1797            error.to_string(),
1798            format!("network timeout at: {registry}pkg")
1799        );
1800    }
1801
1802    #[test]
1803    fn reads_manifests_with_bom() {
1804        let dir = std::env::temp_dir().join(format!("yapi-npm-bom-{}", std::process::id()));
1805        std::fs::create_dir_all(&dir).unwrap();
1806        std::fs::write(dir.join("package.json"), "\u{feff}{\"version\": \"1.2.3\"}").unwrap();
1807        assert_eq!(installed_version(&dir).as_deref(), Some("1.2.3"));
1808        std::fs::remove_dir_all(&dir).unwrap();
1809    }
1810
1811    /// Entries that climb out of the package, absolute paths and links stay
1812    /// out of the folder, and nothing is written beside it. Files keep their
1813    /// bytes and executable bits, however large.
1814    #[test]
1815    fn unpack_stays_inside() {
1816        use tar::EntryType;
1817        let mut builder = tar::Builder::new(Vec::new());
1818        let mut add = |name: &str, kind: EntryType, link: &str, data: &[u8], mode: u32| {
1819            // Raw names: `set_path` refuses `..`.
1820            let mut header = tar::Header::new_gnu();
1821            let raw = header.as_old_mut();
1822            raw.name[..name.len()].copy_from_slice(name.as_bytes());
1823            raw.linkname[..link.len()].copy_from_slice(link.as_bytes());
1824            header.set_entry_type(kind);
1825            header.set_size(data.len() as u64);
1826            header.set_mode(mode);
1827            header.set_cksum();
1828            builder.append(&header, data).unwrap();
1829        };
1830        // Larger than the buffers a streamed copy goes through.
1831        let large: Vec<u8> = (0..300_000u32).map(|n| (n % 251) as u8).collect();
1832        add("package/index.js", EntryType::Regular, "", b"ok", 0o644);
1833        add("package/run", EntryType::Regular, "", &large, 0o755);
1834        add("package/../evil", EntryType::Regular, "", b"evil", 0o644);
1835        add("/abs/evil", EntryType::Regular, "", b"abs", 0o644);
1836        add("package/link", EntryType::Symlink, "/etc", b"", 0o644);
1837        add(
1838            "package/hard",
1839            EntryType::Link,
1840            "package/index.js",
1841            b"",
1842            0o644,
1843        );
1844        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1845        std::io::Write::write_all(&mut gz, &builder.into_inner().unwrap()).unwrap();
1846        let bytes = gz.finish().unwrap();
1847
1848        let parent = std::env::temp_dir().join(format!("yapi-npm-unpack-{}", std::process::id()));
1849        let _ = std::fs::remove_dir_all(&parent);
1850        let dir = parent.join("package");
1851        std::fs::create_dir_all(&dir).unwrap();
1852        unpack(&bytes, &dir, "pkg@1.0.0", CAPS).unwrap();
1853        let names = |dir: &Path| {
1854            let mut names: Vec<String> = std::fs::read_dir(dir)
1855                .unwrap()
1856                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1857                .collect();
1858            names.sort();
1859            names
1860        };
1861        assert_eq!(names(&parent), ["package"]);
1862        // An absolute path loses its root with the first component.
1863        assert_eq!(names(&dir), ["abs", "index.js", "run"]);
1864        assert_eq!(std::fs::read(dir.join("index.js")).unwrap(), b"ok");
1865        assert_eq!(std::fs::read(dir.join("run")).unwrap(), large);
1866        #[cfg(unix)]
1867        {
1868            use std::os::unix::fs::PermissionsExt as _;
1869            let mode = |name: &str| {
1870                let metadata = std::fs::metadata(dir.join(name)).unwrap();
1871                metadata.permissions().mode() & 0o777
1872            };
1873            assert_eq!((mode("index.js"), mode("run")), (0o644, 0o755));
1874        }
1875        std::fs::remove_dir_all(&parent).unwrap();
1876    }
1877
1878    /// A tarball over any of the caps fails its replay, which then leaves
1879    /// the root as it was: one too large to download, one that unpacks to
1880    /// too much, and one with too many entries. A packument too large to
1881    /// download fails before it is parsed.
1882    #[tokio::test(flavor = "multi_thread")]
1883    async fn capped_tarballs_leave_nothing() {
1884        // Noise, so that the gzip stream stays as large as what it holds.
1885        let mut state = 0x2545_f491_4f6c_dd1d_u64;
1886        let noise: Vec<u8> = (0..2 << 20)
1887            .map(|_| {
1888                state ^= state << 13;
1889                state ^= state >> 7;
1890                state ^= state << 17;
1891                state.to_le_bytes()[0]
1892            })
1893            .collect();
1894        let mut builder = tar::Builder::new(Vec::new());
1895        for (name, data) in [
1896            (
1897                "package/package.json",
1898                &br#"{"name": "app", "version": "1.0.0"}"#[..],
1899            ),
1900            ("package/noise", &noise),
1901        ] {
1902            let mut header = tar::Header::new_gnu();
1903            header.set_size(data.len() as u64);
1904            header.set_mode(0o644);
1905            header.set_cksum();
1906            builder.append_data(&mut header, name, data).unwrap();
1907        }
1908        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1909        std::io::Write::write_all(&mut gz, &builder.into_inner().unwrap()).unwrap();
1910        let body = gz.finish().unwrap();
1911        assert!(body.len() > 1 << 20);
1912        let sha512 = STANDARD.encode(digest(&SHA512, &body));
1913        // Serves `body` at every path.
1914        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1915        let base = format!("http://{}", listener.local_addr().unwrap());
1916        std::thread::spawn(move || {
1917            use std::io::{BufRead as _, Write as _};
1918            for stream in listener.incoming() {
1919                let mut stream = stream.unwrap();
1920                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
1921                let mut line = String::new();
1922                while reader.read_line(&mut line).unwrap_or(0) > 2 {
1923                    line.clear();
1924                }
1925                let head = format!(
1926                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1927                    body.len()
1928                );
1929                let _ = stream.write_all(head.as_bytes());
1930                let _ = stream.write_all(&body);
1931            }
1932        });
1933        let pin = Pinned {
1934            path: "node_modules/app".into(),
1935            name: "app".into(),
1936            version: "1.0.0".into(),
1937            tarball: format!("{base}/app/-/app-1.0.0.tgz"),
1938            integrity: format!("sha512-{sha512}"),
1939        };
1940        let root = std::env::temp_dir().join(format!("yapi-npm-caps-{}", std::process::id()));
1941        let _ = std::fs::remove_dir_all(&root);
1942        std::fs::create_dir_all(root.join("node_modules/kept")).unwrap();
1943        let npm = Npm::new(HashMap::from([("registry".to_owned(), format!("{base}/"))]));
1944        let lowered = [
1945            (
1946                Caps {
1947                    download: 1 << 20,
1948                    ..CAPS
1949                },
1950                format!(
1951                    "The tarball of app at {} is over the download cap of 1 MiB",
1952                    pin.tarball
1953                ),
1954            ),
1955            (
1956                Caps {
1957                    unpacked: 1 << 20,
1958                    ..CAPS
1959                },
1960                "The tarball of app@1.0.0 unpacks to over the cap of 1 MiB".to_owned(),
1961            ),
1962            (
1963                Caps { entries: 1, ..CAPS },
1964                "The tarball of app@1.0.0 holds over the cap of 1 entries".to_owned(),
1965            ),
1966        ];
1967        for (caps, message) in lowered {
1968            let npm = Npm {
1969                caps,
1970                ..npm.clone()
1971            };
1972            let error = replay(&npm, &root, std::slice::from_ref(&pin))
1973                .await
1974                .unwrap_err();
1975            assert_eq!(error.to_string(), message);
1976            let names: Vec<_> = std::fs::read_dir(&root)
1977                .unwrap()
1978                .map(|entry| entry.unwrap().file_name())
1979                .collect();
1980            assert_eq!(names, ["node_modules"]);
1981            assert!(root.join("node_modules/kept").exists());
1982        }
1983        let mut npm = Npm {
1984            caps: Caps {
1985                packument: 1 << 20,
1986                ..CAPS
1987            },
1988            ..npm
1989        };
1990        let error = npm.pick("app", "latest").await.unwrap_err();
1991        assert_eq!(
1992            error.to_string(),
1993            format!("The packument of app at {base}/app is over the download cap of 1 MiB")
1994        );
1995        std::fs::remove_dir_all(&root).unwrap();
1996    }
1997
1998    /// When the new tree cannot take its place and the old one cannot go
1999    /// back, the old one stays where the error says.
2000    #[test]
2001    fn swap_keeps_old_tree() {
2002        let parent = std::env::temp_dir().join(format!("yapi-npm-swap-{}", std::process::id()));
2003        let _ = std::fs::remove_dir_all(&parent);
2004        let (dir, staging, old) = (
2005            parent.join("node_modules"),
2006            parent.join(".node_modules-staging"),
2007            parent.join(".node_modules-old"),
2008        );
2009        std::fs::create_dir_all(dir.join("a")).unwrap();
2010        std::fs::create_dir_all(staging.join("b")).unwrap();
2011        let rename = |from: &Path, to: &Path| {
2012            if from == staging || from == old {
2013                Err(std::io::Error::other("refused"))
2014            } else {
2015                std::fs::rename(from, to)
2016            }
2017        };
2018        let error = swap_with(&staging, &dir, rename).unwrap_err();
2019        assert!(
2020            error
2021                .to_string()
2022                .ends_with(&format!("the previous version is at {}", old.display())),
2023            "{error}"
2024        );
2025        assert!(old.join("a").exists());
2026        // A plain failure puts the old tree back.
2027        std::fs::rename(&old, &dir).unwrap();
2028        std::fs::create_dir_all(staging.join("b")).unwrap();
2029        let fail_new = |from: &Path, to: &Path| {
2030            if from == staging {
2031                Err(std::io::Error::other("refused"))
2032            } else {
2033                std::fs::rename(from, to)
2034            }
2035        };
2036        assert!(swap_with(&staging, &dir, fail_new).is_err());
2037        assert!(dir.join("a").exists() && !old.exists() && !staging.exists());
2038        std::fs::remove_dir_all(&parent).unwrap();
2039    }
2040
2041    #[test]
2042    fn ranges_allow_v_prefix() {
2043        assert!(satisfies("1.2.3", "v1.2.3"));
2044        assert!(!satisfies("1.2.4", "v1.2.3"));
2045        assert!(satisfies("1.4.0", "^v1.2.3"));
2046        assert!(satisfies("1.2.9", "~v1.2.0"));
2047        assert!(satisfies("1.5.0", ">=v1.2 <v2"));
2048        assert!(satisfies("1.5.0", ">= v1.2 < v2"));
2049        assert!(satisfies("1.3.0", "v1.2.0 - v1.4.0"));
2050        assert!(satisfies("2.0.0", "v1 || v2.x"));
2051    }
2052
2053    #[test]
2054    fn verifies_strongest_integrity() {
2055        let bytes = b"tarball";
2056        let sha512 = format!("sha512-{}", STANDARD.encode(digest(&SHA512, bytes)));
2057        let sha1 = format!("sha1-{}", STANDARD.encode(digest(&SHA1, bytes)));
2058        let shasum = crate::time::hex(digest(&SHA1, bytes).as_ref());
2059        let wrong512 = format!("sha512-{}", STANDARD.encode(digest(&SHA512, b"other")));
2060        let check = |dist: Value| verify(bytes, &dist);
2061        assert!(check(json!({"integrity": sha512})));
2062        assert!(check(json!({"integrity": sha1})));
2063        assert!(!check(json!({"integrity": "sha1-AAAA"})));
2064        // sha512 decides when present, as the strongest algorithm.
2065        assert!(!check(json!({"integrity": format!("{wrong512} {sha1}")})));
2066        assert!(check(json!({"integrity": format!("{wrong512} {sha512}")})));
2067        // An integrity without a known algorithm falls back to `shasum`.
2068        assert!(check(json!({"integrity": "sha384-AAAA", "shasum": shasum})));
2069        assert!(check(json!({"shasum": shasum.to_uppercase()})));
2070        assert!(!check(json!({"integrity": "sha384-AAAA"})));
2071        assert!(!check(json!({})));
2072    }
2073}