Skip to main content

aube_resolver/
local_source.rs

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
8/// Rewrite a `LocalSource` whose path is relative to `importer_root`
9/// into one whose path is relative to `project_root`, so downstream
10/// code (install.rs, linker) can resolve the target with a single
11/// `project_root.join(rel)` regardless of which workspace importer
12/// declared it.
13///
14/// Both the join-then-diff intermediate and the returned path are
15/// lexically normalized — `Path::join` and `pathdiff::diff_paths`
16/// leave `..` components in place, which means `packages/app` +
17/// `../../vendor-dir` would otherwise produce
18/// `packages/app/../../vendor-dir`. That non-canonical form fed into
19/// `dep_path`'s hash would produce a different key for every
20/// importer declaring the same target, and would also leak into the
21/// lockfile's `version:` string.
22pub(crate) fn rebase_local(
23    local: &LocalSource,
24    importer_root: &Path,
25    project_root: &Path,
26) -> LocalSource {
27    // The fast path: importer_root == project_root. Root-importer
28    // installs take this branch, which is also the single-project
29    // case — no rewrite needed and we preserve the raw specifier
30    // bytes for a byte-identical lockfile round-trip.
31    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        // Non-path sources (git) have nothing to rebase.
39        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
53/// Resolve an `exec:` generator path and reject scripts outside the project root.
54pub 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
81/// Walk a gzipped npm tarball once and return the raw bytes of its
82/// top-level `package.json` entry. The wrapper directory name varies
83/// (`package/`, but also e.g. GitHub's `owner-repo-<sha>/`), so we
84/// match on the entry's basename plus a 2-component depth check
85/// rather than a hardcoded prefix. Errors come back as plain
86/// `String`s so each caller can wrap them with its own package
87/// identity in whatever error type it prefers — used by both the
88/// `file:` tarball path (`read_local_manifest`) and the remote
89/// tarball resolver (`resolve_remote_tarball`).
90/// Hard upper bound on the bytes read from the gzipped tarball stream
91/// while looking for `package.json`. A 64 MiB ceiling is far above any
92/// real npm package and keeps a hostile gzip bomb from amplifying into
93/// arbitrary RAM. Mirrors `aube-store::MAX_TARBALL_DECOMPRESSED_BYTES`
94/// in spirit — the resolver path was missed in the original cap pass.
95const 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    // Cap on the DECOMPRESSED output of the gzip stream so a hostile
101    // tarball with large dummy entries before `package.json` cannot
102    // amplify the fixed compressed input window into arbitrary RAM.
103    // `bytes.take` would only bound the compressed read, which the
104    // decoder is free to expand without ceiling.
105    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
131/// Read the `package.json` of a `file:` / `link:` target to discover
132/// the real package name, version, and production dependencies.
133///
134/// For `LocalSource::Directory`, `LocalSource::Link`, and
135/// `LocalSource::Portal` we read the target dir's `package.json`
136/// directly. For `LocalSource::Tarball` we open the `.tgz`, find the
137/// first `*/package.json` entry, and parse its contents without
138/// extracting the rest of the archive.
139pub(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
256/// Match specifier prefixes that resolve to a non-registry source
257/// (`file:`, `link:`, `portal:`, `exec:`, or a git URL form). Used
258/// by the resolver to decide whether to dispatch the local/git branch
259/// instead of the normal version-range lookup.
260pub(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    // Git first so `https://host/repo.git` dispatches the git branch
271    // rather than the broader bare-http tarball branch below.
272    if aube_lockfile::parse_git_spec(s).is_some() {
273        return true;
274    }
275    // Any remaining bare `http(s)://` URL is a tarball URL, per npm
276    // semantics — the `.tgz` suffix is not required.
277    if aube_lockfile::LocalSource::looks_like_remote_tarball_url(s) {
278        return true;
279    }
280    // `file:` is a local-path prefix only when it *isn't* also a git
281    // URL form — parse_git_spec already matched `file://…/repo.git`
282    // above, so anything that reaches here is treated as a path.
283    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
308/// Pick the lockfile source representation for a *resolved* hosted-git
309/// dependency. pnpm records a github / gitlab / bitbucket dep pinned to
310/// a 40-char commit SHA as a **codeload tarball** (`RemoteTarball`) —
311/// not a `git` resolution — whenever a flat HTTPS archive URL exists
312/// (`codeload_url`) and there's no `&path:` subdir selector. aube
313/// already *fetches* that tarball; emitting it as `RemoteTarball` makes
314/// the written lockfile match pnpm (codeload key + `version:` +
315/// `resolution: {tarball, gitHosted}`) instead of the divergent
316/// `<url>.git#<sha>` / `resolution: {type: git, repo, commit}` form.
317///
318/// Falls back to `Git` for: non-hosted or `git+ssh://` sources (no
319/// codeload URL — pnpm keeps those as `type: git` too), branch/tag refs
320/// that never pinned to a SHA, and `&path:` subpath selectors (a flat
321/// tarball can't address a repo subdirectory).
322fn 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
401/// Turn a raw `GitSource` (committish parsed from the user's
402/// specifier, empty `resolved`) into a fully-resolved one by either
403/// fetching a hosted-tarball over HTTPS (github / gitlab / bitbucket
404/// public reads, matching what npm `pacote` and pnpm
405/// `gitHostedTarballFetcher` do) or, for any other host or any
406/// codeload-unreachable case, falling back to `git ls-remote` +
407/// shallow clone. The materialized tree lives in a commit-keyed temp
408/// directory shared with install-time materialization, so the same
409/// extraction or clone is never repeated within a single `aube
410/// install`.
411///
412/// Hosted-tarball routing matches npm/pnpm semantics: the lockfile's
413/// stored `url` is canonical-identity only — even when it carries an
414/// SSH form the user has no key for, we re-derive an HTTPS URL from
415/// the `(host, owner, repo)` tuple at fetch time. Returns the
416/// original URL unchanged in `LocalSource::Git.url` so a subsequent
417/// `aube install` produces the same lockfile bytes (cross-tool
418/// compat with pnpm / npm / yarn).
419pub(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    // Use the HTTPS form when talking to git for hosted hosts — the
438    // lockfile-canonical `git+ssh://git@…` URL would dial SSH and
439    // fail for users with no `~/.ssh/`. Non-hosted URLs go through
440    // unchanged so SSH-only setups keep working.
441    let runtime_url = hosted
442        .as_ref()
443        .map(|h| h.https_url())
444        .unwrap_or_else(|| original_url.clone());
445
446    // Resolve the committish to a 40-char SHA. `git_resolve_ref`
447    // short-circuits on a SHA and shells `git ls-remote` for branch /
448    // tag / HEAD. Passing the rewritten HTTPS URL means hosted
449    // branch/tag refs are pinnable from a host with no SSH key
450    // configured.
451    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        // Only full SHAs survive — abbreviated user-written prefixes
458        // come back unchanged from `git_resolve_ref` and need to fall
459        // through to the clone path so `git checkout <prefix>` can
460        // expand them.
461        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    // Cache hit fast path: skip the HTTPS round-trip when a prior call
474    // (the resolver's earlier visit to this dep, or a previous install)
475    // already populated the codeload cache. Mirrors `git_shallow_clone`'s
476    // top-of-function reuse check.
477    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    // Try the codeload fast path when applicable. `client` is None for
516    // resolve paths that don't have a registry client wired up
517    // (`aube import`'s lockfile-only flow); those just fall through.
518    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                // Extract into the commit-keyed cache and read the
522                // (possibly subpath-scoped) `package.json` like the
523                // clone path does. Return the original lockfile URL
524                // in `LocalSource::Git.url` for cross-tool round-trip.
525                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                        // Mirror the installer: a corrupt or
582                        // unexpectedly-shaped tarball (CDN hiccup,
583                        // unsafe-path rejection, Windows symlink) falls
584                        // through to `git clone`, which inherits the
585                        // user's git credential helper and can write
586                        // symlinks via git's admin-aware path.
587                        tracing::debug!(
588                            name,
589                            "codeload extract failed, falling back to git clone: {e}",
590                        );
591                    }
592                }
593            }
594            Err(e) => {
595                // Codeload 404s on private repos (it doesn't accept
596                // npm-registry auth) — fall through to `git
597                // clone`, which inherits the user's git credential
598                // helper / ssh keys for private access.
599                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    // Fallback: shallow git clone over the rewritten HTTPS URL (or the
609    // original URL for non-hosted hosts). Same `spawn_blocking` dance
610    // the original implementation used.
611    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
647/// Fetch a remote tarball URL, compute its sha512 integrity, and read
648/// the enclosed `package.json` for version + transitive deps. Returns
649/// a fully-populated `LocalSource::RemoteTarball` alongside the
650/// manifest tuple the resolver's local-dep branch expects.
651pub(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        // Walk the tarball once to pull out the top-level
671        // `package.json` (wrapper name varies, so the helper looks
672        // at the first path component's basename, not a hardcoded
673        // `package/package.json`).
674        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        // packages/app importer declares `file:../../vendor-dir`.
702        // Expected result: `vendor-dir` (workspace-root relative),
703        // collapsed down from the intermediate
704        // `packages/app/../../vendor-dir` form.
705        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        // Both importers end up pointing at the same on-disk path —
716        // the encoded dep_path must match so they de-dupe in the
717        // lockfile.
718        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        // `..` at the root of the project is still meaningful —
749        // don't silently drop it.
750        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        // Backslash-separated input (as Windows would store) must
759        // hash and render the same as a forward-slash equivalent so
760        // a checked-in lockfile resolves identically on either OS.
761        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                // pnpm keys the lockfile entry by this flat tarball URL.
904                assert_eq!(t.url, codeload);
905                assert_eq!(t.integrity, "sha512-deadbeef");
906                assert!(t.git_hosted, "codeload archives must flag gitHosted");
907                // The specifier the writer threads into snapshot deps and
908                // the packages key is exactly the codeload URL.
909                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        // A flat tarball can't address a repo subdirectory, so pnpm keeps
922        // `&path:` deps as `type: git`. We must too.
923        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        // Non-hosted / ssh-only sources have no flat archive URL; pnpm
944        // records those as `type: git` and so do we.
945        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}