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
308pub(crate) async fn resolve_git_source(
327 name: &str,
328 git: &aube_lockfile::GitSource,
329 shallow: bool,
330 client: Option<&RegistryClient>,
331) -> Result<(LocalSource, String, BTreeMap<String, String>), Error> {
332 let original_url = git.url.clone();
333 let committish = git.committish.clone();
334 let subpath = git.subpath.clone();
335 let hosted = aube_lockfile::parse_hosted_git(&original_url);
336 let runtime_url = hosted
341 .as_ref()
342 .map(|h| h.https_url())
343 .unwrap_or_else(|| original_url.clone());
344
345 let runtime_url_for_ref = runtime_url.clone();
351 let committish_for_ref = committish.clone();
352 let name_for_ref = name.to_string();
353 let resolved_sha = tokio::task::spawn_blocking(move || -> Result<String, Error> {
354 let seed = aube_store::git_resolve_ref(&runtime_url_for_ref, committish_for_ref.as_deref())
355 .map_err(|e| Error::Registry(name_for_ref.clone(), e.to_string()))?;
356 Ok(seed)
361 })
362 .await
363 .map_err(|e| {
364 Error::Registry(
365 name.to_string(),
366 format!("git ls-remote task panicked: {e}"),
367 )
368 })??;
369
370 let codeload_url = hosted.as_ref().and_then(|h| h.tarball_url(&resolved_sha));
371
372 if codeload_url.is_some()
377 && let Some((clone_dir, _head_sha)) =
378 aube_store::codeload_cache_lookup(&original_url, &resolved_sha)
379 {
380 let pkg_root = match &subpath {
381 Some(sub) => clone_dir.join(sub),
382 None => clone_dir.clone(),
383 };
384 let manifest_bytes = std::fs::read(pkg_root.join("package.json")).map_err(|e| {
385 let where_ = subpath
386 .as_deref()
387 .map(|s| format!(" at /{s}"))
388 .unwrap_or_default();
389 Error::Registry(
390 name.to_string(),
391 format!("read package.json in cached codeload extract{where_}: {e}"),
392 )
393 })?;
394 let pj: aube_manifest::PackageJson = serde_json::from_slice(&manifest_bytes)
395 .map_err(|e| Error::Registry(name.to_string(), e.to_string()))?;
396 let version = pj.version.unwrap_or_else(|| "0.0.0".to_string());
397 return Ok((
398 LocalSource::Git(aube_lockfile::GitSource {
399 url: original_url,
400 committish,
401 resolved: resolved_sha,
402 subpath,
403 }),
404 version,
405 pj.dependencies,
406 ));
407 }
408
409 if let (Some(c), Some(url_to_fetch)) = (client, codeload_url.as_deref()) {
413 match c.fetch_tarball_bytes(url_to_fetch).await {
414 Ok(bytes) => {
415 let bytes_vec = bytes.to_vec();
420 let url_for_extract = original_url.clone();
421 let sha_for_extract = resolved_sha.clone();
422 let subpath_for_extract = subpath.clone();
423 let name_for_extract = name.to_string();
424 let extracted = tokio::task::spawn_blocking(move || -> Result<_, Error> {
425 let (clone_dir, resolved) = aube_store::extract_codeload_tarball(
426 &bytes_vec,
427 &url_for_extract,
428 &sha_for_extract,
429 )
430 .map_err(|e| Error::Registry(name_for_extract.clone(), e.to_string()))?;
431 let pkg_root = match &subpath_for_extract {
432 Some(sub) => clone_dir.join(sub),
433 None => clone_dir.clone(),
434 };
435 let manifest_bytes =
436 std::fs::read(pkg_root.join("package.json")).map_err(|e| {
437 let where_ = subpath_for_extract
438 .as_deref()
439 .map(|s| format!(" at /{s}"))
440 .unwrap_or_default();
441 Error::Registry(
442 name_for_extract.clone(),
443 format!("read package.json in codeload extract{where_}: {e}"),
444 )
445 })?;
446 let pj: aube_manifest::PackageJson = serde_json::from_slice(&manifest_bytes)
447 .map_err(|e| Error::Registry(name_for_extract.clone(), e.to_string()))?;
448 let version = pj.version.unwrap_or_else(|| "0.0.0".to_string());
449 Ok((resolved, version, pj.dependencies))
450 })
451 .await
452 .map_err(|e| {
453 Error::Registry(name.to_string(), format!("codeload extract panicked: {e}"))
454 })?;
455 match extracted {
456 Ok((resolved, version, deps)) => {
457 return Ok((
458 LocalSource::Git(aube_lockfile::GitSource {
459 url: original_url,
460 committish,
461 resolved,
462 subpath,
463 }),
464 version,
465 deps,
466 ));
467 }
468 Err(e) => {
469 tracing::debug!(
476 name,
477 "codeload extract failed, falling back to git clone: {e}",
478 );
479 }
480 }
481 }
482 Err(e) => {
483 tracing::debug!(
488 name,
489 url = %aube_util::url::redact_url(url_to_fetch),
490 "codeload fetch failed, falling back to git clone: {e}",
491 );
492 }
493 }
494 }
495
496 let runtime_url_for_clone = runtime_url;
500 let original_url_for_lockfile = original_url.clone();
501 let resolved_sha_for_clone = resolved_sha.clone();
502 let subpath_for_clone = subpath.clone();
503 let name_for_clone = name.to_string();
504 let (local, version, deps) = tokio::task::spawn_blocking(move || -> Result<_, Error> {
505 let (clone_dir, resolved) =
506 aube_store::git_shallow_clone(&runtime_url_for_clone, &resolved_sha_for_clone, shallow)
507 .map_err(|e| Error::Registry(name_for_clone.clone(), e.to_string()))?;
508 let pkg_root = match &subpath_for_clone {
509 Some(sub) => clone_dir.join(sub),
510 None => clone_dir.clone(),
511 };
512 let manifest_bytes = std::fs::read(pkg_root.join("package.json")).map_err(|e| {
513 let where_ = subpath_for_clone
514 .as_deref()
515 .map(|s| format!(" at /{s}"))
516 .unwrap_or_default();
517 Error::Registry(
518 name_for_clone.clone(),
519 format!("read package.json in clone{where_}: {e}"),
520 )
521 })?;
522 let pj: aube_manifest::PackageJson = serde_json::from_slice(&manifest_bytes)
523 .map_err(|e| Error::Registry(name_for_clone.clone(), e.to_string()))?;
524 let version = pj.version.unwrap_or_else(|| "0.0.0".to_string());
525 Ok((
526 LocalSource::Git(aube_lockfile::GitSource {
527 url: original_url_for_lockfile,
528 committish,
529 resolved,
530 subpath: subpath_for_clone,
531 }),
532 version,
533 pj.dependencies,
534 ))
535 })
536 .await
537 .map_err(|e| Error::Registry(name.to_string(), format!("git task panicked: {e}")))??;
538 Ok((local, version, deps))
539}
540
541pub(crate) async fn resolve_remote_tarball(
546 name: &str,
547 tarball: &aube_lockfile::RemoteTarballSource,
548 client: &RegistryClient,
549) -> Result<(LocalSource, String, BTreeMap<String, String>), Error> {
550 let bytes = client
551 .fetch_tarball_bytes(&tarball.url)
552 .await
553 .map_err(|e| {
554 Error::Registry(
555 name.to_string(),
556 format!("fetch {}: {e}", aube_util::url::redact_url(&tarball.url)),
557 )
558 })?;
559 let name_owned = name.to_string();
560 let url = aube_util::url::redact_url(&tarball.url);
561 let (integrity, version, deps) = tokio::task::spawn_blocking(move || -> Result<_, Error> {
562 use sha2::{Digest, Sha512};
563 let mut hasher = Sha512::new();
564 hasher.update(&bytes);
565 let digest = hasher.finalize();
566 use base64::Engine;
567 let b64 = base64::engine::general_purpose::STANDARD.encode(digest);
568 let integrity = format!("sha512-{b64}");
569
570 let manifest_bytes = read_tarball_package_json(&bytes)
575 .map_err(|e| Error::Registry(name_owned.clone(), format!("tarball {url}: {e}")))?;
576 let pj: aube_manifest::PackageJson = serde_json::from_slice(&manifest_bytes)
577 .map_err(|e| Error::Registry(name_owned.clone(), e.to_string()))?;
578 let version = pj.version.unwrap_or_else(|| "0.0.0".to_string());
579 Ok((integrity, version, pj.dependencies))
580 })
581 .await
582 .map_err(|e| Error::Registry(name.to_string(), format!("tarball task panicked: {e}")))??;
583 Ok((
584 LocalSource::RemoteTarball(aube_lockfile::RemoteTarballSource {
585 url: tarball.url.clone(),
586 integrity,
587 }),
588 version,
589 deps,
590 ))
591}
592
593#[cfg(test)]
594mod rebase_local_tests {
595 use super::*;
596 use std::path::{Path, PathBuf};
597
598 #[test]
599 fn workspace_file_climbs_out_of_importer_to_root_sibling() {
600 let local = LocalSource::Directory(PathBuf::from("../../vendor-dir"));
605 let rebased = rebase_local(&local, Path::new("packages/app"), Path::new(""));
606 match rebased {
607 LocalSource::Directory(p) => assert_eq!(p, PathBuf::from("vendor-dir")),
608 other => panic!("expected Directory, got {other:?}"),
609 }
610 }
611
612 #[test]
613 fn two_importers_referencing_same_target_collide_on_dep_path() {
614 let a = rebase_local(
618 &LocalSource::Directory(PathBuf::from("../../vendor-dir")),
619 Path::new("packages/app"),
620 Path::new(""),
621 );
622 let b = rebase_local(
623 &LocalSource::Directory(PathBuf::from("../vendor-dir")),
624 Path::new("packages"),
625 Path::new(""),
626 );
627 assert_eq!(a.dep_path("vendor-dir"), b.dep_path("vendor-dir"));
628 }
629
630 #[test]
631 fn root_and_transitive_exec_paths_collide_on_dep_path() {
632 let root = rebase_local(
633 &LocalSource::Exec(PathBuf::from("./scripts/generate-exec.js")),
634 Path::new(""),
635 Path::new(""),
636 );
637 let transitive = rebase_local(
638 &LocalSource::Exec(PathBuf::from("../../scripts/generate-exec.js")),
639 Path::new("packages/portal"),
640 Path::new(""),
641 );
642 assert_eq!(root.dep_path("exec-pkg"), transitive.dep_path("exec-pkg"));
643 }
644
645 #[test]
646 fn normalize_preserves_unresolvable_leading_parent() {
647 assert_eq!(
650 normalize_lexical(Path::new("../vendor")),
651 PathBuf::from("../vendor")
652 );
653 }
654
655 #[test]
656 fn dep_path_and_specifier_use_posix_separators() {
657 let win = LocalSource::Directory(PathBuf::from("vendor\\nested\\dir"));
661 let unix = LocalSource::Directory(PathBuf::from("vendor/nested/dir"));
662 assert_eq!(win.dep_path("foo"), unix.dep_path("foo"));
663 assert_eq!(win.specifier(), "file:vendor/nested/dir");
664 assert_eq!(unix.specifier(), "file:vendor/nested/dir");
665 }
666
667 #[test]
668 fn exec_script_must_stay_inside_project_root() {
669 let temp = tempfile::tempdir().unwrap();
670 let project_root = temp.path().join("project");
671 let outside = temp.path().join("outside.js");
672 std::fs::create_dir(&project_root).unwrap();
673 std::fs::write(&outside, "").unwrap();
674
675 let local = LocalSource::Exec(PathBuf::from("../outside.js"));
676 let err = resolve_exec_script_path(&local, &project_root).unwrap_err();
677 assert!(err.contains("resolves outside project root"), "{err}");
678 }
679
680 #[test]
681 fn exec_script_inside_project_root_is_allowed() {
682 let temp = tempfile::tempdir().unwrap();
683 let project_root = temp.path().join("project");
684 let script_dir = project_root.join("scripts");
685 let script = script_dir.join("generate.js");
686 std::fs::create_dir_all(&script_dir).unwrap();
687 std::fs::write(&script, "").unwrap();
688
689 let local = LocalSource::Exec(PathBuf::from("scripts/generate.js"));
690 let resolved = resolve_exec_script_path(&local, &project_root).unwrap();
691 assert_eq!(resolved, script.canonicalize().unwrap());
692 }
693}
694
695#[cfg(test)]
696mod cve_audit_tarball_bomb {
697 use super::*;
698 use std::io::Write;
699
700 fn build_zero_tarball(uncompressed_size: usize) -> Vec<u8> {
701 let mut tar_buf: Vec<u8> = Vec::new();
702 {
703 let mut builder = tar::Builder::new(&mut tar_buf);
704 let payload = vec![0u8; uncompressed_size];
705 let mut header = tar::Header::new_gnu();
706 header.set_path("pkg/package.json").unwrap();
707 header.set_size(payload.len() as u64);
708 header.set_mode(0o644);
709 header.set_cksum();
710 builder.append(&header, &payload[..]).unwrap();
711 builder.finish().unwrap();
712 }
713 let mut gz = Vec::new();
714 {
715 let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::best());
716 enc.write_all(&tar_buf).unwrap();
717 enc.finish().unwrap();
718 }
719 gz
720 }
721
722 fn build_dummy_then_package_json(dummy_size: usize) -> Vec<u8> {
723 let mut tar_buf: Vec<u8> = Vec::new();
724 {
725 let mut builder = tar::Builder::new(&mut tar_buf);
726 let dummy = vec![0u8; dummy_size];
727 let mut h1 = tar::Header::new_gnu();
728 h1.set_path("pkg/dummy.bin").unwrap();
729 h1.set_size(dummy.len() as u64);
730 h1.set_mode(0o644);
731 h1.set_cksum();
732 builder.append(&h1, &dummy[..]).unwrap();
733 let manifest = b"{\"name\":\"x\",\"version\":\"0.0.1\"}";
734 let mut h2 = tar::Header::new_gnu();
735 h2.set_path("pkg/package.json").unwrap();
736 h2.set_size(manifest.len() as u64);
737 h2.set_mode(0o644);
738 h2.set_cksum();
739 builder.append(&h2, &manifest[..]).unwrap();
740 builder.finish().unwrap();
741 }
742 let mut gz = Vec::new();
743 {
744 let mut enc = flate2::write::GzEncoder::new(&mut gz, flate2::Compression::best());
745 enc.write_all(&tar_buf).unwrap();
746 enc.finish().unwrap();
747 }
748 gz
749 }
750
751 #[test]
752 fn read_tarball_package_json_rejects_decompression_bomb() {
753 let bomb = build_zero_tarball(200 * 1024 * 1024);
754 assert!(
755 bomb.len() < 400 * 1024,
756 "compressed bomb too large to call this an amplification: {}",
757 bomb.len()
758 );
759 let result = read_tarball_package_json(&bomb);
760 assert!(
761 result.is_err(),
762 "200 MiB decompressed payload must be rejected by the cap, got {:?}",
763 result.as_ref().map(|b| b.len())
764 );
765 }
766
767 #[test]
768 fn read_tarball_package_json_rejects_dummy_entry_amplification() {
769 let bomb = build_dummy_then_package_json(200 * 1024 * 1024);
770 assert!(
771 bomb.len() < 400 * 1024,
772 "compressed multi-entry bomb too large: {}",
773 bomb.len()
774 );
775 let result = read_tarball_package_json(&bomb);
776 assert!(
777 result.is_err(),
778 "decompressed dummy entry preceding package.json must hit the output cap"
779 );
780 }
781}