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.
92pub fn 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.
968pub(crate) const 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    lock(&root.join("package.json")).await
1334}
1335
1336/// Locks `path`, whose folder exists, against other processes until the
1337/// lock is dropped, as [`lock_root`] does. The lock is a folder beside it.
1338pub(crate) async fn lock(path: &Path) -> Result<FileLock, NpmError> {
1339    loop {
1340        match FileLock::acquire(path, &CancellationToken::new()).await {
1341            // A lock still held after the stale period is fresh: its holder is alive.
1342            Err(LockError::Held) => continue,
1343            result => return result.map_err(|err| io(path)(std::io::Error::other(err))),
1344        }
1345    }
1346}
1347
1348/// What [`install`] installed.
1349#[derive(Clone, Debug, PartialEq, Eq)]
1350pub struct Installed {
1351    /// The version of the requested package.
1352    pub version: String,
1353    /// A pin for each package the call unpacked, the requested one included,
1354    /// in no particular order. Packages it found already installed are not
1355    /// listed, so the tree is complete only when the root started empty: a
1356    /// tree recorded over a non-empty root must not be locked.
1357    pub tree: Vec<Pinned>,
1358}
1359
1360/// Installs `name` (at `range`, else the latest version) into the npm root
1361/// `root`, and records it in the root's `package.json`. The caller holds the
1362/// root's `lock_root`.
1363pub async fn install(
1364    npm: &mut Npm,
1365    root: &Path,
1366    name: &str,
1367    range: Option<&str>,
1368) -> Result<Installed, NpmError> {
1369    check_name(name)?;
1370    let mut manifest_file = root_manifest(root)?;
1371    let manifest = npm.pick(name, range.unwrap_or("latest")).await?;
1372    let version = text(&manifest["version"]);
1373    let modules = root.join("node_modules");
1374    let mut installer = Installer {
1375        npm,
1376        root_modules: modules.clone(),
1377        installing: Vec::new(),
1378        tree: Vec::new(),
1379    };
1380    installer
1381        .unpack_package(modules.join(name), manifest)
1382        .await?;
1383    let tree = installer.tree;
1384    let dependencies = manifest_file
1385        .entry("dependencies")
1386        .or_insert_with(|| Value::Object(Map::new()));
1387    if let Some(dependencies) = dependencies.as_object_mut() {
1388        let spec = range.map_or_else(|| format!("^{version}"), str::to_owned);
1389        dependencies.insert(name.to_owned(), Value::String(spec));
1390    }
1391    write_manifest(root, manifest_file)?;
1392    Ok(Installed { version, tree })
1393}
1394
1395/// Installs the dependencies of the package in `dir` (a git checkout) into
1396/// its own `node_modules`, and returns a pin for each package it unpacked,
1397/// with paths below `dir`, as [`Installed::tree`] lists them. Fails when the
1398/// checkout links `node_modules` or a scope folder in it elsewhere.
1399pub async fn install_dependencies(npm: &mut Npm, dir: &Path) -> Result<Vec<Pinned>, NpmError> {
1400    let Some(manifest) = read_manifest(dir) else {
1401        return Ok(Vec::new());
1402    };
1403    let modules = dir.join("node_modules");
1404    let scopes = std::fs::read_dir(&modules)
1405        .into_iter()
1406        .flatten()
1407        .flatten()
1408        .filter(|entry| entry.file_name().to_string_lossy().starts_with('@'))
1409        .map(|entry| entry.path());
1410    let is_link = |path: &PathBuf| {
1411        path.symlink_metadata()
1412            .is_ok_and(|metadata| metadata.file_type().is_symlink())
1413    };
1414    if let Some(link) = std::iter::once(modules.clone()).chain(scopes).find(is_link) {
1415        return Err(NpmError::Refused(format!(
1416            "Refusing to install dependencies through the link {}",
1417            link.display()
1418        )));
1419    }
1420    let mut installer = Installer {
1421        npm,
1422        root_modules: modules,
1423        installing: Vec::new(),
1424        tree: Vec::new(),
1425    };
1426    installer.dependencies(dir, &manifest).await?;
1427    Ok(installer.tree)
1428}
1429
1430/// Downloads `name` (at `range`, else the latest version), verifies its
1431/// tarball and unpacks it in place of `dir`, without its dependencies.
1432/// Returns its pin, whose `path` is empty: the package is `dir` itself.
1433pub async fn fetch(
1434    npm: &mut Npm,
1435    name: &str,
1436    range: Option<&str>,
1437    dir: &Path,
1438) -> Result<Pinned, NpmError> {
1439    check_name(name)?;
1440    let manifest = npm.pick(name, range.unwrap_or("latest")).await?;
1441    let bytes = npm.tarball(&manifest).await?;
1442    let staging = stage(dir)?;
1443    let id = format!("{name}@{}", text(&manifest["version"]));
1444    swap(&staging, dir, unpack(&bytes, &staging, &id, npm.caps))?;
1445    Ok(Pinned::new(String::new(), &manifest, &bytes))
1446}
1447
1448/// Reinstalls the package `pin` records, as [`fetch`] returned it, in place
1449/// of `dir`, under the download rules of [`replay`]. Its `path` is ignored.
1450pub async fn fetch_pinned(npm: &Npm, pin: &Pinned, dir: &Path) -> Result<(), NpmError> {
1451    npm.check_pin(pin)?;
1452    let bytes = npm.pinned_tarball(pin).await?;
1453    let staging = stage(dir)?;
1454    let id = format!("{}@{}", pin.name, pin.version);
1455    swap(&staging, dir, unpack(&bytes, &staging, &id, npm.caps))
1456}
1457
1458/// Fails unless the tree `pins` records, installed in `root/node_modules`
1459/// and declared by `root/package.json`, is one that [`replay`] accepts once
1460/// unpacked, so that a lock never pins a tree its replays refuse.
1461pub(crate) fn check_installed(root: &Path, pins: &[Pinned]) -> Result<(), NpmError> {
1462    let pins: Vec<&Pinned> = pins.iter().collect();
1463    check_tree(root, &root.join("node_modules"), &pins)
1464}
1465
1466/// How many tarballs [`replay`] downloads at once.
1467const REPLAY_DOWNLOADS: usize = 8;
1468
1469/// Replaces `root/node_modules` with exactly the tree `pins` records, as
1470/// [`Installed::tree`] lists it, without packuments or range resolution.
1471///
1472/// Before any request, it refuses a pin whose path is not
1473/// `node_modules/<name>` repeated, whose path collides with another's once
1474/// case is folded, whose parent package is not pinned, whose integrity is
1475/// not exactly one sha512 digest, or whose tarball URL is plain http while
1476/// its registry is not. It also refuses a `root` or `root/node_modules`
1477/// that is a link. Each tarball is downloaded with the credentials of its
1478/// name's registry and checked against its digest, eight at a time, and
1479/// unpacked only once it passes. Then each package's `package.json` must
1480/// name its pin, each required dependency, of the packages and of
1481/// `root/package.json`, that reaches a pinned folder must be satisfied by
1482/// it, and each pinned folder must be reached by a dependency it
1483/// satisfies, so that a lock can neither put one package in another's
1484/// place nor add one. A tree with a package at its top therefore needs a
1485/// `root/package.json` that depends on it. The tree is built beside
1486/// `root/node_modules` and swapped in only once complete, so a failure
1487/// leaves `root` as it was. The caller holds the root's `lock_root`.
1488pub async fn replay(npm: &Npm, root: &Path, pins: &[Pinned]) -> Result<(), NpmError> {
1489    let mut pins = pins
1490        .iter()
1491        .map(|pin| {
1492            npm.check_pin(pin)?;
1493            let names = pin_names(&pin.path)?;
1494            let parent = (names.len() > 1).then(|| {
1495                let outer = &names[..names.len() - 1];
1496                let parts: Vec<String> =
1497                    outer.iter().map(|n| format!("node_modules/{n}")).collect();
1498                parts.join("/")
1499            });
1500            Ok((pin.path.to_ascii_lowercase(), parent, pin))
1501        })
1502        .collect::<Result<Vec<_>, NpmError>>()?;
1503    // Parents first, so that nested packages unpack into their parent's folder.
1504    pins.sort_by(|a, b| a.0.cmp(&b.0));
1505    if let Some(pair) = pins.windows(2).find(|pair| pair[0].0 == pair[1].0) {
1506        return Err(NpmError::Refused(format!(
1507            "Package paths collide: {} and {}",
1508            pair[0].2.path, pair[1].2.path
1509        )));
1510    }
1511    let paths: HashSet<&str> = pins.iter().map(|(_, _, pin)| pin.path.as_str()).collect();
1512    if let Some((_, Some(parent), pin)) = pins.iter().find(|(_, parent, _)| {
1513        parent
1514            .as_deref()
1515            .is_some_and(|parent| !paths.contains(parent))
1516    }) {
1517        return Err(NpmError::Refused(format!(
1518            "Package path {} has no pinned parent {parent}",
1519            pin.path
1520        )));
1521    }
1522    if is_link(root) {
1523        return Err(NpmError::Refused(format!(
1524            "Refusing to install through the link {}",
1525            root.display()
1526        )));
1527    }
1528    let modules = root.join("node_modules");
1529    let staging = stage(&modules)?;
1530    let built = async {
1531        // Downloads overlap, and each tarball unpacks in order once checked.
1532        // The futures are built first: a closure the stream held across
1533        // these awaits would keep the caller's future from being `Send`.
1534        let downloads: Vec<_> = pins
1535            .iter()
1536            .map(|(_, _, pin)| {
1537                let pin: &Pinned = pin;
1538                async move { (pin, npm.pinned_tarball(pin).await) }
1539            })
1540            .collect();
1541        let mut downloads = futures_util::stream::iter(downloads).buffered(REPLAY_DOWNLOADS);
1542        while let Some((pin, bytes)) = downloads.next().await {
1543            let bytes = bytes?;
1544            // `pin_names` checked that the path starts so, and `staging` stands for it.
1545            let relative = pin.path.strip_prefix("node_modules/").unwrap_or(&pin.path);
1546            let dir = staging.join(relative);
1547            // A bundled copy in the parent's tarball gives way to the pinned one.
1548            remove_path(&dir).map_err(io(&dir))?;
1549            std::fs::create_dir_all(&dir).map_err(io(&dir))?;
1550            let id = format!("{}@{}", pin.name, pin.version);
1551            unpack(&bytes, &dir, &id, npm.caps)?;
1552        }
1553        let pins: Vec<&Pinned> = pins.iter().map(|(_, _, pin)| *pin).collect();
1554        check_tree(root, &staging, &pins)
1555    }
1556    .await;
1557    swap(&staging, &modules, built)
1558}
1559
1560/// Removes `name` from the npm root `root`, then the packages nothing
1561/// depends on any more, `name` included unless another package needs it.
1562pub async fn uninstall(root: &Path, name: &str) -> Result<(), NpmError> {
1563    if !root.exists() {
1564        return Ok(());
1565    }
1566    let _lock = lock_root(root).await?;
1567    let mut manifest = root_manifest(root)?;
1568    if let Some(dependencies) = manifest
1569        .get_mut("dependencies")
1570        .and_then(Value::as_object_mut)
1571    {
1572        dependencies.shift_remove(name);
1573    }
1574    let roots: Vec<String> = manifest
1575        .get("dependencies")
1576        .and_then(Value::as_object)
1577        .map(|dependencies| dependencies.keys().cloned().collect())
1578        .unwrap_or_default();
1579    write_manifest(root, manifest)?;
1580    prune(&root.join("node_modules"), &roots);
1581    Ok(())
1582}
1583
1584/// Removes top-level packages in `modules` that no package in `roots`
1585/// reaches through Node's resolution.
1586fn prune(modules: &Path, roots: &[String]) {
1587    let mut reached: Vec<PathBuf> = Vec::new();
1588    let mut queue: Vec<PathBuf> = roots.iter().map(|name| modules.join(name)).collect();
1589    while let Some(dir) = queue.pop() {
1590        if reached.contains(&dir) || !dir.exists() {
1591            continue;
1592        }
1593        reached.push(dir.clone());
1594        let manifest = read_manifest(&dir).unwrap_or(Value::Null);
1595        for key in ["dependencies", "optionalDependencies"] {
1596            for name in manifest[key]
1597                .as_object()
1598                .into_iter()
1599                .flatten()
1600                .map(|(name, _)| name)
1601            {
1602                let resolved = lookup_path(&dir, modules)
1603                    .map(|modules| modules.join(name))
1604                    .find(|path| path.exists());
1605                queue.extend(resolved);
1606            }
1607        }
1608    }
1609    let Ok(entries) = std::fs::read_dir(modules) else {
1610        return;
1611    };
1612    for entry in entries.flatten() {
1613        let path = entry.path();
1614        let name = entry.file_name().to_string_lossy().into_owned();
1615        let candidates: Vec<PathBuf> = if name.starts_with('@') {
1616            std::fs::read_dir(&path)
1617                .map(|scoped| scoped.flatten().map(|entry| entry.path()).collect())
1618                .unwrap_or_default()
1619        } else if name.starts_with('.') {
1620            Vec::new()
1621        } else {
1622            vec![path]
1623        };
1624        for candidate in candidates {
1625            if !reached.contains(&candidate) {
1626                let _ = std::fs::remove_dir_all(&candidate);
1627            }
1628        }
1629    }
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634    use super::*;
1635
1636    #[test]
1637    fn compares_versions_like_semver() {
1638        assert_eq!(newer("1.2.0", "1.0.0"), Some(true));
1639        assert_eq!(newer(" 1.0.0\n", "1.0.0"), Some(false));
1640        assert_eq!(newer("1.0.0-rc.1", "1.0.0"), Some(false));
1641        // `gt` throws, and pi's update goes ahead while its check reports none.
1642        assert_eq!(newer("1.0.0", "next"), None);
1643    }
1644
1645    #[test]
1646    fn reads_npm_view_output() {
1647        assert_eq!(
1648            viewed_version("\"2.0.0\"\n", Some("^1")).as_deref(),
1649            Some("2.0.0")
1650        );
1651        let several = r#"["1.0.0", "1.5.0", "2.0.0", ""]"#;
1652        assert_eq!(
1653            viewed_version(several, Some("^1")).as_deref(),
1654            Some("1.5.0")
1655        );
1656        assert_eq!(viewed_version(several, None).as_deref(), Some("2.0.0"));
1657        assert_eq!(viewed_version(several, Some("^3")), None);
1658        assert_eq!(viewed_version("", None), None);
1659    }
1660
1661    #[test]
1662    fn matches_npm_ranges() {
1663        assert!(satisfies("1.4.0", "^1.2.3"));
1664        assert!(!satisfies("2.0.0", "^1.2.3"));
1665        assert!(satisfies("1.2.9", "~1.2.0"));
1666        assert!(satisfies("1.5.0", ">=1.2 <2"));
1667        assert!(satisfies("3.0.0", "^1 || ^3"));
1668        assert!(satisfies("1.9.9", "1.x"));
1669        assert!(satisfies("0.1.0", "*"));
1670        assert!(satisfies("1.3.0", "1.2.0 - 1.4.0"));
1671        assert!(!satisfies("1.0.0-beta.1", "^1.0.0"));
1672        // A bare version is exact, and a partial one fixes what it names.
1673        assert!(satisfies("0.35.0", "0.35.0"));
1674        assert!(!satisfies("0.35.1", "0.35.0"));
1675        assert!(!satisfies("1.3.0", "1.2.3"));
1676        assert!(satisfies("1.2.9", "1.2"));
1677        assert!(!satisfies("1.3.0", "1.2"));
1678        assert!(satisfies("1.9.0", "1"));
1679        assert!(!satisfies("2.0.0", "1"));
1680        assert!(satisfies("1.2.3", "1.2.3 || 2"));
1681        assert!(!satisfies("1.2.4", "1.2.3 || 2"));
1682        assert!(satisfies("1.2.9", "1.2.x"));
1683        assert!(!satisfies("1.3.0", "1.2.x"));
1684    }
1685
1686    #[test]
1687    fn npmrc_environment_references() {
1688        let env = |name: &str| (name == "TOKEN").then(|| "secret".to_owned());
1689        let config = parse_config(
1690            "; comment\n\
1691             # comment\n\
1692             registry = https://registry.example/ \n\
1693             @acme:registry=\"https://npm.acme.test/\"\n\
1694             //npm.acme.test/:_authToken=${TOKEN}\n\
1695             //other.test/:_authToken=${MISSING}\n\
1696             //optional.test/:_authToken=${MISSING?}\n\
1697             //token.test/:_auth=dXNlcjpwYXNz ; comment\n\
1698             [section]\n\
1699             ignored=1\n",
1700            env,
1701        );
1702        let mut entries: Vec<_> = config.iter().collect();
1703        entries.sort();
1704        assert_eq!(
1705            entries,
1706            [
1707                (
1708                    &"//npm.acme.test/:_authToken".to_owned(),
1709                    &"secret".to_owned()
1710                ),
1711                (&"//other.test/:_authToken".into(), &"${MISSING}".into()),
1712                (&"//token.test/:_auth".into(), &"dXNlcjpwYXNz".into()),
1713                (&"@acme:registry".into(), &"https://npm.acme.test/".into()),
1714                (&"registry".into(), &"https://registry.example/".into()),
1715            ]
1716        );
1717        assert_eq!(
1718            expand("a${TOKEN}b${${TOKEN}}${}", env),
1719            "asecretb${secret}${}"
1720        );
1721    }
1722
1723    #[test]
1724    fn credentials_scoped_to_url() {
1725        let npm = Npm::new(HashMap::from(
1726            [
1727                ("@acme:registry", "https://npm.acme.test"),
1728                ("//npm.acme.test/:_authToken", "acme"),
1729                ("//npm.acme.test/private/:_authToken", "private"),
1730                ("//basic.test/:username", "user"),
1731                ("//basic.test/:_password", "cGFzcw=="),
1732                ("//legacy.test:8080/:_auth", "dXNlcjpwYXNz"),
1733                ("//gitlab.test/api/v4/packages/npm/:_authToken", "gitlab"),
1734            ]
1735            .map(|(key, value)| (key.to_owned(), value.to_owned())),
1736        ));
1737        assert_eq!(npm.registry("@acme/pkg"), "https://npm.acme.test/");
1738        assert_eq!(npm.registry("pkg"), "https://registry.npmjs.org/");
1739        let auth = |url: &str, registry: &str| npm.authorization(url, registry);
1740        let acme = "https://npm.acme.test/";
1741        assert_eq!(
1742            auth("https://npm.acme.test/@acme%2fpkg", acme).unwrap(),
1743            "Bearer acme"
1744        );
1745        assert_eq!(
1746            auth("https://npm.acme.test/private/pkg.tgz", acme).unwrap(),
1747            "Bearer private"
1748        );
1749        assert_eq!(
1750            auth("https://npm.acme.test/privateer/pkg.tgz", acme).unwrap(),
1751            "Bearer acme"
1752        );
1753        assert_eq!(
1754            auth("https://basic.test/pkg", "https://basic.test/").unwrap(),
1755            format!("Basic {}", STANDARD.encode("user:pass"))
1756        );
1757        assert_eq!(
1758            auth("http://legacy.test:8080/pkg", acme).unwrap(),
1759            "Basic dXNlcjpwYXNz"
1760        );
1761        // A tarball elsewhere on the registry's host gets the registry's.
1762        let gitlab = "https://gitlab.test/api/v4/packages/npm/";
1763        assert_eq!(
1764            auth(
1765                "https://gitlab.test/api/v4/projects/7/packages/npm/pkg.tgz",
1766                gitlab
1767            )
1768            .unwrap(),
1769            "Bearer gitlab"
1770        );
1771        // Other hosts and ports get nothing, tarballs included.
1772        assert_eq!(auth("http://legacy.test/pkg", acme), None);
1773        assert_eq!(auth("https://npm.acme.test.evil/pkg", acme), None);
1774        assert_eq!(auth("https://cdn.acme.test/pkg.tgz", acme), None);
1775        assert_eq!(
1776            auth(
1777                "https://registry.npmjs.org/pkg",
1778                "https://registry.npmjs.org/"
1779            ),
1780            None
1781        );
1782    }
1783
1784    #[tokio::test]
1785    async fn registry_timeout() {
1786        assert_eq!(Npm::new(HashMap::new()).timeout(), Some(FETCH_TIMEOUT));
1787        let unlimited = HashMap::from([("fetch-timeout".to_owned(), "0".to_owned())]);
1788        assert_eq!(Npm::new(unlimited).timeout(), None);
1789        // A registry that takes the connection and never answers.
1790        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1791        let registry = format!("http://{}/", listener.local_addr().unwrap());
1792        let mut npm = Npm::new(HashMap::from([
1793            ("registry".to_owned(), registry.clone()),
1794            ("fetch-timeout".to_owned(), "200".to_owned()),
1795        ]));
1796        let pick = npm.pick("pkg", "latest");
1797        let error = tokio::time::timeout(std::time::Duration::from_secs(10), pick)
1798            .await
1799            .expect("the request never timed out")
1800            .unwrap_err();
1801        assert_eq!(
1802            error.to_string(),
1803            format!("network timeout at: {registry}pkg")
1804        );
1805    }
1806
1807    #[test]
1808    fn reads_manifests_with_bom() {
1809        let dir = std::env::temp_dir().join(format!("yapi-npm-bom-{}", std::process::id()));
1810        std::fs::create_dir_all(&dir).unwrap();
1811        std::fs::write(dir.join("package.json"), "\u{feff}{\"version\": \"1.2.3\"}").unwrap();
1812        assert_eq!(installed_version(&dir).as_deref(), Some("1.2.3"));
1813        std::fs::remove_dir_all(&dir).unwrap();
1814    }
1815
1816    /// Entries that climb out of the package, absolute paths and links stay
1817    /// out of the folder, and nothing is written beside it. Files keep their
1818    /// bytes and executable bits, however large.
1819    #[test]
1820    fn unpack_stays_inside() {
1821        use tar::EntryType;
1822        let mut builder = tar::Builder::new(Vec::new());
1823        let mut add = |name: &str, kind: EntryType, link: &str, data: &[u8], mode: u32| {
1824            // Raw names: `set_path` refuses `..`.
1825            let mut header = tar::Header::new_gnu();
1826            let raw = header.as_old_mut();
1827            raw.name[..name.len()].copy_from_slice(name.as_bytes());
1828            raw.linkname[..link.len()].copy_from_slice(link.as_bytes());
1829            header.set_entry_type(kind);
1830            header.set_size(data.len() as u64);
1831            header.set_mode(mode);
1832            header.set_cksum();
1833            builder.append(&header, data).unwrap();
1834        };
1835        // Larger than the buffers a streamed copy goes through.
1836        let large: Vec<u8> = (0..300_000u32).map(|n| (n % 251) as u8).collect();
1837        add("package/index.js", EntryType::Regular, "", b"ok", 0o644);
1838        add("package/run", EntryType::Regular, "", &large, 0o755);
1839        add("package/../evil", EntryType::Regular, "", b"evil", 0o644);
1840        add("/abs/evil", EntryType::Regular, "", b"abs", 0o644);
1841        add("package/link", EntryType::Symlink, "/etc", b"", 0o644);
1842        add(
1843            "package/hard",
1844            EntryType::Link,
1845            "package/index.js",
1846            b"",
1847            0o644,
1848        );
1849        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
1850        std::io::Write::write_all(&mut gz, &builder.into_inner().unwrap()).unwrap();
1851        let bytes = gz.finish().unwrap();
1852
1853        let parent = std::env::temp_dir().join(format!("yapi-npm-unpack-{}", std::process::id()));
1854        let _ = std::fs::remove_dir_all(&parent);
1855        let dir = parent.join("package");
1856        std::fs::create_dir_all(&dir).unwrap();
1857        unpack(&bytes, &dir, "pkg@1.0.0", CAPS).unwrap();
1858        let names = |dir: &Path| {
1859            let mut names: Vec<String> = std::fs::read_dir(dir)
1860                .unwrap()
1861                .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1862                .collect();
1863            names.sort();
1864            names
1865        };
1866        assert_eq!(names(&parent), ["package"]);
1867        // An absolute path loses its root with the first component.
1868        assert_eq!(names(&dir), ["abs", "index.js", "run"]);
1869        assert_eq!(std::fs::read(dir.join("index.js")).unwrap(), b"ok");
1870        assert_eq!(std::fs::read(dir.join("run")).unwrap(), large);
1871        #[cfg(unix)]
1872        {
1873            use std::os::unix::fs::PermissionsExt as _;
1874            let mode = |name: &str| {
1875                let metadata = std::fs::metadata(dir.join(name)).unwrap();
1876                metadata.permissions().mode() & 0o777
1877            };
1878            assert_eq!((mode("index.js"), mode("run")), (0o644, 0o755));
1879        }
1880        std::fs::remove_dir_all(&parent).unwrap();
1881    }
1882
1883    /// A tarball over any of the caps fails its replay, which then leaves
1884    /// the root as it was: one too large to download, one that unpacks to
1885    /// too much, and one with too many entries. A packument too large to
1886    /// download fails before it is parsed.
1887    #[tokio::test(flavor = "multi_thread")]
1888    async fn capped_tarballs_leave_nothing() {
1889        // Noise, so that the gzip stream stays as large as what it holds.
1890        let mut state = 0x2545_f491_4f6c_dd1d_u64;
1891        let noise: Vec<u8> = (0..2 << 20)
1892            .map(|_| {
1893                state ^= state << 13;
1894                state ^= state >> 7;
1895                state ^= state << 17;
1896                state.to_le_bytes()[0]
1897            })
1898            .collect();
1899        let mut builder = tar::Builder::new(Vec::new());
1900        for (name, data) in [
1901            (
1902                "package/package.json",
1903                &br#"{"name": "app", "version": "1.0.0"}"#[..],
1904            ),
1905            ("package/noise", &noise),
1906        ] {
1907            let mut header = tar::Header::new_gnu();
1908            header.set_size(data.len() as u64);
1909            header.set_mode(0o644);
1910            header.set_cksum();
1911            builder.append_data(&mut header, name, data).unwrap();
1912        }
1913        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1914        std::io::Write::write_all(&mut gz, &builder.into_inner().unwrap()).unwrap();
1915        let body = gz.finish().unwrap();
1916        assert!(body.len() > 1 << 20);
1917        let sha512 = STANDARD.encode(digest(&SHA512, &body));
1918        // Serves `body` at every path.
1919        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1920        let base = format!("http://{}", listener.local_addr().unwrap());
1921        std::thread::spawn(move || {
1922            use std::io::{BufRead as _, Write as _};
1923            for stream in listener.incoming() {
1924                let mut stream = stream.unwrap();
1925                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
1926                let mut line = String::new();
1927                while reader.read_line(&mut line).unwrap_or(0) > 2 {
1928                    line.clear();
1929                }
1930                let head = format!(
1931                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1932                    body.len()
1933                );
1934                let _ = stream.write_all(head.as_bytes());
1935                let _ = stream.write_all(&body);
1936            }
1937        });
1938        let pin = Pinned {
1939            path: "node_modules/app".into(),
1940            name: "app".into(),
1941            version: "1.0.0".into(),
1942            tarball: format!("{base}/app/-/app-1.0.0.tgz"),
1943            integrity: format!("sha512-{sha512}"),
1944        };
1945        let root = std::env::temp_dir().join(format!("yapi-npm-caps-{}", std::process::id()));
1946        let _ = std::fs::remove_dir_all(&root);
1947        std::fs::create_dir_all(root.join("node_modules/kept")).unwrap();
1948        let npm = Npm::new(HashMap::from([("registry".to_owned(), format!("{base}/"))]));
1949        let lowered = [
1950            (
1951                Caps {
1952                    download: 1 << 20,
1953                    ..CAPS
1954                },
1955                format!(
1956                    "The tarball of app at {} is over the download cap of 1 MiB",
1957                    pin.tarball
1958                ),
1959            ),
1960            (
1961                Caps {
1962                    unpacked: 1 << 20,
1963                    ..CAPS
1964                },
1965                "The tarball of app@1.0.0 unpacks to over the cap of 1 MiB".to_owned(),
1966            ),
1967            (
1968                Caps { entries: 1, ..CAPS },
1969                "The tarball of app@1.0.0 holds over the cap of 1 entries".to_owned(),
1970            ),
1971        ];
1972        for (caps, message) in lowered {
1973            let npm = Npm {
1974                caps,
1975                ..npm.clone()
1976            };
1977            let error = replay(&npm, &root, std::slice::from_ref(&pin))
1978                .await
1979                .unwrap_err();
1980            assert_eq!(error.to_string(), message);
1981            let names: Vec<_> = std::fs::read_dir(&root)
1982                .unwrap()
1983                .map(|entry| entry.unwrap().file_name())
1984                .collect();
1985            assert_eq!(names, ["node_modules"]);
1986            assert!(root.join("node_modules/kept").exists());
1987        }
1988        let mut npm = Npm {
1989            caps: Caps {
1990                packument: 1 << 20,
1991                ..CAPS
1992            },
1993            ..npm
1994        };
1995        let error = npm.pick("app", "latest").await.unwrap_err();
1996        assert_eq!(
1997            error.to_string(),
1998            format!("The packument of app at {base}/app is over the download cap of 1 MiB")
1999        );
2000        std::fs::remove_dir_all(&root).unwrap();
2001    }
2002
2003    /// When the new tree cannot take its place and the old one cannot go
2004    /// back, the old one stays where the error says.
2005    #[test]
2006    fn swap_keeps_old_tree() {
2007        let parent = std::env::temp_dir().join(format!("yapi-npm-swap-{}", std::process::id()));
2008        let _ = std::fs::remove_dir_all(&parent);
2009        let (dir, staging, old) = (
2010            parent.join("node_modules"),
2011            parent.join(".node_modules-staging"),
2012            parent.join(".node_modules-old"),
2013        );
2014        std::fs::create_dir_all(dir.join("a")).unwrap();
2015        std::fs::create_dir_all(staging.join("b")).unwrap();
2016        let rename = |from: &Path, to: &Path| {
2017            if from == staging || from == old {
2018                Err(std::io::Error::other("refused"))
2019            } else {
2020                std::fs::rename(from, to)
2021            }
2022        };
2023        let error = swap_with(&staging, &dir, rename).unwrap_err();
2024        assert!(
2025            error
2026                .to_string()
2027                .ends_with(&format!("the previous version is at {}", old.display())),
2028            "{error}"
2029        );
2030        assert!(old.join("a").exists());
2031        // A plain failure puts the old tree back.
2032        std::fs::rename(&old, &dir).unwrap();
2033        std::fs::create_dir_all(staging.join("b")).unwrap();
2034        let fail_new = |from: &Path, to: &Path| {
2035            if from == staging {
2036                Err(std::io::Error::other("refused"))
2037            } else {
2038                std::fs::rename(from, to)
2039            }
2040        };
2041        assert!(swap_with(&staging, &dir, fail_new).is_err());
2042        assert!(dir.join("a").exists() && !old.exists() && !staging.exists());
2043        std::fs::remove_dir_all(&parent).unwrap();
2044    }
2045
2046    #[test]
2047    fn ranges_allow_v_prefix() {
2048        assert!(satisfies("1.2.3", "v1.2.3"));
2049        assert!(!satisfies("1.2.4", "v1.2.3"));
2050        assert!(satisfies("1.4.0", "^v1.2.3"));
2051        assert!(satisfies("1.2.9", "~v1.2.0"));
2052        assert!(satisfies("1.5.0", ">=v1.2 <v2"));
2053        assert!(satisfies("1.5.0", ">= v1.2 < v2"));
2054        assert!(satisfies("1.3.0", "v1.2.0 - v1.4.0"));
2055        assert!(satisfies("2.0.0", "v1 || v2.x"));
2056    }
2057
2058    #[test]
2059    fn verifies_strongest_integrity() {
2060        let bytes = b"tarball";
2061        let sha512 = format!("sha512-{}", STANDARD.encode(digest(&SHA512, bytes)));
2062        let sha1 = format!("sha1-{}", STANDARD.encode(digest(&SHA1, bytes)));
2063        let shasum = crate::time::hex(digest(&SHA1, bytes).as_ref());
2064        let wrong512 = format!("sha512-{}", STANDARD.encode(digest(&SHA512, b"other")));
2065        let check = |dist: Value| verify(bytes, &dist);
2066        assert!(check(json!({"integrity": sha512})));
2067        assert!(check(json!({"integrity": sha1})));
2068        assert!(!check(json!({"integrity": "sha1-AAAA"})));
2069        // sha512 decides when present, as the strongest algorithm.
2070        assert!(!check(json!({"integrity": format!("{wrong512} {sha1}")})));
2071        assert!(check(json!({"integrity": format!("{wrong512} {sha512}")})));
2072        // An integrity without a known algorithm falls back to `shasum`.
2073        assert!(check(json!({"integrity": "sha384-AAAA", "shasum": shasum})));
2074        assert!(check(json!({"shasum": shasum.to_uppercase()})));
2075        assert!(!check(json!({"integrity": "sha384-AAAA"})));
2076        assert!(!check(json!({})));
2077    }
2078}