1use crate::{Error, ResolveTask};
2use aube_lockfile::{LocalSource, LockedPackage};
3use aube_registry::client::RegistryClient;
4use aube_util::path::normalize_lexical;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8pub(crate) fn rebase_local(
23 local: &LocalSource,
24 importer_root: &Path,
25 project_root: &Path,
26) -> LocalSource {
27 if importer_root == project_root {
32 if let LocalSource::Exec(path) = local {
33 return LocalSource::Exec(normalize_lexical(path));
34 }
35 return local.clone();
36 }
37 let Some(local_path) = local.path() else {
38 return local.clone();
40 };
41 let abs = normalize_lexical(&importer_root.join(local_path));
42 let rebased = pathdiff::diff_paths(&abs, project_root).map_or(abs, |p| normalize_lexical(&p));
43 match local {
44 LocalSource::Directory(_) => LocalSource::Directory(rebased),
45 LocalSource::Tarball(_) => LocalSource::Tarball(rebased),
46 LocalSource::Link(_) => LocalSource::Link(rebased),
47 LocalSource::Portal(_) => LocalSource::Portal(rebased),
48 LocalSource::Exec(_) => LocalSource::Exec(rebased),
49 LocalSource::Git(_) | LocalSource::RemoteTarball(_) => local.clone(),
50 }
51}
52
53pub fn resolve_exec_script_path(
55 local: &LocalSource,
56 project_root: &Path,
57) -> Result<PathBuf, String> {
58 let LocalSource::Exec(rel) = local else {
59 return Err("resolve_exec_script_path called on non-exec source".to_string());
60 };
61 let script = project_root.join(rel);
62 if !script.is_file() {
63 return Err(format!("{} is not a file", script.display()));
64 }
65 let canonical_root = project_root
66 .canonicalize()
67 .map_err(|e| format!("canonicalize project root {}: {e}", project_root.display()))?;
68 let canonical_script = script
69 .canonicalize()
70 .map_err(|e| format!("canonicalize exec script {}: {e}", script.display()))?;
71 if !canonical_script.starts_with(&canonical_root) {
72 return Err(format!(
73 "{} resolves outside project root {}",
74 script.display(),
75 canonical_root.display()
76 ));
77 }
78 Ok(canonical_script)
79}
80
81const MAX_RESOLVE_TARBALL_DECOMPRESSED_BYTES: u64 = 64 * 1024 * 1024;
96const MAX_RESOLVE_PACKAGE_JSON_BYTES: u64 = 8 * 1024 * 1024;
97
98fn read_tarball_package_json(bytes: &[u8]) -> Result<Vec<u8>, String> {
99 use std::io::Read;
100 let gz = flate2::read::GzDecoder::new(bytes);
106 let capped = gz.take(MAX_RESOLVE_TARBALL_DECOMPRESSED_BYTES);
107 let mut archive = tar::Archive::new(capped);
108 for entry in archive.entries().map_err(|e| e.to_string())? {
109 let entry = entry.map_err(|e| e.to_string())?;
110 let entry_path = entry.path().map_err(|e| e.to_string())?.to_path_buf();
111 if entry_path
112 .file_name()
113 .and_then(|n| n.to_str())
114 .is_some_and(|n| n == "package.json")
115 && entry_path.components().count() == 2
116 {
117 let mut buf = Vec::new();
118 entry
119 .take(MAX_RESOLVE_PACKAGE_JSON_BYTES + 1)
120 .read_to_end(&mut buf)
121 .map_err(|e| e.to_string())?;
122 if buf.len() as u64 > MAX_RESOLVE_PACKAGE_JSON_BYTES {
123 return Err("package.json exceeds 8 MiB cap".to_string());
124 }
125 return Ok(buf);
126 }
127 }
128 Err("tarball has no top-level package.json".to_string())
129}
130
131pub(crate) fn read_local_manifest(
140 local: &LocalSource,
141 importer_root: &Path,
142) -> Result<(String, String, BTreeMap<String, String>), Error> {
143 let Some(local_path) = local.path() else {
144 return Err(Error::Registry(
145 local.specifier(),
146 "read_local_manifest called on non-path source".to_string(),
147 ));
148 };
149 let path = importer_root.join(local_path);
150
151 let content = match local {
152 LocalSource::Directory(_) | LocalSource::Link(_) | LocalSource::Portal(_) => {
153 std::fs::read(path.join("package.json"))
154 .map_err(|e| Error::Registry(local.specifier(), e.to_string()))?
155 }
156 LocalSource::Tarball(_) => {
157 let bytes = std::fs::read(&path)
158 .map_err(|e| Error::Registry(local.specifier(), e.to_string()))?;
159 read_tarball_package_json(&bytes).map_err(|e| Error::Registry(local.specifier(), e))?
160 }
161 LocalSource::Exec(_) | LocalSource::Git(_) | LocalSource::RemoteTarball(_) => {
162 return Err(Error::Registry(
163 local.specifier(),
164 "read_local_manifest: generated or remote source handled separately".to_string(),
165 ));
166 }
167 };
168
169 let pj: aube_manifest::PackageJson = sonic_rs::from_slice(&content)
170 .or_else(|_| serde_json::from_slice(&content))
171 .map_err(|e| Error::Registry(local.specifier(), e.to_string()))?;
172 Ok((
173 pj.name.unwrap_or_default(),
174 pj.version.unwrap_or_else(|| "0.0.0".to_string()),
175 pj.dependencies,
176 ))
177}
178
179pub(crate) async fn resolve_exec_manifest(
180 name: &str,
181 local: &LocalSource,
182 project_root: &Path,
183) -> Result<(String, BTreeMap<String, String>), Error> {
184 let LocalSource::Exec(_) = local else {
185 return Err(Error::Registry(
186 name.to_string(),
187 "resolve_exec_manifest called on non-exec source".to_string(),
188 ));
189 };
190 let script = resolve_exec_script_path(local, project_root).map_err(|e| {
191 Error::Registry(
192 name.to_string(),
193 format!("exec dependency {}: {e}", local.specifier()),
194 )
195 })?;
196
197 let temp = tempfile::Builder::new()
198 .prefix("aube-exec-resolve-")
199 .tempdir()
200 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
201 let build_dir = temp.path().join("build");
202 let temp_dir = temp.path().join("temp");
203 std::fs::create_dir_all(&build_dir)
204 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
205 std::fs::create_dir_all(&temp_dir)
206 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
207
208 let env = serde_json::json!({
209 "tempDir": temp_dir,
210 "buildDir": build_dir,
211 "locator": format!("{name}@{}", local.specifier()),
212 });
213 let status = tokio::process::Command::new("node")
214 .arg("-e")
215 .arg(crate::YARN_EXEC_WRAPPER)
216 .arg(&script)
217 .env("AUBE_YARN_EXEC_ENV", env.to_string())
218 .current_dir(project_root)
219 .status()
220 .await
221 .map_err(|e| {
222 Error::Registry(
223 name.to_string(),
224 format!("execute {} with Node.js from PATH: {e}", local.specifier()),
225 )
226 })?;
227 if !status.success() {
228 return Err(Error::Registry(
229 name.to_string(),
230 format!(
231 "exec dependency {} failed with status {status}",
232 local.specifier()
233 ),
234 ));
235 }
236
237 let content = std::fs::read(build_dir.join("package.json")).map_err(|e| {
238 Error::Registry(
239 name.to_string(),
240 format!("read generated package.json for {}: {e}", local.specifier()),
241 )
242 })?;
243 let pj: aube_manifest::PackageJson = sonic_rs::from_slice(&content)
244 .or_else(|_| serde_json::from_slice(&content))
245 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
246 Ok((
247 pj.version.unwrap_or_else(|| "0.0.0".to_string()),
248 pj.dependencies,
249 ))
250}
251
252pub(crate) fn dep_path_for(name: &str, version: &str) -> String {
253 format!("{name}@{version}")
254}
255
256pub(crate) fn is_non_registry_specifier(s: &str) -> bool {
261 if s.starts_with("link:") {
262 return true;
263 }
264 if s.starts_with("portal:") {
265 return true;
266 }
267 if s.starts_with("exec:") {
268 return true;
269 }
270 if aube_lockfile::parse_git_spec(s).is_some() {
273 return true;
274 }
275 if aube_lockfile::LocalSource::looks_like_remote_tarball_url(s) {
278 return true;
279 }
280 s.starts_with("file:")
284}
285
286pub(crate) fn should_block_exotic_subdep(
287 task: &ResolveTask,
288 resolved: &BTreeMap<String, LockedPackage>,
289 block_exotic_subdeps: bool,
290) -> bool {
291 block_exotic_subdeps
292 && !task.is_root
293 && !task
294 .parent
295 .as_ref()
296 .and_then(|parent| resolved.get(parent))
297 .is_some_and(|pkg| {
298 matches!(
299 pkg.local_source,
300 Some(LocalSource::Directory(_))
301 | Some(LocalSource::Link(_))
302 | Some(LocalSource::Portal(_))
303 | Some(LocalSource::Exec(_))
304 )
305 })
306}
307
308fn hosted_git_local_source(
323 original_url: String,
324 committish: Option<String>,
325 resolved: String,
326 subpath: Option<String>,
327 integrity: Option<String>,
328 codeload_url: Option<&str>,
329) -> LocalSource {
330 match (subpath.as_deref(), codeload_url) {
331 (None, Some(codeload)) => LocalSource::RemoteTarball(aube_lockfile::RemoteTarballSource {
332 url: codeload.to_string(),
333 integrity: integrity.unwrap_or_default(),
334 git_hosted: true,
335 }),
336 _ => LocalSource::Git(aube_lockfile::GitSource {
337 url: original_url,
338 committish,
339 resolved,
340 integrity,
341 subpath,
342 }),
343 }
344}
345
346fn read_git_package_manifest(
347 name: &str,
348 pkg_root: &Path,
349 location: &str,
350 subpath: Option<&str>,
351) -> Result<(String, BTreeMap<String, String>), Error> {
352 let where_ = subpath.map(|s| format!(" at /{s}")).unwrap_or_default();
353 let meta = std::fs::metadata(pkg_root).map_err(|e| {
354 Error::Registry(
355 name.to_string(),
356 format!("stat git package root in {location}{where_}: {e}"),
357 )
358 })?;
359 if !meta.is_dir() {
360 return Err(Error::Registry(
361 name.to_string(),
362 format!(
363 "git package root in {location}{where_} is not a directory: {}",
364 pkg_root.display()
365 ),
366 ));
367 }
368
369 let manifest_path = pkg_root.join("package.json");
370 let manifest_bytes = match std::fs::read(&manifest_path) {
371 Ok(bytes) => bytes,
372 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
373 tracing::debug!(
374 package = name,
375 root = %pkg_root.display(),
376 "git dependency has no package.json; resolving as version 0.0.0 with no dependencies",
377 );
378 return Ok(("0.0.0".to_string(), BTreeMap::new()));
379 }
380 Err(e) => {
381 return Err(Error::Registry(
382 name.to_string(),
383 format!("read package.json in {location}{where_}: {e}"),
384 ));
385 }
386 };
387 let pj: aube_manifest::PackageJson = sonic_rs::from_slice(&manifest_bytes)
388 .or_else(|_| serde_json::from_slice(&manifest_bytes))
389 .map_err(|e| {
390 Error::Registry(
391 name.to_string(),
392 format!("parse package.json in {location}{where_}: {e}"),
393 )
394 })?;
395 Ok((
396 pj.version.unwrap_or_else(|| "0.0.0".to_string()),
397 pj.dependencies,
398 ))
399}
400
401pub(crate) async fn resolve_git_source(
420 name: &str,
421 git: &aube_lockfile::GitSource,
422 shallow: bool,
423 client: Option<&RegistryClient>,
424) -> Result<
425 (
426 LocalSource,
427 String,
428 BTreeMap<String, String>,
429 Option<String>,
430 ),
431 Error,
432> {
433 let original_url = git.url.clone();
434 let committish = git.committish.clone();
435 let subpath = git.subpath.clone();
436 let hosted = aube_lockfile::parse_hosted_git(&original_url);
437 let runtime_url = hosted
442 .as_ref()
443 .map(|h| h.https_url())
444 .unwrap_or_else(|| original_url.clone());
445
446 let runtime_url_for_ref = runtime_url.clone();
452 let committish_for_ref = committish.clone();
453 let name_for_ref = name.to_string();
454 let resolved_sha = tokio::task::spawn_blocking(move || -> Result<String, Error> {
455 let seed = aube_store::git_resolve_ref(&runtime_url_for_ref, committish_for_ref.as_deref())
456 .map_err(|e| Error::Registry(name_for_ref.clone(), e.to_string()))?;
457 Ok(seed)
462 })
463 .await
464 .map_err(|e| {
465 Error::Registry(
466 name.to_string(),
467 format!("git ls-remote task panicked: {e}"),
468 )
469 })??;
470
471 let codeload_url = hosted.as_ref().and_then(|h| h.tarball_url(&resolved_sha));
472
473 if codeload_url.is_some()
478 && git.integrity.is_some()
479 && let Some((clone_dir, _head_sha)) = aube_store::codeload_cache_lookup(
480 &original_url,
481 &resolved_sha,
482 git.integrity.as_deref(),
483 )
484 {
485 let integrity = aube_store::codeload_cache_integrity(
486 &original_url,
487 &resolved_sha,
488 git.integrity.as_deref(),
489 );
490 let pkg_root = match &subpath {
491 Some(sub) => clone_dir.join(sub),
492 None => clone_dir.clone(),
493 };
494 let (version, deps) = read_git_package_manifest(
495 name,
496 &pkg_root,
497 "cached codeload extract",
498 subpath.as_deref(),
499 )?;
500 return Ok((
501 hosted_git_local_source(
502 original_url,
503 committish,
504 resolved_sha,
505 subpath,
506 git.integrity.clone(),
507 codeload_url.as_deref(),
508 ),
509 version,
510 deps,
511 integrity,
512 ));
513 }
514
515 if let (Some(c), Some(url_to_fetch)) = (client, codeload_url.as_deref()) {
519 match c.fetch_tarball_bytes(url_to_fetch).await {
520 Ok(bytes) => {
521 let bytes_vec = bytes.to_vec();
526 if let Some(pinned) = &git.integrity {
527 aube_store::verify_integrity(&bytes_vec, pinned)
528 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
529 }
530 let integrity = git
531 .integrity
532 .clone()
533 .unwrap_or_else(|| aube_store::sha512_integrity(&bytes_vec));
534 let url_for_extract = original_url.clone();
535 let sha_for_extract = resolved_sha.clone();
536 let integrity_for_extract = integrity.clone();
537 let subpath_for_extract = subpath.clone();
538 let name_for_extract = name.to_string();
539 let extracted = tokio::task::spawn_blocking(move || -> Result<_, Error> {
540 let (clone_dir, resolved) = aube_store::extract_codeload_tarball(
541 &bytes_vec,
542 &url_for_extract,
543 &sha_for_extract,
544 Some(&integrity_for_extract),
545 )
546 .map_err(|e| Error::Registry(name_for_extract.clone(), e.to_string()))?;
547 let pkg_root = match &subpath_for_extract {
548 Some(sub) => clone_dir.join(sub),
549 None => clone_dir.clone(),
550 };
551 let (version, deps) = read_git_package_manifest(
552 &name_for_extract,
553 &pkg_root,
554 "codeload extract",
555 subpath_for_extract.as_deref(),
556 )?;
557 Ok((resolved, version, deps))
558 })
559 .await
560 .map_err(|e| {
561 Error::Registry(name.to_string(), format!("codeload extract panicked: {e}"))
562 })?;
563 let integrity = aube_store::sha512_integrity(&bytes);
564 match extracted {
565 Ok((resolved, version, deps)) => {
566 return Ok((
567 hosted_git_local_source(
568 original_url,
569 committish,
570 resolved,
571 subpath,
572 Some(integrity.clone()),
573 Some(url_to_fetch),
574 ),
575 version,
576 deps,
577 Some(integrity),
578 ));
579 }
580 Err(e) => {
581 tracing::debug!(
588 name,
589 "codeload extract failed, falling back to git clone: {e}",
590 );
591 }
592 }
593 }
594 Err(e) => {
595 tracing::debug!(
600 name,
601 url = %aube_util::url::redact_url(url_to_fetch),
602 "codeload fetch failed, falling back to git clone: {e}",
603 );
604 }
605 }
606 }
607
608 let runtime_url_for_clone = runtime_url;
612 let original_url_for_lockfile = original_url.clone();
613 let resolved_sha_for_clone = resolved_sha.clone();
614 let subpath_for_clone = subpath.clone();
615 let name_for_clone = name.to_string();
616 let (local, version, deps) = tokio::task::spawn_blocking(move || -> Result<_, Error> {
617 let (clone_dir, resolved) =
618 aube_store::git_shallow_clone(&runtime_url_for_clone, &resolved_sha_for_clone, shallow)
619 .map_err(|e| Error::Registry(name_for_clone.clone(), e.to_string()))?;
620 let pkg_root = match &subpath_for_clone {
621 Some(sub) => clone_dir.join(sub),
622 None => clone_dir.clone(),
623 };
624 let (version, deps) = read_git_package_manifest(
625 &name_for_clone,
626 &pkg_root,
627 "clone",
628 subpath_for_clone.as_deref(),
629 )?;
630 Ok((
631 LocalSource::Git(aube_lockfile::GitSource {
632 url: original_url_for_lockfile,
633 committish,
634 resolved,
635 integrity: None,
636 subpath: subpath_for_clone,
637 }),
638 version,
639 deps,
640 ))
641 })
642 .await
643 .map_err(|e| Error::Registry(name.to_string(), format!("git task panicked: {e}")))??;
644 Ok((local, version, deps, None))
645}
646
647pub(crate) async fn resolve_remote_tarball(
652 name: &str,
653 tarball: &aube_lockfile::RemoteTarballSource,
654 client: &RegistryClient,
655) -> Result<(LocalSource, String, BTreeMap<String, String>), Error> {
656 let bytes = client
657 .fetch_tarball_bytes(&tarball.url)
658 .await
659 .map_err(|e| {
660 Error::Registry(
661 name.to_string(),
662 format!("fetch {}: {e}", aube_util::url::redact_url(&tarball.url)),
663 )
664 })?;
665 let name_owned = name.to_string();
666 let url = aube_util::url::redact_url(&tarball.url);
667 let (integrity, version, deps) = tokio::task::spawn_blocking(move || -> Result<_, Error> {
668 let integrity = aube_store::sha512_integrity(&bytes);
669
670 let manifest_bytes = read_tarball_package_json(&bytes)
675 .map_err(|e| Error::Registry(name_owned.clone(), format!("tarball {url}: {e}")))?;
676 let pj: aube_manifest::PackageJson = serde_json::from_slice(&manifest_bytes)
677 .map_err(|e| Error::Registry(name_owned.clone(), e.to_string()))?;
678 let version = pj.version.unwrap_or_else(|| "0.0.0".to_string());
679 Ok((integrity, version, pj.dependencies))
680 })
681 .await
682 .map_err(|e| Error::Registry(name.to_string(), format!("tarball task panicked: {e}")))??;
683 Ok((
684 LocalSource::RemoteTarball(aube_lockfile::RemoteTarballSource {
685 url: tarball.url.clone(),
686 integrity,
687 git_hosted: tarball.git_hosted,
688 }),
689 version,
690 deps,
691 ))
692}
693
694#[cfg(test)]
695mod rebase_local_tests {
696 use super::*;
697 use std::path::{Path, PathBuf};
698
699 #[test]
700 fn workspace_file_climbs_out_of_importer_to_root_sibling() {
701 let local = LocalSource::Directory(PathBuf::from("../../vendor-dir"));
706 let rebased = rebase_local(&local, Path::new("packages/app"), Path::new(""));
707 match rebased {
708 LocalSource::Directory(p) => assert_eq!(p, PathBuf::from("vendor-dir")),
709 other => panic!("expected Directory, got {other:?}"),
710 }
711 }
712
713 #[test]
714 fn two_importers_referencing_same_target_collide_on_dep_path() {
715 let a = rebase_local(
719 &LocalSource::Directory(PathBuf::from("../../vendor-dir")),
720 Path::new("packages/app"),
721 Path::new(""),
722 );
723 let b = rebase_local(
724 &LocalSource::Directory(PathBuf::from("../vendor-dir")),
725 Path::new("packages"),
726 Path::new(""),
727 );
728 assert_eq!(a.dep_path("vendor-dir"), b.dep_path("vendor-dir"));
729 }
730
731 #[test]
732 fn root_and_transitive_exec_paths_collide_on_dep_path() {
733 let root = rebase_local(
734 &LocalSource::Exec(PathBuf::from("./scripts/generate-exec.js")),
735 Path::new(""),
736 Path::new(""),
737 );
738 let transitive = rebase_local(
739 &LocalSource::Exec(PathBuf::from("../../scripts/generate-exec.js")),
740 Path::new("packages/portal"),
741 Path::new(""),
742 );
743 assert_eq!(root.dep_path("exec-pkg"), transitive.dep_path("exec-pkg"));
744 }
745
746 #[test]
747 fn normalize_preserves_unresolvable_leading_parent() {
748 assert_eq!(
751 normalize_lexical(Path::new("../vendor")),
752 PathBuf::from("../vendor")
753 );
754 }
755
756 #[test]
757 fn dep_path_and_specifier_use_posix_separators() {
758 let win = LocalSource::Directory(PathBuf::from("vendor\\nested\\dir"));
762 let unix = LocalSource::Directory(PathBuf::from("vendor/nested/dir"));
763 assert_eq!(win.dep_path("foo"), unix.dep_path("foo"));
764 assert_eq!(win.specifier(), "file:vendor/nested/dir");
765 assert_eq!(unix.specifier(), "file:vendor/nested/dir");
766 }
767
768 #[test]
769 fn exec_script_must_stay_inside_project_root() {
770 let temp = tempfile::tempdir().unwrap();
771 let project_root = temp.path().join("project");
772 let outside = temp.path().join("outside.js");
773 std::fs::create_dir(&project_root).unwrap();
774 std::fs::write(&outside, "").unwrap();
775
776 let local = LocalSource::Exec(PathBuf::from("../outside.js"));
777 let err = resolve_exec_script_path(&local, &project_root).unwrap_err();
778 assert!(err.contains("resolves outside project root"), "{err}");
779 }
780
781 #[test]
782 fn exec_script_inside_project_root_is_allowed() {
783 let temp = tempfile::tempdir().unwrap();
784 let project_root = temp.path().join("project");
785 let script_dir = project_root.join("scripts");
786 let script = script_dir.join("generate.js");
787 std::fs::create_dir_all(&script_dir).unwrap();
788 std::fs::write(&script, "").unwrap();
789
790 let local = LocalSource::Exec(PathBuf::from("scripts/generate.js"));
791 let resolved = resolve_exec_script_path(&local, &project_root).unwrap();
792 assert_eq!(resolved, script.canonicalize().unwrap());
793 }
794}
795
796#[cfg(test)]
797mod cve_audit_tarball_bomb {
798 use super::*;
799 use std::io::Write;
800
801 fn build_zero_tarball(uncompressed_size: usize) -> Vec<u8> {
802 let mut tar_buf: Vec<u8> = Vec::new();
803 {
804 let mut builder = tar::Builder::new(&mut tar_buf);
805 let payload = vec![0u8; uncompressed_size];
806 let mut header = tar::Header::new_gnu();
807 header.set_path("pkg/package.json").unwrap();
808 header.set_size(payload.len() as u64);
809 header.set_mode(0o644);
810 header.set_cksum();
811 builder.append(&header, &payload[..]).unwrap();
812 builder.finish().unwrap();
813 }
814 let mut gz = Vec::new();
815 {
816 let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::best());
817 enc.write_all(&tar_buf).unwrap();
818 enc.finish().unwrap();
819 }
820 gz
821 }
822
823 fn build_dummy_then_package_json(dummy_size: usize) -> Vec<u8> {
824 let mut tar_buf: Vec<u8> = Vec::new();
825 {
826 let mut builder = tar::Builder::new(&mut tar_buf);
827 let dummy = vec![0u8; dummy_size];
828 let mut h1 = tar::Header::new_gnu();
829 h1.set_path("pkg/dummy.bin").unwrap();
830 h1.set_size(dummy.len() as u64);
831 h1.set_mode(0o644);
832 h1.set_cksum();
833 builder.append(&h1, &dummy[..]).unwrap();
834 let manifest = b"{\"name\":\"x\",\"version\":\"0.0.1\"}";
835 let mut h2 = tar::Header::new_gnu();
836 h2.set_path("pkg/package.json").unwrap();
837 h2.set_size(manifest.len() as u64);
838 h2.set_mode(0o644);
839 h2.set_cksum();
840 builder.append(&h2, &manifest[..]).unwrap();
841 builder.finish().unwrap();
842 }
843 let mut gz = Vec::new();
844 {
845 let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::best());
846 enc.write_all(&tar_buf).unwrap();
847 enc.finish().unwrap();
848 }
849 gz
850 }
851
852 #[test]
853 fn read_tarball_package_json_rejects_decompression_bomb() {
854 let bomb = build_zero_tarball(200 * 1024 * 1024);
855 assert!(
856 bomb.len() < 400 * 1024,
857 "compressed bomb too large to call this an amplification: {}",
858 bomb.len()
859 );
860 let result = read_tarball_package_json(&bomb);
861 assert!(
862 result.is_err(),
863 "200 MiB decompressed payload must be rejected by the cap, got {:?}",
864 result.as_ref().map(|b| b.len())
865 );
866 }
867
868 #[test]
869 fn read_tarball_package_json_rejects_dummy_entry_amplification() {
870 let bomb = build_dummy_then_package_json(200 * 1024 * 1024);
871 assert!(
872 bomb.len() < 400 * 1024,
873 "compressed multi-entry bomb too large: {}",
874 bomb.len()
875 );
876 let result = read_tarball_package_json(&bomb);
877 assert!(
878 result.is_err(),
879 "decompressed dummy entry preceding package.json must hit the output cap"
880 );
881 }
882}
883
884#[cfg(test)]
885mod hosted_git_local_source_tests {
886 use super::*;
887
888 const SHA: &str = "78e559baa908942097330f7967dfbf623ebc2529";
889
890 #[test]
891 fn hosted_sha_without_subpath_becomes_codeload_remote_tarball() {
892 let codeload = format!("https://codeload.github.com/xmppo/node-expat/tar.gz/{SHA}");
893 let src = hosted_git_local_source(
894 "git+ssh://git@github.com/xmppo/node-expat.git".to_string(),
895 Some(format!("v2.4.3#{SHA}")),
896 SHA.to_string(),
897 None,
898 Some("sha512-deadbeef".to_string()),
899 Some(codeload.as_str()),
900 );
901 match src {
902 LocalSource::RemoteTarball(t) => {
903 assert_eq!(t.url, codeload);
905 assert_eq!(t.integrity, "sha512-deadbeef");
906 assert!(t.git_hosted, "codeload archives must flag gitHosted");
907 assert_eq!(
910 LocalSource::RemoteTarball(t).specifier(),
911 codeload,
912 "specifier must be the bare codeload URL pnpm records"
913 );
914 }
915 other => panic!("expected RemoteTarball, got {other:?}"),
916 }
917 }
918
919 #[test]
920 fn subpath_selector_stays_git() {
921 let codeload = format!("https://codeload.github.com/acme/mono/tar.gz/{SHA}");
924 let src = hosted_git_local_source(
925 "git+ssh://git@github.com/acme/mono.git".to_string(),
926 Some(SHA.to_string()),
927 SHA.to_string(),
928 Some("packages/leaf".to_string()),
929 Some("sha512-x".to_string()),
930 Some(codeload.as_str()),
931 );
932 match src {
933 LocalSource::Git(g) => {
934 assert_eq!(g.resolved, SHA);
935 assert_eq!(g.subpath.as_deref(), Some("packages/leaf"));
936 }
937 other => panic!("expected Git with subpath, got {other:?}"),
938 }
939 }
940
941 #[test]
942 fn no_codeload_url_stays_git() {
943 let src = hosted_git_local_source(
946 "git+ssh://git@example.com/internal/dep.git".to_string(),
947 Some(SHA.to_string()),
948 SHA.to_string(),
949 None,
950 Some("sha512-y".to_string()),
951 None,
952 );
953 match src {
954 LocalSource::Git(g) => {
955 assert_eq!(g.url, "git+ssh://git@example.com/internal/dep.git");
956 assert_eq!(g.integrity.as_deref(), Some("sha512-y"));
957 }
958 other => panic!("expected Git, got {other:?}"),
959 }
960 }
961}
962
963#[cfg(test)]
964mod git_package_manifest_tests {
965 use super::*;
966
967 #[test]
968 fn missing_git_package_json_defaults_to_empty_manifest() {
969 let temp = tempfile::tempdir().unwrap();
970 std::fs::write(temp.path().join("schema.json"), "{}").unwrap();
971
972 let (version, deps) =
973 read_git_package_manifest("asset-only", temp.path(), "clone", None).unwrap();
974
975 assert_eq!(version, "0.0.0");
976 assert!(deps.is_empty());
977 }
978
979 #[test]
980 fn invalid_git_package_json_still_errors() {
981 let temp = tempfile::tempdir().unwrap();
982 std::fs::write(temp.path().join("package.json"), "{").unwrap();
983
984 let err = read_git_package_manifest("broken", temp.path(), "clone", None).unwrap_err();
985
986 assert!(
987 err.to_string().contains("parse package.json in clone"),
988 "{err}"
989 );
990 }
991
992 #[test]
993 fn missing_git_subpath_still_errors() {
994 let temp = tempfile::tempdir().unwrap();
995 let missing = temp.path().join("packages/missing");
996
997 let err = read_git_package_manifest(
998 "missing-subpath",
999 &missing,
1000 "clone",
1001 Some("packages/missing"),
1002 )
1003 .unwrap_err();
1004
1005 assert!(
1006 err.to_string()
1007 .contains("stat git package root in clone at /packages/missing"),
1008 "{err}"
1009 );
1010 }
1011}