Skip to main content

aube_lockfile/
source.rs

1use std::path::{Path, PathBuf};
2
3/// Non-registry source for a locked package.
4///
5/// When a package comes from a local path (via `file:` or `link:` in
6/// `package.json`) it doesn't have a tarball URL or integrity hash, so we
7/// record the source separately and let the linker materialize it
8/// on-the-fly.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum LocalSource {
11    /// `file:<dir>` — a directory on disk whose contents should be
12    /// hardlink-copied into the virtual store like a normal package.
13    /// Path is stored relative to the project root.
14    Directory(PathBuf),
15    /// `file:<tarball>` — a `.tgz` on disk, extracted into the virtual
16    /// store the same way we extract registry tarballs.
17    Tarball(PathBuf),
18    /// `link:<dir>` — a plain symlink into `node_modules/<name>`, never
19    /// materialized into the virtual store. Transitive deps are the
20    /// target's responsibility.
21    Link(PathBuf),
22    /// `portal:<dir>` — a Yarn Berry package portal. The target is a
23    /// package on disk, but unlike `link:` its dependencies are still
24    /// modeled in the lockfile graph.
25    Portal(PathBuf),
26    /// `exec:<script>` — a Yarn Berry generator script. The script is
27    /// executed at fetch time and writes the package files into a
28    /// generated build directory.
29    Exec(PathBuf),
30    /// `git+https://`, `git+ssh://`, `github:user/repo`, etc. — a
31    /// remote git repo. Cloned at fetch time and imported like a
32    /// `file:` directory. `url` is the normalized clone URL (what
33    /// gets passed to `git clone`). `committish` is the user-written
34    /// ref after `#` (branch, tag, or commit; `None` means HEAD).
35    /// `resolved` is the 40-char commit SHA that `git ls-remote`
36    /// pinned the ref to — the lockfile records this so repeat
37    /// installs reproduce bit-for-bit.
38    Git(GitSource),
39    /// `https://example.com/pkg.tgz` — a remote tarball URL. Fetched
40    /// once at resolve time so the resolver can read the enclosed
41    /// `package.json` for version + transitive deps and pin the
42    /// sha512 integrity. `integrity` stays empty on freshly-parsed
43    /// specifiers and is filled in by the resolver after download.
44    RemoteTarball(RemoteTarballSource),
45}
46
47/// A remote tarball dependency spec. See [`LocalSource::RemoteTarball`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RemoteTarballSource {
50    pub url: String,
51    pub integrity: String,
52    pub git_hosted: bool,
53}
54
55/// A git dependency spec. See [`LocalSource::Git`].
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct GitSource {
58    pub url: String,
59    pub committish: Option<String>,
60    pub resolved: String,
61    /// SHA-512 SRI of the hosted tarball bytes when the git source was
62    /// fetched through a codeload-style archive. Plain git-clone sources
63    /// leave this unset because git object IDs verify the checkout.
64    pub integrity: Option<String>,
65    /// pnpm `&path:/sub/dir` selector — when set, only this
66    /// subdirectory of the cloned repo is treated as the package
67    /// root. Stored without leading slash so dep_path hashes are
68    /// stable regardless of whether the user wrote `path:/x` or
69    /// `path:x`.
70    pub subpath: Option<String>,
71}
72
73pub fn git_commits_match(left: &str, right: &str) -> bool {
74    if left.eq_ignore_ascii_case(right) {
75        return true;
76    }
77    let left = left.trim();
78    let right = right.trim();
79    if left.len().min(right.len()) < 7
80        || !left.bytes().all(|b| b.is_ascii_hexdigit())
81        || !right.bytes().all(|b| b.is_ascii_hexdigit())
82    {
83        return false;
84    }
85    let left = left.to_ascii_lowercase();
86    let right = right.to_ascii_lowercase();
87    (left.len() == 40 && right.len() < 40 && left.starts_with(&right))
88        || (right.len() == 40 && left.len() < 40 && right.starts_with(&left))
89}
90
91impl LocalSource {
92    /// The original path (relative to the project root) the user wrote
93    /// in `package.json`. `None` for non-path sources like git.
94    pub fn path(&self) -> Option<&Path> {
95        match self {
96            LocalSource::Directory(p)
97            | LocalSource::Tarball(p)
98            | LocalSource::Link(p)
99            | LocalSource::Portal(p)
100            | LocalSource::Exec(p) => Some(p),
101            LocalSource::Git(_) | LocalSource::RemoteTarball(_) => None,
102        }
103    }
104
105    /// The protocol kind (`"file"` / `"link"` / `"git"` / `"url"`).
106    pub fn kind_str(&self) -> &'static str {
107        match self {
108            LocalSource::Directory(_) | LocalSource::Tarball(_) => "file",
109            LocalSource::Link(_) => "link",
110            LocalSource::Portal(_) => "portal",
111            LocalSource::Exec(_) => "exec",
112            LocalSource::Git(_) => "git",
113            LocalSource::RemoteTarball(_) => "url",
114        }
115    }
116
117    /// Whether this source is pinned to immutable, globally
118    /// reproducible content and can therefore be shared across
119    /// projects inside aube's global virtual store, exactly like a
120    /// registry package.
121    ///
122    /// `Git` is pinned to a 40-char commit SHA and `RemoteTarball` to
123    /// a fetched URL (and, once resolved, an integrity hash), so two
124    /// projects that depend on the same one resolve to the same files.
125    /// `file:` / `link:` / `portal:` / `exec:` all resolve against a
126    /// path inside the depending project, so they stay per-project and
127    /// are never promoted into the shared store.
128    ///
129    /// Load-bearing for global-virtual-store correctness: a registry
130    /// package materialized into the shared store points its
131    /// dependency siblings at the hashed global path
132    /// (`virtual_store_subdir(dep_path)`). If one of those deps were a
133    /// git/tarball source that only ever landed in the per-project
134    /// `.aube/`, the sibling symlink would dangle and Node's module
135    /// walk would silently fall back to some unrelated `<name>` found
136    /// higher up the tree.
137    pub fn is_globally_shareable(&self) -> bool {
138        matches!(self, LocalSource::Git(_) | LocalSource::RemoteTarball(_))
139    }
140
141    /// The path as a POSIX-style string with forward-slash separators.
142    /// `Path::display()` and `to_string_lossy()` honor the host's
143    /// separator (backslash on Windows), which would make `dep_path`
144    /// hashes and lockfile `specifier:` strings non-portable: the
145    /// same `file:./some/dir` would render as `some\dir` on Windows
146    /// and `some/dir` on Unix, producing two different hashes for
147    /// the same logical target. Always rendering with `/` keeps
148    /// lockfiles cross-platform identical.
149    pub fn path_posix(&self) -> String {
150        self.path()
151            .map(|p| p.to_string_lossy().replace('\\', "/"))
152            .unwrap_or_default()
153    }
154
155    /// Canonical specifier string as pnpm writes it in the `packages:`
156    /// and `snapshots:` keys (post-`<name>@` part). For `file:` /
157    /// `link:` this is `file:./vendor/foo` / `link:../sibling`. For
158    /// `git`, pnpm uses the resolved form `<url>#<commit>` (no
159    /// `git+` prefix) because the lockfile pins to the exact commit
160    /// regardless of what the user wrote. Always emits POSIX
161    /// separators so the resulting lockfile is portable.
162    pub fn specifier(&self) -> String {
163        match self {
164            LocalSource::Git(g) => match &g.subpath {
165                Some(sub) => format!("{}#{}&path:/{}", g.url, g.resolved, sub),
166                None => format!("{}#{}", g.url, g.resolved),
167            },
168            LocalSource::RemoteTarball(t) => t.url.clone(),
169            _ => format!("{}:{}", self.kind_str(), self.path_posix()),
170        }
171    }
172
173    /// Internal FS-safe dep_path used as the key in
174    /// `LockfileGraph.packages` and as the `.aube/` subdir name.
175    ///
176    /// Distinct paths must map to distinct keys (otherwise the
177    /// linker would silently mix files between two local packages),
178    /// and the result must be a single filesystem component — no
179    /// `/`, `\`, `:`, or `..`. Ad-hoc character substitution trips
180    /// over cases like `../vendor` vs `__/vendor` or `a.b` vs `a_b`
181    /// collapsing to the same string, so we hash the raw path bytes
182    /// and suffix the first 16 hex chars (64 bits — more than enough
183    /// to avoid collisions inside a single project).
184    ///
185    /// The hash input is the POSIX-form path string so a checked-in
186    /// lockfile resolves to the same key regardless of which
187    /// platform ran `aube install`.
188    pub fn dep_path(&self, name: &str) -> String {
189        use sha2::{Digest, Sha256};
190        let mut hasher = Sha256::new();
191        match self {
192            LocalSource::Git(g) => {
193                hasher.update(g.url.as_bytes());
194                hasher.update(b"#");
195                hasher.update(g.resolved.as_bytes());
196                if let Some(sub) = &g.subpath {
197                    hasher.update(b"&path:/");
198                    hasher.update(sub.as_bytes());
199                }
200            }
201            LocalSource::RemoteTarball(t) => {
202                hasher.update(t.url.as_bytes());
203            }
204            _ => hasher.update(self.path_posix().as_bytes()),
205        }
206        let digest = hasher.finalize();
207        let short: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
208        format!("{name}@{}+{short}", self.kind_str())
209    }
210
211    /// Classify a user-written `file:` / `link:` specifier against the
212    /// project root. Returns `None` if `spec` isn't a local specifier.
213    /// Resolves the target path relative to `project_root`; a `file:`
214    /// target that resolves to a `.tgz` / `.tar.gz` on disk is treated
215    /// as a tarball, anything else as a directory.
216    pub fn parse(spec: &str, project_root: &Path) -> Option<Self> {
217        // Check git first so URLs like `https://host/user/repo.git`
218        // aren't swallowed by the broader bare-http tarball check
219        // below.
220        if let Some((url, committish, subpath)) = parse_git_spec(spec) {
221            // `resolved` is filled in by the resolver after running
222            // `git ls-remote`. A lockfile round-trip that never
223            // re-resolves will leave this empty, which is the sentinel
224            // the resolver checks for before calling ls-remote.
225            return Some(LocalSource::Git(GitSource {
226                url,
227                committish,
228                resolved: String::new(),
229                integrity: None,
230                subpath,
231            }));
232        }
233        // Any remaining bare `http(s)://` URL is a remote tarball.
234        // npm semantics treat *all* non-git HTTP URLs in a dependency
235        // value as tarball URLs, so services that serve tarballs from
236        // URLs without a `.tgz` extension (pkg.pr.new, GitHub
237        // codeload, etc.) classify correctly here.
238        if Self::looks_like_remote_tarball_url(spec) {
239            return Some(LocalSource::RemoteTarball(RemoteTarballSource {
240                url: spec.to_string(),
241                integrity: String::new(),
242                git_hosted: false,
243            }));
244        }
245        let (kind, rest) = if let Some(r) = spec.strip_prefix("file:") {
246            ("file", r)
247        } else if let Some(r) = spec.strip_prefix("link:") {
248            ("link", r)
249        } else if let Some(r) = spec.strip_prefix("portal:") {
250            ("portal", r)
251        } else if let Some(r) = spec.strip_prefix("exec:") {
252            return Some(LocalSource::Exec(PathBuf::from(r)));
253        } else {
254            return None;
255        };
256        let rel = PathBuf::from(rest);
257        let abs = project_root.join(&rel);
258        if kind == "link" {
259            return Some(LocalSource::Link(rel));
260        }
261        if kind == "portal" {
262            return Some(LocalSource::Portal(rel));
263        }
264        if abs.is_file() && Self::path_looks_like_tarball(&rel) {
265            return Some(LocalSource::Tarball(rel));
266        }
267        Some(LocalSource::Directory(rel))
268    }
269
270    /// Whether a specifier looks like a direct HTTP(S) URL that should
271    /// be fetched as a tarball. Per npm semantics, *any* `http://` or
272    /// `https://` URL in a dependency value is a tarball URL — services
273    /// like pkg.pr.new, GitHub codeload, and private registries with
274    /// auth-token query strings serve tarballs from URLs that don't
275    /// carry a `.tgz` extension. Git URLs must already have been
276    /// ruled out by the caller (see [`parse_git_spec`]) so a
277    /// `.git`-suffixed URL doesn't get misclassified here.
278    pub fn looks_like_remote_tarball_url(spec: &str) -> bool {
279        spec.starts_with("https://") || spec.starts_with("http://")
280    }
281
282    pub fn path_looks_like_tarball(path: &Path) -> bool {
283        let name = match path.file_name().and_then(|n| n.to_str()) {
284            Some(n) => n,
285            None => return false,
286        };
287        let lower = name.to_ascii_lowercase();
288        lower.ends_with(".tgz") || lower.ends_with(".tar.gz")
289    }
290}
291
292/// Resolve a transitive dependency's recorded spec *value* to the same
293/// `dep_path` key the lockfile parser assigns the target package, for
294/// the two content-pinned source kinds that get shared globally (git
295/// and remote tarball).
296///
297/// pnpm records a git / remote-tarball dependency inside a snapshot's
298/// `dependencies:` map by its *resolved spec* — `<url>#<sha>` for git,
299/// the tarball URL for remote tarballs (e.g. request-promise-core lists
300/// `request: https://github.com/request/request.git#<sha>`). The parser,
301/// however, keys the package itself under [`LocalSource::dep_path`] — the
302/// short `name@git+<hash>` / `name@url+<hash>` form. A naive
303/// `format!("{name}@{value}")` lookup therefore points at a key that was
304/// never inserted into the graph, so:
305///
306/// * the linker's sibling symlink dangles (Node resolves the wrong
307///   `<name>` or none — the request-promise-core crash), and
308/// * the graph hasher skips the child entirely, so neither its content
309///   fingerprint nor its build/engine taint cascades into the parent's
310///   global-virtual-store hash.
311///
312/// Mirror `pnpm::read::push_direct`'s keying so the resolved value lands
313/// on the exact `dep_path` the package was materialized under. Returns
314/// `None` for every other value (plain semver, `file:`, `link:`, npm
315/// aliases, …) so callers keep the verbatim `name@value` key those
316/// already resolve correctly with.
317pub fn shared_local_dep_path(dep_name: &str, dep_value: &str) -> Option<String> {
318    // pnpm appends a `(peer@ver)` suffix to some spec values; the parser
319    // strips it before classifying the source, so strip it here too.
320    //
321    // This MUST stay byte-for-byte identical to `pnpm::read::push_direct`'s
322    // `classify_version` (`info.version.split('(').next()`), which is what
323    // produced the `dep_path` keys in `graph.packages` we're matching
324    // against. A "smarter" strip (e.g. only a trailing `(peer@…)` via
325    // rfind) would *desync* the two: any value with a non-peer `(` would
326    // hash differently here than the key the parser inserted, silently
327    // re-skipping that child in the linker and graph hasher. If the
328    // first-`(` truncation is ever wrong for a real spec, fix it in
329    // `push_direct` and here together — never in isolation.
330    let classify = dep_value.split('(').next().unwrap_or(dep_value);
331    match LocalSource::parse(classify, Path::new("")) {
332        Some(LocalSource::Git(mut git)) => {
333            // Snapshot specs carry the pinned commit after `#`, which
334            // `parse` records as `committish` rather than `resolved`. The
335            // package was keyed with that commit promoted to `resolved`
336            // (see `push_direct`), so promote it here too — otherwise the
337            // `url#resolved` hash diverges from the package's dep_path.
338            if git.resolved.is_empty() {
339                git.resolved = git.committish.take()?;
340            }
341            Some(LocalSource::Git(git).dep_path(dep_name))
342        }
343        Some(tarball @ LocalSource::RemoteTarball(_)) => Some(tarball.dep_path(dep_name)),
344        _ => None,
345    }
346}
347
348/// Parse a git dependency specifier into `(clone_url, committish)`.
349///
350/// Recognized forms:
351/// - `git+https://host/user/repo.git[#ref]`
352/// - `git+ssh://git@host/user/repo.git[#ref]`
353/// - `git://host/user/repo.git[#ref]`
354/// - `https://host/user/repo.git[#ref]` (only when ending in `.git`)
355/// - `user@host:path[.git][#ref]` (scp-form, only for github.com / gitlab.com /
356///   bitbucket.org — matches pnpm 11 behavior, where unknown SCP hosts are
357///   treated as local paths) → `ssh://user@host/path[.git]`
358/// - `github:user/repo[#ref]` → `https://github.com/user/repo.git`
359/// - `gitlab:user/repo[#ref]` → `https://gitlab.com/user/repo.git`
360/// - `bitbucket:user/repo[#ref]` → `https://bitbucket.org/user/repo.git`
361/// - `user/repo[#ref]` (bare GitHub shorthand, npm/pnpm compat)
362///   → `https://github.com/user/repo.git`
363///
364/// Returns `None` for any specifier that doesn't look like a git URL,
365/// so the caller can fall through to other protocol parsers.
366pub fn parse_git_spec(spec: &str) -> Option<(String, Option<String>, Option<String>)> {
367    let (body, committish, subpath) = match spec.find('#') {
368        Some(idx) => {
369            let (c, s) = parse_git_fragment(&spec[idx + 1..]);
370            (&spec[..idx], c, s)
371        }
372        None => (spec, None, None),
373    };
374    let is_bare_transport = body.starts_with("https://")
375        || body.starts_with("http://")
376        || body.starts_with("ssh://")
377        || body.starts_with("file://");
378    let url = if let Some(rest) = body.strip_prefix("git+") {
379        // `git+` explicitly tags the URL as git, so the `.git`
380        // suffix is optional (GitHub/GitLab accept both forms).
381        rest.to_string()
382    } else if body.starts_with("git://") {
383        body.to_string()
384    } else if let Some(scp) = parse_scp_url(body) {
385        scp
386    } else if let Some(path) = body.strip_prefix("github:") {
387        format!("https://github.com/{path}.git")
388    } else if let Some(path) = body.strip_prefix("gitlab:") {
389        format!("https://gitlab.com/{path}.git")
390    } else if let Some(path) = body.strip_prefix("bitbucket:") {
391        format!("https://bitbucket.org/{path}.git")
392    } else if is_bare_transport && body.ends_with(".git") {
393        body.to_string()
394    } else if is_bare_transport
395        && committish
396            .as_deref()
397            .is_some_and(|c| c.len() == 40 && c.chars().all(|ch| ch.is_ascii_hexdigit()))
398    {
399        // Lockfile round-trip form: `specifier()` writes the stored
400        // URL verbatim plus `#<sha>`. URLs that dropped the `git+`
401        // prefix (and happen to lack `.git`) are disambiguated from
402        // plain tarball URLs by the 40-hex committish suffix.
403        body.to_string()
404    } else if is_bare_github_shorthand(body) {
405        // npm/pnpm bare GitHub shorthand: `user/repo` expands to
406        // `github:user/repo`. Placed last so all explicit URL/scheme
407        // forms above shadow it.
408        format!("https://github.com/{body}.git")
409    } else {
410        return None;
411    };
412    Some((url, committish, subpath))
413}
414
415/// `user/repo` — a single `/`, both segments non-empty, ASCII
416/// alphanumeric + `_.-` only, owner doesn't start with `.` so
417/// single-component relative paths (`./repo`, `../repo`) are rejected.
418/// Excludes scoped npm names (`@scope/pkg`) and file paths. Other
419/// URL/SCP forms are ruled out by placement order in `parse_git_spec`.
420fn is_bare_github_shorthand(body: &str) -> bool {
421    let Some((owner, repo)) = body.split_once('/') else {
422        return false;
423    };
424    !owner.is_empty()
425        && !owner.starts_with('.')
426        && !repo.is_empty()
427        && !repo.contains('/')
428        && owner
429            .bytes()
430            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
431        && repo
432            .bytes()
433            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
434}
435
436/// A git URL that maps to one of the three "hosted" providers npm /
437/// pnpm both special-case (github / gitlab / bitbucket). For these
438/// hosts a public read can be served as a flat HTTPS tarball over
439/// `codeload.github.com` (or each host's equivalent), bypassing `git`
440/// entirely. The lockfile's stored URL is canonical-identity only —
441/// pnpm and npm both re-derive the fetch URL from `(host, owner,
442/// repo)` on every install rather than dialing whatever scheme
443/// happens to be in `resolved:`.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct HostedGit {
446    pub host: HostedGitHost,
447    pub owner: String,
448    pub repo: String,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum HostedGitHost {
453    GitHub,
454    GitLab,
455    Bitbucket,
456}
457
458impl HostedGit {
459    /// `https://github.com/<owner>/<repo>.git` — the form `git fetch`
460    /// can dial without an SSH key. Used as the runtime fetch URL when
461    /// the lockfile's stored URL is `git+ssh://git@…` (npm canonical
462    /// identity) but the actual install host has no SSH configured.
463    pub fn https_url(&self) -> String {
464        let host = self.host.host_domain();
465        format!("https://{host}/{}/{}.git", self.owner, self.repo)
466    }
467
468    /// `https://codeload.github.com/<owner>/<repo>/tar.gz/<sha>` (or
469    /// each host's equivalent) — a flat HTTPS tarball at the given
470    /// commit. Returns `None` unless `committish` is a 40-char hex
471    /// SHA, since the codeload path can't be verified after extraction
472    /// without `.git/` metadata. Branch / tag names round-trip through
473    /// `git ls-remote` to get pinned to a SHA first.
474    pub fn tarball_url(&self, committish: &str) -> Option<String> {
475        if committish.len() != 40 || !committish.chars().all(|c| c.is_ascii_hexdigit()) {
476            return None;
477        }
478        let sha = committish.to_ascii_lowercase();
479        Some(match self.host {
480            HostedGitHost::GitHub => format!(
481                "https://codeload.github.com/{}/{}/tar.gz/{sha}",
482                self.owner, self.repo
483            ),
484            HostedGitHost::GitLab => format!(
485                "https://gitlab.com/{}/{}/-/archive/{sha}/{}-{sha}.tar.gz",
486                self.owner, self.repo, self.repo
487            ),
488            HostedGitHost::Bitbucket => format!(
489                "https://bitbucket.org/{}/{}/get/{sha}.tar.gz",
490                self.owner, self.repo
491            ),
492        })
493    }
494}
495
496impl HostedGitHost {
497    fn from_domain(domain: &str) -> Option<Self> {
498        match domain {
499            "github.com" => Some(HostedGitHost::GitHub),
500            "gitlab.com" => Some(HostedGitHost::GitLab),
501            "bitbucket.org" => Some(HostedGitHost::Bitbucket),
502            _ => None,
503        }
504    }
505
506    pub fn host_domain(self) -> &'static str {
507        match self {
508            HostedGitHost::GitHub => "github.com",
509            HostedGitHost::GitLab => "gitlab.com",
510            HostedGitHost::Bitbucket => "bitbucket.org",
511        }
512    }
513}
514
515/// Parse a clone URL — in any form `parse_git_spec` accepts as input
516/// or produces as output — into its `(host, owner, repo)` components,
517/// when the host is one of the three providers npm / pnpm route
518/// through HTTPS tarballs. Returns `None` for any other host (including
519/// self-hosted GitLab / Gitea / Bitbucket Data Center): those still
520/// need a real `git clone` because no codeload-style HTTP archive is
521/// available.
522///
523/// Accepts:
524/// - `https://github.com/owner/repo[.git]`
525/// - `git+https://github.com/owner/repo[.git]`
526/// - `git://github.com/owner/repo[.git]`
527/// - `ssh://git@github.com/owner/repo[.git]`
528/// - `git+ssh://git@github.com/owner/repo[.git]` (npm canonical lockfile form)
529/// - `git@github.com:owner/repo[.git]` (scp shorthand, in case a caller
530///   parses raw lockfile fields without going through `parse_git_spec`)
531pub fn parse_hosted_git(url: &str) -> Option<HostedGit> {
532    let body = url.strip_prefix("git+").unwrap_or(url);
533    let after_scheme = if let Some(rest) = body.strip_prefix("https://") {
534        rest
535    } else if let Some(rest) = body.strip_prefix("http://") {
536        rest
537    } else if let Some(rest) = body.strip_prefix("ssh://") {
538        rest
539    } else if let Some(rest) = body.strip_prefix("git://") {
540        rest
541    } else {
542        // scp shorthand `user@host:path` — not produced by parse_git_spec
543        // but accepted defensively in case a raw lockfile string ever
544        // bypasses it.
545        let scp_path = parse_scp_url(body)?;
546        return parse_hosted_git(&scp_path);
547    };
548    // Strip optional `user@` (always `git@` for hosted forms).
549    let host_and_path = match after_scheme.split_once('@') {
550        Some((_, rest)) => rest,
551        None => after_scheme,
552    };
553    let (host, path) = host_and_path.split_once('/')?;
554    let host = HostedGitHost::from_domain(host)?;
555    // Take exactly two path segments: owner and repo. Anything beyond
556    // (subgroup-style GitLab paths) doesn't have a stable HTTPS tarball
557    // form on the three providers we care about, so refuse and let the
558    // caller fall back to clone.
559    let mut segs = path.splitn(3, '/');
560    let owner = segs.next()?;
561    let repo = segs.next()?;
562    if owner.is_empty() || repo.is_empty() || segs.next().is_some() {
563        return None;
564    }
565    let repo = repo
566        .strip_suffix(".git")
567        .unwrap_or(repo)
568        .trim_end_matches('/');
569    if repo.is_empty() {
570        return None;
571    }
572    Some(HostedGit {
573        host,
574        owner: owner.to_string(),
575        repo: repo.to_string(),
576    })
577}
578
579fn parse_scp_url(body: &str) -> Option<String> {
580    if body.contains("://") {
581        return None;
582    }
583    let colon = body.find(':')?;
584    let before = &body[..colon];
585    let path = &body[colon + 1..];
586    if before.is_empty() || path.is_empty() {
587        return None;
588    }
589    if path.starts_with('/') {
590        return None;
591    }
592    let at = before.find('@')?;
593    let user = &before[..at];
594    let host = &before[at + 1..];
595    if user.is_empty() || host.is_empty() || host.contains('/') || host.contains('@') {
596        return None;
597    }
598    // pnpm 11 only resolves SCP-form as hosted Git for the three known
599    // providers; other hosts (e.g. `git@example.com:foo/bar.git`) are
600    // treated as local paths, and `host:path` without a user errors.
601    if !matches!(host, "github.com" | "gitlab.com" | "bitbucket.org") {
602        return None;
603    }
604    Some(format!("ssh://{user}@{host}/{path}"))
605}
606
607/// Normalize git URL fragments used by npm-compatible lockfiles.
608///
609/// Plain git accepts `#<ref>`, while npm and Yarn Berry also write
610/// key/value fragments such as `#commit=<sha>` for pinned git deps.
611/// Downstream code passes this value directly to `git ls-remote` and
612/// `git checkout`, so strip the selector key here and keep only the
613/// actual ref name or SHA.
614pub(crate) fn normalize_git_fragment(fragment: &str) -> Option<String> {
615    parse_git_fragment(fragment).0
616}
617
618/// Parse a git URL fragment into `(committish, subpath)`. Handles the
619/// pnpm/hosted-git-info form `<ref>&path:/sub/dir` (the `path:` key
620/// uses a colon, not `=`, by historical convention) as well as the
621/// `key=value` form npm/Yarn Berry write. Unknown selectors are
622/// ignored. Subpath is returned without leading slash so the caller
623/// can join it with a clone dir without tripping the absolute-path
624/// branch of `Path::join`.
625pub(crate) fn parse_git_fragment(fragment: &str) -> (Option<String>, Option<String>) {
626    if fragment.is_empty() {
627        return (None, None);
628    }
629
630    let mut fallback: Option<&str> = None;
631    let mut preferred: Option<&str> = None;
632    let mut subpath: Option<String> = None;
633    for part in fragment.split('&') {
634        if part.is_empty() {
635            continue;
636        }
637        // Try `key=value` first; fall back to `key:value` only for
638        // the small set of selectors we actually handle below. A tag
639        // name with a colon (e.g. `release:2026-01`) is left alone —
640        // and `semver:^1.0.0` stays as a literal ref so `ls-remote`
641        // surfaces an explicit error rather than silently HEAD-ing.
642        let split = part.split_once('=').or_else(|| {
643            part.split_once(':')
644                .filter(|(k, _)| matches!(*k, "commit" | "tag" | "head" | "branch" | "path"))
645        });
646        let (key, value) = split.unwrap_or(("", part));
647        if value.is_empty() {
648            continue;
649        }
650        match key {
651            "commit" => {
652                preferred.get_or_insert(value);
653            }
654            "tag" | "head" | "branch" => {
655                fallback.get_or_insert(value);
656            }
657            "path" => {
658                // Strip leading slashes (pnpm writes `path:/sub`) and
659                // reject any `..` / `.` component. Without this, a
660                // crafted spec like `&path:/../../etc` would let the
661                // resolver and installer escape the clone dir and
662                // import an arbitrary host directory into the store.
663                if subpath.is_some() {
664                    // First-wins, matching the other selectors above.
665                    continue;
666                }
667                let trimmed = value.trim_start_matches('/');
668                if trimmed.is_empty() {
669                    continue;
670                }
671                if trimmed
672                    .split('/')
673                    .any(|c| c.is_empty() || c == "." || c == "..")
674                {
675                    continue;
676                }
677                subpath = Some(trimmed.to_string());
678            }
679            "" => {
680                fallback.get_or_insert(value);
681            }
682            _ => {}
683        }
684    }
685
686    (preferred.or(fallback).map(ToString::to_string), subpath)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn matches_https_tgz() {
695        assert!(LocalSource::looks_like_remote_tarball_url(
696            "https://example.com/pkg-1.0.0.tgz"
697        ));
698    }
699
700    #[test]
701    fn matches_http_tar_gz() {
702        assert!(LocalSource::looks_like_remote_tarball_url(
703            "http://example.com/pkg-1.0.0.tar.gz"
704        ));
705    }
706
707    #[test]
708    fn strips_fragment_before_suffix_check() {
709        assert!(LocalSource::looks_like_remote_tarball_url(
710            "https://example.com/pkg-1.0.0.tgz#sha512-abc"
711        ));
712    }
713
714    #[test]
715    fn strips_query_string_before_suffix_check() {
716        // Auth-token URLs from private registries (JFrog, Nexus,
717        // CodeArtifact, …) routinely trail `?token=…` after the
718        // filename. Must still classify as a tarball URL.
719        assert!(LocalSource::looks_like_remote_tarball_url(
720            "https://registry.example.com/pkg/-/pkg-1.0.0.tgz?token=abc"
721        ));
722        assert!(LocalSource::looks_like_remote_tarball_url(
723            "https://example.com/pkg-1.0.0.tar.gz?v=2&signed=1"
724        ));
725    }
726
727    #[test]
728    fn matches_bare_http_url_without_tarball_suffix() {
729        // pkg.pr.new serves tarballs from URLs without a `.tgz`
730        // extension; npm treats all non-git http(s) URLs as tarball
731        // URLs, so these must classify as remote tarballs.
732        assert!(LocalSource::looks_like_remote_tarball_url(
733            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935"
734        ));
735        assert!(LocalSource::looks_like_remote_tarball_url(
736            "https://codeload.github.com/user/repo/tar.gz/main"
737        ));
738    }
739
740    #[test]
741    fn git_commits_match_only_allows_full_sha_prefix_pairs() {
742        let full = "abcdef0123456789abcdef0123456789abcdef01";
743        assert!(git_commits_match(full, "abcdef0"));
744        assert!(git_commits_match("abcdef0", full));
745        assert!(git_commits_match(full, full));
746        assert!(!git_commits_match("abcdef0", "abcdef012"));
747        assert!(!git_commits_match(full, "abcdef1"));
748        assert!(!git_commits_match("main", full));
749    }
750
751    #[test]
752    fn rejects_non_http_schemes() {
753        assert!(!LocalSource::looks_like_remote_tarball_url(
754            "ftp://example.com/pkg.tgz"
755        ));
756        assert!(!LocalSource::looks_like_remote_tarball_url(
757            "git://example.com/repo.git"
758        ));
759    }
760
761    #[test]
762    fn parse_classifies_bare_http_url_as_remote_tarball() {
763        use std::path::Path;
764        let parsed = LocalSource::parse(
765            "https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@904b935",
766            Path::new(""),
767        );
768        assert!(matches!(parsed, Some(LocalSource::RemoteTarball(_))));
769    }
770
771    #[test]
772    fn parse_prefers_git_over_tarball_for_dot_git_url() {
773        use std::path::Path;
774        let parsed = LocalSource::parse("https://github.com/user/repo.git", Path::new(""));
775        assert!(matches!(parsed, Some(LocalSource::Git(_))));
776    }
777
778    #[test]
779    fn parse_classifies_exec_as_local_source() {
780        let parsed = LocalSource::parse("exec:./scripts/generate.js", Path::new(""));
781        assert_eq!(
782            parsed,
783            Some(LocalSource::Exec(PathBuf::from("./scripts/generate.js")))
784        );
785    }
786
787    #[test]
788    fn git_plus_https_without_dot_git_roundtrips_via_lockfile_form() {
789        // Initial parse: `git+https://…/repo` (no `.git`).
790        let (url, committish, subpath) = parse_git_spec("git+https://host/user/repo").unwrap();
791        assert_eq!(url, "https://host/user/repo");
792        assert_eq!(committish, None);
793        assert_eq!(subpath, None);
794
795        // After resolving, the serializer writes `<url>#<sha>` into
796        // the lockfile's importer `version:` field.
797        let sha = "abcdef0123456789abcdef0123456789abcdef01";
798        let source = LocalSource::Git(GitSource {
799            url: url.clone(),
800            committish: None,
801            resolved: sha.to_string(),
802            integrity: None,
803            subpath: None,
804        });
805        let lockfile_version = source.specifier();
806        assert_eq!(lockfile_version, format!("https://host/user/repo#{sha}"));
807
808        // Re-parse must recognize the bare URL because the 40-hex
809        // committish suffix unambiguously tags it as git.
810        let (round_url, round_committish, round_subpath) =
811            parse_git_spec(&lockfile_version).unwrap();
812        assert_eq!(round_url, "https://host/user/repo");
813        assert_eq!(round_committish.as_deref(), Some(sha));
814        assert_eq!(round_subpath, None);
815    }
816
817    #[test]
818    fn bare_https_without_dot_git_and_no_committish_is_not_git() {
819        // A plain `https://…` URL with no `.git` and no SHA could be
820        // anything (including a tarball); don't claim it.
821        assert!(parse_git_spec("https://example.com/pkg").is_none());
822    }
823
824    #[test]
825    fn github_shorthand_expands_and_roundtrips() {
826        let (url, _, _) = parse_git_spec("github:user/repo").unwrap();
827        assert_eq!(url, "https://github.com/user/repo.git");
828    }
829
830    #[test]
831    fn bare_user_repo_expands_to_github() {
832        let (url, committish, subpath) = parse_git_spec("kevva/is-negative").unwrap();
833        assert_eq!(url, "https://github.com/kevva/is-negative.git");
834        assert!(committish.is_none());
835        assert!(subpath.is_none());
836    }
837
838    #[test]
839    fn bare_user_repo_with_committish_preserved() {
840        let (url, committish, _) = parse_git_spec("kevva/is-negative#v1.0.0").unwrap();
841        assert_eq!(url, "https://github.com/kevva/is-negative.git");
842        assert_eq!(committish.as_deref(), Some("v1.0.0"));
843    }
844
845    #[test]
846    fn bare_scope_pkg_is_not_git_shorthand() {
847        // npm-style `@scope/pkg` is a registry name, not a GitHub shorthand.
848        assert!(parse_git_spec("@types/node").is_none());
849    }
850
851    #[test]
852    fn bare_relative_path_is_not_git_shorthand() {
853        // Single-component relative paths split as owner=".", owner="..",
854        // so owner-starts-with-`.` is the load-bearing guard here.
855        assert!(parse_git_spec("./repo").is_none());
856        assert!(parse_git_spec("../repo").is_none());
857        // Multi-component relative paths additionally fail the
858        // single-`/`-only guard.
859        assert!(parse_git_spec("./local/path").is_none());
860        assert!(parse_git_spec("../local/path").is_none());
861    }
862
863    #[test]
864    fn bare_path_with_extra_slashes_is_not_git_shorthand() {
865        // Real GitHub shorthand is exactly `user/repo` — anything with a
866        // second `/` is a path, not a shorthand.
867        assert!(parse_git_spec("path/with/slashes/extra").is_none());
868    }
869
870    #[test]
871    fn bare_scp_form_unknown_host_is_not_github_shorthand() {
872        // `user@host:repo.git` is scp form (handled or rejected above);
873        // the bare-shorthand branch must not pick it up.
874        assert!(parse_git_spec("user@host:repo.git").is_none());
875    }
876
877    #[test]
878    fn scp_form_recognized() {
879        let (url, committish, _) =
880            parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git").unwrap();
881        assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
882        assert!(committish.is_none());
883    }
884
885    #[test]
886    fn scp_form_with_ref_recognized() {
887        let (url, committish, _) =
888            parse_git_spec("git@github.com:EthanHenrickson/math-mcp.git#0.1.5").unwrap();
889        assert_eq!(url, "ssh://git@github.com/EthanHenrickson/math-mcp.git");
890        assert_eq!(committish.as_deref(), Some("0.1.5"));
891    }
892
893    #[test]
894    fn scp_form_bitbucket_recognized() {
895        let (url, _, _) = parse_git_spec("git@bitbucket.org:pnpmjs/git-resolver.git").unwrap();
896        assert_eq!(url, "ssh://git@bitbucket.org/pnpmjs/git-resolver.git");
897    }
898
899    #[test]
900    fn scp_form_unknown_host_rejected() {
901        // pnpm 11 treats `user@unknown-host:path` as a local path, not Git.
902        assert!(parse_git_spec("git@example.com:org/repo.git").is_none());
903        assert!(parse_git_spec("alice@host.example.com:org/repo.git").is_none());
904    }
905
906    #[test]
907    fn scp_form_without_user_rejected() {
908        // pnpm 11 errors on bare `host:path` as unsupported.
909        assert!(parse_git_spec("github.com:user/repo.git").is_none());
910    }
911
912    #[test]
913    fn commit_selector_fragment_normalizes_to_sha() {
914        let sha = "abcdef0123456789abcdef0123456789abcdef01";
915        let (url, committish, _) =
916            parse_git_spec(&format!("https://host/user/repo.git#commit={sha}")).unwrap();
917        assert_eq!(url, "https://host/user/repo.git");
918        assert_eq!(committish.as_deref(), Some(sha));
919    }
920
921    #[test]
922    fn named_selector_fragment_normalizes_to_ref() {
923        let (url, committish, _) = parse_git_spec("git+https://host/user/repo#tag=v1.2.3").unwrap();
924        assert_eq!(url, "https://host/user/repo");
925        assert_eq!(committish.as_deref(), Some("v1.2.3"));
926    }
927
928    #[test]
929    fn pnpm_path_subpath_extracted_from_fragment() {
930        // pnpm syntax: `<url>#<ref>&path:/<subdir>` selects a
931        // subdirectory of the cloned repo as the package root.
932        let (url, committish, subpath) =
933            parse_git_spec("github:org/dep#v0.1.4&path:/packages/special").unwrap();
934        assert_eq!(url, "https://github.com/org/dep.git");
935        assert_eq!(committish.as_deref(), Some("v0.1.4"));
936        assert_eq!(subpath.as_deref(), Some("packages/special"));
937    }
938
939    #[test]
940    fn path_subpath_roundtrips_via_specifier() {
941        let sha = "abcdef0123456789abcdef0123456789abcdef01";
942        let source = LocalSource::Git(GitSource {
943            url: "https://github.com/org/dep.git".to_string(),
944            committish: None,
945            resolved: sha.to_string(),
946            integrity: None,
947            subpath: Some("packages/special".to_string()),
948        });
949        let spec = source.specifier();
950        assert_eq!(
951            spec,
952            format!("https://github.com/org/dep.git#{sha}&path:/packages/special")
953        );
954        let (url, committish, subpath) = parse_git_spec(&spec).unwrap();
955        assert_eq!(url, "https://github.com/org/dep.git");
956        assert_eq!(committish.as_deref(), Some(sha));
957        assert_eq!(subpath.as_deref(), Some("packages/special"));
958    }
959
960    #[test]
961    fn parse_hosted_git_recognizes_canonical_forms() {
962        // All these point at the same (github.com, owner, repo) tuple
963        // and must map to the same HostedGit so the runtime fetch URL
964        // doesn't depend on which scheme the lockfile happens to record.
965        let canonical = HostedGit {
966            host: HostedGitHost::GitHub,
967            owner: "owner".to_string(),
968            repo: "repo".to_string(),
969        };
970        for spec in [
971            "https://github.com/owner/repo.git",
972            "https://github.com/owner/repo",
973            "http://github.com/owner/repo.git",
974            "git+https://github.com/owner/repo.git",
975            "git+https://github.com/owner/repo",
976            "git://github.com/owner/repo.git",
977            "ssh://git@github.com/owner/repo.git",
978            "git+ssh://git@github.com/owner/repo.git",
979            "git@github.com:owner/repo.git",
980        ] {
981            assert_eq!(
982                parse_hosted_git(spec).as_ref(),
983                Some(&canonical),
984                "spec {spec} should map to canonical HostedGit",
985            );
986        }
987    }
988
989    #[test]
990    fn parse_hosted_git_returns_none_for_non_hosted() {
991        // Self-hosted GitLab / Gitea / arbitrary hosts: no codeload
992        // template, so the codeload fast path doesn't apply.
993        for spec in [
994            "https://example.com/owner/repo.git",
995            "ssh://git@gitea.internal/owner/repo.git",
996            "git+ssh://git@gitlab.example.com/group/sub/repo.git",
997            "https://github.com/owner/repo/sub",
998            "https://github.com/owner",
999        ] {
1000            assert!(
1001                parse_hosted_git(spec).is_none(),
1002                "spec {spec} must not match a hosted provider",
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn hosted_tarball_url_only_for_full_sha() {
1009        let g = HostedGit {
1010            host: HostedGitHost::GitHub,
1011            owner: "o".to_string(),
1012            repo: "r".to_string(),
1013        };
1014        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1015        assert_eq!(
1016            g.tarball_url(sha).as_deref(),
1017            Some("https://codeload.github.com/o/r/tar.gz/abcdef0123456789abcdef0123456789abcdef01"),
1018        );
1019        // Branch / tag / abbreviated SHA don't take the fast path —
1020        // codeload accepts them but the wrapper-dir name varies and
1021        // we can't verify a non-SHA committish post-extraction.
1022        assert!(g.tarball_url("main").is_none());
1023        assert!(g.tarball_url("v1.2.3").is_none());
1024        assert!(g.tarball_url("abcdef0").is_none());
1025    }
1026
1027    #[test]
1028    fn hosted_tarball_url_per_provider() {
1029        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1030        let gitlab = HostedGit {
1031            host: HostedGitHost::GitLab,
1032            owner: "g".to_string(),
1033            repo: "r".to_string(),
1034        }
1035        .tarball_url(sha)
1036        .unwrap();
1037        assert!(gitlab.starts_with("https://gitlab.com/g/r/-/archive/"));
1038        assert!(gitlab.ends_with("/r-abcdef0123456789abcdef0123456789abcdef01.tar.gz"));
1039        let bitbucket = HostedGit {
1040            host: HostedGitHost::Bitbucket,
1041            owner: "g".to_string(),
1042            repo: "r".to_string(),
1043        }
1044        .tarball_url(sha)
1045        .unwrap();
1046        assert_eq!(
1047            bitbucket,
1048            "https://bitbucket.org/g/r/get/abcdef0123456789abcdef0123456789abcdef01.tar.gz",
1049        );
1050    }
1051
1052    #[test]
1053    fn hosted_https_url_normalizes() {
1054        let g = parse_hosted_git("git+ssh://git@github.com/owner/repo.git").unwrap();
1055        assert_eq!(g.https_url(), "https://github.com/owner/repo.git");
1056    }
1057
1058    #[test]
1059    fn path_traversal_components_in_subpath_are_rejected() {
1060        // `..` and `.` components would let a crafted spec escape the
1061        // clone dir at install time. The parser drops them so the
1062        // resolver/installer never see a traversal-laden subpath.
1063        let cases = [
1064            "github:org/dep#main&path:/../../etc",
1065            "github:org/dep#main&path:/packages/../../../etc",
1066            "github:org/dep#main&path:/./packages/foo",
1067            "github:org/dep#main&path:/packages//foo",
1068        ];
1069        for spec in cases {
1070            let (_, _, subpath) = parse_git_spec(spec).unwrap();
1071            assert_eq!(subpath, None, "spec should drop subpath: {spec}");
1072        }
1073    }
1074
1075    #[test]
1076    fn dep_path_distinguishes_subpaths_under_same_commit() {
1077        // Two packages from the same repo+commit but different
1078        // subdirs must hash to distinct dep_paths so the linker
1079        // doesn't collapse them.
1080        let sha = "abcdef0123456789abcdef0123456789abcdef01";
1081        let a = LocalSource::Git(GitSource {
1082            url: "https://example.com/r.git".to_string(),
1083            committish: None,
1084            resolved: sha.to_string(),
1085            integrity: None,
1086            subpath: Some("packages/a".to_string()),
1087        });
1088        let b = LocalSource::Git(GitSource {
1089            url: "https://example.com/r.git".to_string(),
1090            committish: None,
1091            resolved: sha.to_string(),
1092            integrity: None,
1093            subpath: Some("packages/b".to_string()),
1094        });
1095        assert_ne!(a.dep_path("dep"), b.dep_path("dep"));
1096    }
1097
1098    const SHARED_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
1099
1100    /// The dep_path the lockfile parser keys a git package under, given
1101    /// its normalized clone URL and pinned commit.
1102    fn git_key(url: &str, resolved: &str) -> String {
1103        LocalSource::Git(GitSource {
1104            url: url.to_string(),
1105            committish: None,
1106            resolved: resolved.to_string(),
1107            integrity: None,
1108            subpath: None,
1109        })
1110        .dep_path("request")
1111    }
1112
1113    /// The dep_path the lockfile parser keys a remote-tarball package
1114    /// under, given its fetch URL.
1115    fn tarball_key(url: &str) -> String {
1116        LocalSource::RemoteTarball(RemoteTarballSource {
1117            url: url.to_string(),
1118            integrity: String::new(),
1119            git_hosted: false,
1120        })
1121        .dep_path("request")
1122    }
1123
1124    #[test]
1125    fn shared_github_shorthand_maps_to_git_dep_path() {
1126        // A dependent records its git `request` via the `github:` spec,
1127        // but the package is keyed under the hashed `git+` dep_path. The
1128        // sibling symlink / hasher lookup must use that same key or it
1129        // dangles / silently skips the child.
1130        let got = shared_local_dep_path("request", &format!("github:request/request#{SHARED_SHA}"))
1131            .expect("github: spec is a shareable local source");
1132        assert_eq!(
1133            got,
1134            git_key("https://github.com/request/request.git", SHARED_SHA)
1135        );
1136        assert!(got.starts_with("request@git+"), "unexpected key: {got}");
1137    }
1138
1139    #[test]
1140    fn shared_git_url_and_shorthand_converge() {
1141        // Whether the dependent recorded the shorthand or the resolved
1142        // `<url>.git#<sha>` form, both must canonicalize to one key.
1143        let from_shorthand =
1144            shared_local_dep_path("request", &format!("github:request/request#{SHARED_SHA}"))
1145                .unwrap();
1146        let from_url = shared_local_dep_path(
1147            "request",
1148            &format!("https://github.com/request/request.git#{SHARED_SHA}"),
1149        )
1150        .unwrap();
1151        assert_eq!(from_shorthand, from_url);
1152    }
1153
1154    #[test]
1155    fn shared_missing_resolved_is_promoted_from_committish() {
1156        // A lockfile round-trip that never re-resolved leaves `resolved`
1157        // empty and only carries `#<committish>`; the helper must promote
1158        // it so the hash matches the package's `<url>#<sha>` key.
1159        let got = shared_local_dep_path(
1160            "request",
1161            &format!("https://github.com/request/request.git#{SHARED_SHA}"),
1162        )
1163        .unwrap();
1164        assert_eq!(
1165            got,
1166            git_key("https://github.com/request/request.git", SHARED_SHA)
1167        );
1168    }
1169
1170    #[test]
1171    fn shared_codeload_tarball_maps_to_url_dep_path() {
1172        // The exact form pnpm records for a `github:` dep that resolves to
1173        // a codeload archive. This is the case that crashed
1174        // request-promise-core under the global virtual store.
1175        let url = format!("https://codeload.github.com/request/request/tar.gz/{SHARED_SHA}");
1176        let got = shared_local_dep_path("request", &url).unwrap();
1177        assert_eq!(got, tarball_key(&url));
1178        assert!(got.starts_with("request@url+"), "unexpected key: {got}");
1179    }
1180
1181    #[test]
1182    fn shared_strips_peer_suffix_before_classifying() {
1183        let url = format!("https://codeload.github.com/request/request/tar.gz/{SHARED_SHA}");
1184        let with_peer = format!("{url}(typescript@5.8.3)");
1185        assert_eq!(
1186            shared_local_dep_path("request", &with_peer),
1187            shared_local_dep_path("request", &url),
1188        );
1189    }
1190
1191    #[test]
1192    fn shared_returns_none_for_non_shareable_specs() {
1193        for value in [
1194            "4.18.1",
1195            "^1.2.3",
1196            "link:../sibling",
1197            "file:./vendor/x",
1198            "npm:lodash@4.18.1",
1199        ] {
1200            assert!(
1201                shared_local_dep_path("dep", value).is_none(),
1202                "{value:?} must not be treated as a shareable local source",
1203            );
1204        }
1205    }
1206}