1use 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
21const 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#[derive(Debug, thiserror::Error)]
38pub enum NpmError {
39 #[error("{}", escaped(.0))]
41 Registry(String),
42 #[error("No version of {} matches {}", escaped(.name), escaped(.range))]
44 NoMatch {
45 name: String,
47 range: String,
49 },
50 #[error("Integrity check failed for {}", escaped(.0))]
52 Integrity(String),
53 #[error("{}", escaped(.0))]
55 Refused(String),
56 #[error("{path}: {source}")]
58 Io {
59 path: PathBuf,
61 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
73pub(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 '\\' | '\'' | '"' => escaped.push(c),
81 _ => escaped.extend(c.escape_debug()),
82 }
83 }
84 escaped
85}
86
87pub 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#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116pub struct Pinned {
117 pub path: String,
120 pub name: String,
123 pub version: String,
125 pub tarball: String,
127 pub integrity: String,
130}
131
132impl Pinned {
133 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
146pub(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
166fn 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
172pub(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
187pub(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 remove_path(&staging).map_err(io(&staging))?;
202 std::fs::create_dir(&staging).map_err(io(&staging))?;
203 Ok(staging)
204}
205
206pub(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
221fn 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
259pub 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
280fn 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 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
295fn 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 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
327fn 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
360fn 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
371fn 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 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 _ 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
414pub(crate) fn is_range(range: &str) -> bool {
416 requirements(range).is_some()
417}
418
419pub 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
428pub 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
437fn 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
459pub(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
478const PACKUMENT_ACCEPT: &str =
482 "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*";
483
484const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
486
487#[derive(Clone, Copy, Debug)]
490struct Caps {
491 packument: usize,
494 download: usize,
496 unpacked: u64,
498 entries: usize,
500}
501
502const CAPS: Caps = Caps {
504 packument: 256 << 20,
505 download: 512 << 20,
506 unpacked: 2 << 30,
507 entries: 200_000,
508};
509
510fn 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#[derive(Clone)]
521pub struct Npm {
522 config: HashMap<String, String>,
523 packuments: HashMap<String, Value>,
524 caps: Caps,
525}
526
527impl Npm {
528 pub fn new(config: HashMap<String, String>) -> Npm {
530 Npm {
531 config,
532 packuments: HashMap::new(),
533 caps: CAPS,
534 }
535 }
536
537 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 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 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(®istry) == host(&parsed));
579 if url != registry && same_host {
580 self.authorization(registry, registry)
581 } else {
582 None
583 }
584 }
585
586 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 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, ®istry).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 Ok(&self.packuments[name])
633 }
634
635 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 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 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 async fn download(&self, name: &str, url: &str) -> Result<Vec<u8>, NpmError> {
698 let registry = self.registry(name);
699 let response = self.get(url, "*/*", ®istry).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 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 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(®istry).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 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 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
797async 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
826fn 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
836fn 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
861struct 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
885fn 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 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
906fn 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 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
961fn read_manifest(dir: &Path) -> Option<Value> {
964 yapi_types::json::parse(&std::fs::read_to_string(dir.join("package.json")).ok()?).ok()
965}
966
967pub(crate) const INCOMPLETE: &str = ".yapi-incomplete";
969
970fn manifest_version(dir: &Path) -> Option<String> {
972 read_manifest(dir)?["version"].as_str().map(str::to_owned)
973}
974
975pub(crate) fn installed_version(dir: &Path) -> Option<String> {
978 if dir.join(INCOMPLETE).exists() {
979 return None;
980 }
981 manifest_version(dir)
982}
983
984fn 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
996fn 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
1014fn 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
1032fn 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
1049fn check_tree(root: &Path, staging: &Path, pins: &[&Pinned]) -> Result<(), NpmError> {
1060 let modules = Path::new("node_modules");
1061 let folder = |path: &Path| staging.join(path.strip_prefix(modules).unwrap_or(path));
1063 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 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 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 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
1127struct Installer<'a> {
1130 npm: &'a mut Npm,
1131 root_modules: PathBuf,
1132 installing: Vec<(PathBuf, Value)>,
1135 tree: Vec<Pinned>,
1137}
1138
1139impl Installer<'_> {
1140 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 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 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 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 for installed in self.tree.drain(start..) {
1189 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 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 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 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 _ => dependent.join("node_modules").join(name),
1261 };
1262 self.unpack_package(target, manifest).await
1263 })
1264 }
1265
1266 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
1295pub(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
1306fn 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
1328pub(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
1336pub(crate) async fn lock(path: &Path) -> Result<FileLock, NpmError> {
1339 loop {
1340 match FileLock::acquire(path, &CancellationToken::new()).await {
1341 Err(LockError::Held) => continue,
1343 result => return result.map_err(|err| io(path)(std::io::Error::other(err))),
1344 }
1345 }
1346}
1347
1348#[derive(Clone, Debug, PartialEq, Eq)]
1350pub struct Installed {
1351 pub version: String,
1353 pub tree: Vec<Pinned>,
1358}
1359
1360pub 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
1395pub 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
1430pub 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
1448pub 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
1458pub(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
1466const REPLAY_DOWNLOADS: usize = 8;
1468
1469pub 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 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 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 let relative = pin.path.strip_prefix("node_modules/").unwrap_or(&pin.path);
1546 let dir = staging.join(relative);
1547 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
1560pub 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
1584fn 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 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 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 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 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 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 #[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 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 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 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 #[tokio::test(flavor = "multi_thread")]
1888 async fn capped_tarballs_leave_nothing() {
1889 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 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 #[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 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 assert!(!check(json!({"integrity": format!("{wrong512} {sha1}")})));
2071 assert!(check(json!({"integrity": format!("{wrong512} {sha512}")})));
2072 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}