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