From ebbdda324c0c26edc187155342f1533aa732ff32 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Fri, 2 Oct 2026 00:47:01 +0900 Subject: [PATCH 1/2] supply-chain checks a first-party npm git dependency against its remote instead of asking npm guarddog npm verify asks npm for every name in a package.json's dependencies. A git dependency -- git+https://...#v0.1.0, github:owner/repo, the owner/repo shorthand -- names nothing npm holds, so guarddog answered a 404 and a repository depending on its own package by git exited 2 on every run, the npm half of what #275 fixed for uv. The manifest is now sorted before guarddog reads it. Its git dependencies come out and go through the same first-party rule and git ls-remote check as a uv git source, against the commit bun.lock or package-lock.json records (the manifest's directory first, then each one up to the root). A # that is a tag must point at that commit; one that is a branch is read as a bare commit some ref must point at. A git dependency no lock pins, whose # is not itself a commit, is refused by name. guarddog is handed the rest of the manifest, or the file itself where nothing came out, and is not asked at all where nothing is left. --- docs/REFERENCE.md | 15 +++ src/supply.rs | 77 +++++++++-- src/supply/references.rs | 273 +++++++++++++++++++++++++++++++++++++- tests/supply_chain_cli.rs | 91 +++++++++++++ 4 files changed, 442 insertions(+), 14 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 5144460..2fe4694 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -2706,6 +2706,21 @@ An export with nothing left that an index resolves is not handed to guarddog at all: guarddog handed an empty list answers `[]`, which this section reads as a network failure. +`guarddog npm verify` reads a `package.json`'s `dependencies` and asks npm for +each by name, so an npm git dependency is the same 404. Its git dependencies +(`git+#`, `git://...`, `github:`, `gitlab:` and `bitbucket:` +specifiers, and the `owner/repo` shorthand) are taken out and guarddog is +handed the manifest without them; a manifest with none is handed over as it +is. Each git dependency is held to the same first-party rule and the same +remote check. Its commit is the one `bun.lock` or `package-lock.json` records, +read from the manifest's directory and each one above it up to the +repository root. Its `#`, where it names one, must point at that commit +if it is a tag; if it is a branch, the commit must be what some ref points at, +as for a commit the lock names alone. A git dependency no lock records a +commit for, and whose `#` is not itself a commit, is refused by name: there is +no pin to check. A manifest left with no dependencies is not handed to +guarddog, for the same `[]`. + ### Waiving a confirmed guarddog false positive Because the findings are read here, a finding somebody has looked at and judged diff --git a/src/supply.rs b/src/supply.rs index 6881eb9..e025721 100644 --- a/src/supply.rs +++ b/src/supply.rs @@ -1461,6 +1461,32 @@ struct Ledger { read: BTreeSet<&'static str>, } +/// Who is first party here, as the policy declares it. +fn first_party_of<'a>(policy: &'a Policy, root: &Path) -> references::FirstParty<'a> { + references::FirstParty { + owner: policy + .declared_owner(root) + .map_err(|error| error.to_string()), + host: policy + .supply_chain + .forge_host + .as_deref() + .unwrap_or(references::DEFAULT_FORGE_HOST), + } +} + +/// The npm locks that may say what a manifest in `directory` resolved, nearest +/// first: its own, then each directory up to `root`, where a workspace keeps +/// the one lock for every member. +fn npm_locks(directory: &Path, root: &Path) -> Vec { + directory + .ancestors() + .take_while(|ancestor| ancestor.starts_with(root)) + .flat_map(|ancestor| ["bun.lock", "package-lock.json"].map(|name| ancestor.join(name))) + .filter_map(|lock| std::fs::read_to_string(lock).ok()) + .collect() +} + fn guarddog(root: &Path, scope: &Scope, policy: &Policy) -> Result
{ let waivers = policy.supply_chain.waive.as_slice(); let (python, npm) = match scope { @@ -1535,16 +1561,7 @@ fn guarddog(root: &Path, scope: &Scope, policy: &Policy) -> Result
{ refused = true; } for pin in &sorted.git { - let deciding = first_party.get_or_insert_with(|| references::FirstParty { - owner: policy - .declared_owner(root) - .map_err(|error| error.to_string()), - host: policy - .supply_chain - .forge_host - .as_deref() - .unwrap_or(references::DEFAULT_FORGE_HOST), - }); + let deciding = first_party.get_or_insert_with(|| first_party_of(policy, root)); match references::check(pin, deciding, directory, &mut remotes) { references::Checked::Holds(said) => println!(" first party: {said}"), references::Checked::Refused(said) => { @@ -1593,14 +1610,50 @@ fn guarddog(root: &Path, scope: &Scope, policy: &Policy) -> Result
{ for manifest in npm { let directory = manifest.parent().unwrap_or(root); checked += 1; + let at = directory.display().to_string(); + let sorted = std::fs::read_to_string(&manifest) + .map(|text| references::sort_npm(&text, &npm_locks(directory, root))) + .unwrap_or_default(); + for said in &sorted.refused { + println!(" FAILED: guarddog npm: {at}: {said}"); + refused = true; + } + for pin in &sorted.git { + let deciding = first_party.get_or_insert_with(|| first_party_of(policy, root)); + match references::check(pin, deciding, directory, &mut remotes) { + references::Checked::Holds(said) => println!(" first party: {said}"), + references::Checked::Refused(said) => { + println!(" FAILED: guarddog npm: {at}: {said}"); + refused = true; + } + references::Checked::Unread(said) => unrun = unrun.or(Some(said)), + } + } + // The manifest itself where nothing came out of it; otherwise what is + // left, unless nothing is, which guarddog would answer with `[]`. + let rewritten = match &sorted.kept { + None => None, + Some(_) if sorted.indexed == 0 => { + println!( + " {at}: no dependency here resolves from npm, so guarddog was not asked" + ); + continue; + } + Some(kept) => Some(tempfile_guard::TempFile::containing(kept)?), + }; let status = Command::new("guarddog") - .args(["npm", "verify", "--output-format", "json", "package.json"]) + .args(["npm", "verify", "--output-format", "json"]) + .arg( + rewritten + .as_ref() + .map_or_else(|| Path::new("package.json"), |file| file.path.as_path()), + ) .args(GUARDDOG_RULES) .current_dir(directory) .output() .map_err(|error| Fatal::new(format!("could not run guarddog: {error}")))?; match guarddog_read( - &directory.display().to_string(), + &at, "npm", waivers, &mut ledger, diff --git a/src/supply/references.rs b/src/supply/references.rs index 294301c..eefc006 100644 --- a/src/supply/references.rs +++ b/src/supply/references.rs @@ -42,6 +42,9 @@ pub(super) struct GitPin { pub commit: String, /// The tag the lock's source names, where it names one. pub tag: Option, + /// Whether `tag` is an npm committish, which may name a branch as well as a + /// tag: a uv lock says `tag=` and means it, an npm `#` does not say. + pub committish: bool, } /// One `uv export`, sorted. @@ -109,6 +112,7 @@ fn by_reference( remote, commit, tag, + committish: false, }); } None => sorted.refused.push(format!( @@ -240,6 +244,152 @@ fn tags_in_lock(lock: &str) -> BTreeMap<(String, String), String> { tags } +/// One `package.json`, sorted the same way. +/// +/// `guarddog npm verify` reads a manifest's `dependencies` and asks npm for +/// each by name, so a git dependency -- `git+https://…#v0.1.0`, +/// `github:owner/repo`, `owner/repo` -- is a 404 there just as a uv git source +/// is on `PyPI`. The git dependencies come out for the owner check and the +/// remote; the rest of the manifest is handed to guarddog unchanged. +#[derive(Debug, Default)] +pub(super) struct NpmSorted { + /// The manifest without its git dependencies, where it had any: what + /// guarddog is handed instead of the file. + pub kept: Option, + /// How many dependencies are left for npm to resolve. + pub indexed: usize, + /// Git dependencies, for the owner check and the remote. + pub git: Vec, + /// Git dependencies refused as they stand, each a line naming it. + pub refused: Vec, +} + +/// Sort a `package.json`'s `dependencies`. `locks` are the texts of the +/// `bun.lock` and `package-lock.json` files that may record what each git +/// dependency resolved to, nearest first. A manifest that does not parse is +/// left whole, for guarddog to say so. +pub(super) fn sort_npm(manifest: &str, locks: &[String]) -> NpmSorted { + let mut sorted = NpmSorted::default(); + let Ok(serde_json::Value::Object(mut parsed)) = serde_json::from_str(manifest) else { + return sorted; + }; + let Some(serde_json::Value::Object(dependencies)) = parsed.get_mut("dependencies") else { + return sorted; + }; + let mut taken = false; + dependencies.retain(|name, spec| { + let Some((remote, fragment)) = spec.as_str().and_then(npm_git) else { + return true; + }; + taken = true; + let fragment = fragment.filter(|fragment| !fragment.starts_with("semver:")); + let commit = locks + .iter() + .find_map(|lock| locked_commit(lock, name)) + .or_else(|| { + fragment + .filter(|fragment| is_commit(fragment)) + .map(str::to_owned) + }); + match commit { + Some(commit) => sorted.git.push(GitPin { + name: name.clone(), + remote, + tag: fragment + .filter(|fragment| !commit.starts_with(*fragment)) + .map(str::to_owned), + commit, + committish: true, + }), + None => sorted.refused.push(format!( + "{name} is a git dependency ({remote}), and no bun.lock or package-lock.json \ + records the commit it resolved to, so there is no pin to check" + )), + } + false + }); + sorted.indexed = dependencies.len(); + if taken { + sorted.kept = serde_json::to_string(&parsed).ok(); + } + sorted +} + +/// The remote and the `#` fragment of an npm git dependency, or `None` for +/// anything npm resolves from its registry, a path or a tarball URL. +fn npm_git(spec: &str) -> Option<(String, Option<&str>)> { + let (url, fragment) = match spec.split_once('#') { + Some((url, fragment)) => (url, Some(fragment).filter(|fragment| !fragment.is_empty())), + None => (spec, None), + }; + let remote = if let Some(url) = url.strip_prefix("git+") { + url.to_owned() + } else if url.starts_with("git://") { + url.to_owned() + } else if let Some((forge, path)) = url + .split_once(':') + .filter(|(forge, _)| matches!(*forge, "github" | "gitlab" | "bitbucket")) + { + let host = match forge { + "gitlab" => "gitlab.com", + "bitbucket" => "bitbucket.org", + _ => DEFAULT_FORGE_HOST, + }; + format!("https://{host}/{path}") + } else if is_shorthand(url) { + format!("https://{DEFAULT_FORGE_HOST}/{url}") + } else { + return None; + }; + Some((remote, fragment)) +} + +/// npm's `owner/repo` shorthand for a GitHub repository: one slash, and none of +/// what starts a scope, a path, a URL or a version range. +fn is_shorthand(spec: &str) -> bool { + let Some((owner, repo)) = spec.split_once('/') else { + return false; + }; + !owner.is_empty() + && !repo.is_empty() + && !repo.contains('/') + && !spec.contains(':') + && !spec.contains(' ') + && !owner.starts_with(['@', '.', '~', '^', '<', '>', '=', '*']) +} + +/// A full or abbreviated commit id. +fn is_commit(text: &str) -> bool { + (7..=40).contains(&text.len()) && text.chars().all(|character| character.is_ascii_hexdigit()) +} + +/// The commit a lock records for `name`'s git dependency: a `bun.lock` entry +/// `"": ["@#", …]`, or a `package-lock.json` +/// `packages["node_modules/"].resolved` ending `#`. +fn locked_commit(lock: &str, name: &str) -> Option { + let from_bun = || { + let start = format!("\"{name}\": [\"{name}@"); + let rest = &lock[lock.find(&start)? + start.len()..]; + let resolved = &rest[..rest.find('"')?]; + resolved.rsplit_once('#').map(|(_, commit)| commit) + }; + let from_npm = || -> Option { + let parsed: serde_json::Value = serde_json::from_str(lock).ok()?; + let resolved = parsed + .get("packages")? + .get(format!("node_modules/{name}"))? + .get("resolved")? + .as_str()?; + resolved + .rsplit_once('#') + .map(|(_, commit)| commit.to_owned()) + }; + from_bun() + .map(str::to_owned) + .or_else(from_npm) + .filter(|commit| is_commit(commit)) +} + /// Who counts as first party, as the policy declared it. #[derive(Debug)] pub(super) struct FirstParty<'a> { @@ -298,7 +448,7 @@ pub(super) fn check( if !ours { return Checked::Refused(format!( "{name} is a git source under {}/{}, not under {}/{owner}, which is this \ - repository's declared owner: PyPI does not hold it, so guarddog cannot look it \ + repository's declared owner: no registry holds it, so guarddog cannot look it \ up, and nothing here vouches for it", host.as_deref().unwrap_or("no host"), under.unwrap_or("no owner"), @@ -341,7 +491,17 @@ fn against_refs(pin: &GitPin, refs: &str) -> Checked { let at = pointed .get(peeled.as_str()) .or_else(|| pointed.get(full.as_str())); + let branch = pin.committish && pointed.contains_key(format!("refs/heads/{tag}").as_str()); return match at { + // An npm `#`: the lock holds whichever commit the branch was + // at, so it is read as a bare commit below. + None if branch => against_refs( + &GitPin { + tag: None, + ..pin.clone() + }, + refs, + ), None => Checked::Refused(format!( "{name} is locked to tag {tag}, and {} has no such tag", pin.remote @@ -402,7 +562,9 @@ fn ls_remote(remote: &str, directory: &Path) -> Result { #[cfg(test)] mod tests { - use super::{Checked, GitPin, against_refs, normalise, sort, split_git, tags_in_lock}; + use super::{ + Checked, GitPin, against_refs, normalise, npm_git, sort, sort_npm, split_git, tags_in_lock, + }; const COMMIT: &str = "53b5755d35af9bb71e6266a45c487682d1884130"; const OTHER: &str = "0000000000000000000000000000000000000001"; @@ -445,6 +607,7 @@ mod tests { remote: String::from("https://github.com/example-org/example-kit"), commit: String::from(COMMIT), tag: None, + committish: false, }] ); assert_eq!(sorted.refused.len(), 3, "{:?}", sorted.refused); @@ -513,6 +676,7 @@ mod tests { remote: String::from("https://github.com/example-org/example-kit"), commit: String::from(COMMIT), tag: tag.map(String::from), + committish: false, } } @@ -545,4 +709,109 @@ mod tests { Checked::Refused(said) if said.contains("no branch or tag") )); } + + #[test] + fn npm_git_dependencies_are_told_from_registry_ranges_paths_and_tarballs() { + let git = + |spec| npm_git(spec).map(|(remote, fragment)| (remote, fragment.map(String::from))); + assert_eq!( + git("git+https://oauth2@github.com/example-org/example-kit.git#v0.1.0"), + Some(( + String::from("https://oauth2@github.com/example-org/example-kit.git"), + Some(String::from("v0.1.0")) + )) + ); + assert_eq!( + git("github:example-org/example-kit"), + Some(( + String::from("https://github.com/example-org/example-kit"), + None + )) + ); + assert_eq!( + git("example-org/example-kit#main"), + Some(( + String::from("https://github.com/example-org/example-kit"), + Some(String::from("main")) + )) + ); + for registry in [ + "^1.1.1", + "1.2.3", + ">=1 <2", + "npm:other@1", + "workspace:*", + "file:../vendor", + "./vendor", + "https://example.test/kit-1.0.0.tgz", + "latest", + ] { + assert_eq!(git(registry), None, "{registry}"); + } + } + + #[test] + fn a_manifest_loses_its_git_dependencies_to_the_lock_and_keeps_the_rest() { + let manifest = "{\"name\":\"app\",\"dependencies\":{\ + \"@example-org/kit\":\"git+https://github.com/example-org/kit.git#v0.1.0\",\ + \"loose\":\"github:example-org/loose\",\ + \"cookie\":\"^1.1.1\"},\ + \"devDependencies\":{\"jose\":\"^6\"}}"; + let bun = format!( + "{{\n \"packages\": {{\n \"@example-org/kit\": [\"@example-org/kit@git+https://github.com/example-org/kit.git#{COMMIT}\", {{}}, \"{COMMIT}\"],\n }}\n}}\n" + ); + let sorted = sort_npm(manifest, &[bun]); + assert_eq!(sorted.indexed, 1); + assert_eq!( + sorted.git, + vec![GitPin { + name: String::from("@example-org/kit"), + remote: String::from("https://github.com/example-org/kit.git"), + commit: String::from(COMMIT), + tag: Some(String::from("v0.1.0")), + committish: true, + }] + ); + assert_eq!(sorted.refused.len(), 1, "{:?}", sorted.refused); + assert!(sorted.refused[0].contains("loose is a git dependency")); + let kept = sorted.kept.unwrap(); + assert!(kept.contains("\"cookie\""), "{kept}"); + assert!(kept.contains("\"jose\""), "{kept}"); + assert!(!kept.contains("example-org"), "{kept}"); + } + + #[test] + fn a_package_lock_supplies_the_commit_and_a_registry_only_manifest_is_left_whole() { + let manifest = "{\"dependencies\":{\"kit\":\"example-org/kit#main\"}}"; + let lock = format!( + "{{\"packages\":{{\"node_modules/kit\":{{\"resolved\":\"git+ssh://git@github.com/example-org/kit.git#{COMMIT}\"}}}}}}" + ); + let sorted = sort_npm(manifest, &[lock]); + assert_eq!(sorted.indexed, 0); + assert_eq!(sorted.git.len(), 1); + assert_eq!(sorted.git[0].tag.as_deref(), Some("main")); + let whole = sort_npm("{\"dependencies\":{\"cookie\":\"^1.1.1\"}}", &[]); + assert!(whole.kept.is_none() && whole.git.is_empty() && whole.refused.is_empty()); + assert!(sort_npm("not json", &[]).kept.is_none()); + } + + #[test] + fn an_npm_ref_that_is_a_branch_is_read_as_the_commit_a_ref_points_at() { + let branch = GitPin { + tag: Some(String::from("main")), + committish: true, + ..pin(None) + }; + let refs = format!("{COMMIT}\trefs/heads/main\n"); + assert!(matches!(against_refs(&branch, &refs), Checked::Holds(_))); + // A uv `tag=` is a tag, and a branch of the same name does not stand in. + let tag = GitPin { + committish: false, + ..branch + }; + assert!(matches!( + against_refs(&tag, &refs), + Checked::Refused(said) if said.contains("no such tag") + )); + } } diff --git a/tests/supply_chain_cli.rs b/tests/supply_chain_cli.rs index bfe4910..63f74d6 100644 --- a/tests/supply_chain_cli.rs +++ b/tests/supply_chain_cli.rs @@ -2161,3 +2161,94 @@ fn an_export_with_nothing_from_an_index_does_not_ask_guarddog() { assert!(said.contains("guarddog was not asked"), "{said}"); assert!(!journal(&root).contains("guarddog"), "{}", journal(&root)); } + +// ── npm git dependencies ── + +/// A `package.json` whose `dependencies` hold `spec` for example-kit beside a +/// registry range, a `bun.lock` recording `commit` for it, and stubs whose +/// guarddog answers a manifest still naming example-kit the way the real tool +/// does -- a 404 from npm -- and records every run. +fn npm_depending_on(root: &Path, spec: &str, commit: &str) -> PathBuf { + std::fs::write( + root.join("package.json"), + format!( + "{{\"name\":\"app\",\"dependencies\":{{\"example-kit\":\"{spec}\",\ + \"cookie\":\"^1.1.1\"}}}}\n" + ), + ) + .unwrap(); + std::fs::write( + root.join("bun.lock"), + format!( + "{{\n \"packages\": {{\n \"example-kit\": [\"example-kit@{spec}#{commit}\", \ + {{}}, \"{commit}\"],\n }}\n}}\n" + ), + ) + .unwrap(); + stubs(&[ + ("osv-scanner", "exit 0"), + ( + "guarddog", + &recording(&format!( + "grep -q example-kit \"$5\" && {{ echo '[{{\"dependency\":\"example-kit\",\ + \"result\":{{\"errors\":{{\"download-package\":\"Received status code: 404 \ + from npm\"}},\"issues\":0}}}}]'; exit 0; }}\n{GUARDDOG_CLEAN}" + )), + ), + ]) +} + +/// The npm half of the defect: a first-party package depended on by git was +/// handed to guarddog, which asked npm for it, got a 404 and made every run +/// exit 2. The tag the manifest names is now asked of the remote, against the +/// commit `bun.lock` holds, and guarddog reads the registry dependencies alone. +#[test] +fn a_first_party_npm_git_dependency_whose_tag_resolves_is_not_sent_to_guarddog_and_passes() { + let root = owned_by_example_org(); + let (config, tagged, _) = example_kit_remote(); + let tools = npm_depending_on( + &root, + "git+https://github.com/example-org/example-kit#v0.1.0", + &tagged, + ); + let output = supply_with(&root, &tools, &forge_environment(&config)); + let said = text(&output); + assert_eq!(code(&output), 0, "{said}"); + assert!( + said.contains(&format!( + "first party: example-kit: tag v0.1.0 on \ + https://github.com/example-org/example-kit is {tagged}" + )), + "{said}" + ); + assert!(journal(&root).contains("guarddog"), "{}", journal(&root)); + assert!(said.contains("all checks passed"), "{said}"); +} + +/// A lock whose commit is not where the manifest's tag points is refused. +#[test] +fn a_first_party_npm_tag_that_points_elsewhere_is_refused() { + let root = owned_by_example_org(); + let (config, _, tip) = example_kit_remote(); + let tools = npm_depending_on(&root, "github:example-org/example-kit#v0.1.0", &tip); + let output = supply_with(&root, &tools, &forge_environment(&config)); + assert_eq!(code(&output), 1, "{}", text(&output)); + assert!(text(&output).contains("the tag moved"), "{}", text(&output)); +} + +/// A git dependency under another owner is refused by name, and is never +/// handed to guarddog to 404 on. +#[test] +fn an_npm_git_dependency_under_another_owner_is_refused_by_name() { + let root = owned_by_example_org(); + let (config, tagged, _) = example_kit_remote(); + let tools = npm_depending_on(&root, "github:someone-else/example-kit#v0.1.0", &tagged); + let output = supply_with(&root, &tools, &forge_environment(&config)); + let said = text(&output); + assert_eq!(code(&output), 1, "{said}"); + assert!( + said.contains("example-kit is a git source under github.com/someone-else"), + "{said}" + ); + assert!(!said.contains("404"), "{said}"); +} From c84b81da3b000de4cf7a4e65a285e993b2b5eb90 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Fri, 2 Oct 2026 01:08:05 +0900 Subject: [PATCH 2/2] supply-chain refuses a git remote spelled as an option, and git ls-remote takes it after -- A manifest's remote reaches git ls-remote as written. One spelled --upload-pack=;...://github.com//x still parses as the forge and the declared owner, and git read it as --upload-pack and ran the command. check() now refuses a remote starting with - for uv and npm alike, and ls_remote passes -- before it, so git takes whatever reaches it as a repository. --- src/supply/references.rs | 46 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/supply/references.rs b/src/supply/references.rs index eefc006..c44d544 100644 --- a/src/supply/references.rs +++ b/src/supply/references.rs @@ -423,6 +423,16 @@ pub(super) fn check( remotes: &mut BTreeMap>, ) -> Checked { let name = &pin.name; + // The remote is whatever a manifest wrote, and one spelled as an option + // (`--upload-pack=;…:////x`) still reads as the + // forge and the owner below. git would run it. + if pin.remote.starts_with('-') { + return Checked::Refused(format!( + "{name} is a git source whose remote starts with `-` ({}), which git would read \ + as an option", + pin.remote + )); + } let owner = match &first_party.owner { Ok(Some(owner)) => owner, Ok(None) => { @@ -542,7 +552,7 @@ fn ls_remote(remote: &str, directory: &Path) -> Result { } let mut command = crate::shim::inner_tool("git"); command - .args(["ls-remote", remote]) + .args(["ls-remote", "--", remote]) .current_dir(directory) .env("GIT_TERMINAL_PROMPT", "0") .stdin(Stdio::null()); @@ -563,7 +573,8 @@ fn ls_remote(remote: &str, directory: &Path) -> Result { #[cfg(test)] mod tests { use super::{ - Checked, GitPin, against_refs, normalise, npm_git, sort, sort_npm, split_git, tags_in_lock, + Checked, FirstParty, GitPin, against_refs, check, normalise, npm_git, sort, sort_npm, + split_git, tags_in_lock, }; const COMMIT: &str = "53b5755d35af9bb71e6266a45c487682d1884130"; @@ -814,4 +825,35 @@ mod tests { Checked::Refused(said) if said.contains("no such tag") )); } + + /// A remote spelled as an option is refused before git is run: it reads as + /// the forge and the declared owner, and `git ls-remote` would take it as + /// `--upload-pack` and run the command in it. + #[test] + fn a_remote_that_git_would_read_as_an_option_is_refused_before_git_runs() { + let base = crate::fixture::scratch("supply-references-option"); + std::fs::create_dir_all(&base).unwrap(); + let probe = base.join("ran"); + let spec = format!( + "git+--upload-pack=touch {};git-upload-pack://github.com/example-org/kit#{COMMIT}", + probe.display() + ); + let sorted = sort_npm(&format!("{{\"dependencies\":{{\"kit\":\"{spec}\"}}}}"), &[]); + assert_eq!(sorted.git.len(), 1, "{:?}", sorted.refused); + let first_party = FirstParty { + owner: Ok(Some(String::from("example-org"))), + host: "github.com", + }; + let said = check( + &sorted.git[0], + &first_party, + &base, + &mut std::collections::BTreeMap::new(), + ); + assert!( + matches!(&said, Checked::Refused(said) if said.contains("read as an option")), + "{said:?}" + ); + assert!(!probe.exists()); + } }