From bfd3f5f86658cb957094c72ff21f468d7fd1ac45 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sat, 3 Oct 2026 18:20:09 +0900 Subject: [PATCH 1/4] A GitHub Enterprise remote is no longer asked about on github.com, so unowned-push keeps the allow-list's refusal for it Forge::of_url counted any host containing "github" as GitHub, so a remote on github.acme.com reached forge_owns, whose `gh api` carries no --hostname and answered about the same-named github.com repository. An operator who administers github.com/acme/widget could push to github.acme.com/acme/widget past the allow-list. The git shim's visibility lookup read the same classifier. There is now one definition of GitHub, git::is_github_host (moved from guard::names), and Forge::of_url uses it. An enterprise host is a host no client answers for: unowned-push refuses it on the allow-list alone (exit 1, no gh call), and the shim's visibility lookup reports there was nobody to ask instead of reading github.com's answer. Closes #278 --- docs/REFERENCE.md | 9 ++++++--- src/git.rs | 16 ++++++++++++++++ src/guard/names.rs | 17 ++--------------- src/shim.rs | 18 +++++++++++++++--- tests/guard_cli.rs | 30 ++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6a28949..012e839 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -1450,8 +1450,8 @@ against acme -- DERIVED FROM ORIGIN, not pinned. […] Pin it with The pin is asked first and the forge second. A destination on the allow-list — the pinned owner, `allowed_owners`, `allowed_repos` — passes exactly as before, -with no network call. Only a destination the list refused, and only on GitHub, -is put to `gh`: whether `gh api user` is the destination's owner, and failing +with no network call. Only a destination the list refused, and only on +github.com, is put to `gh`: whether `gh api user` is the destination's owner, and failing that whether `gh api repos//` reports `permissions.admin`. Either is ownership and the run exits `0` with nothing extra printed. This covers what a pin cannot, since a bundled rule takes no parameter, and it is not the @@ -1463,7 +1463,10 @@ the forge was asked and disagreed too. A forge that **could not be asked** — n `gh`, not authenticated, no network, output that is neither a yes nor a no — is exit `2` with the same report and a line saying why: a question that could not be asked is not a pass. A destination on a host no client answers for keeps the -allow-list's answer unchanged. +allow-list's answer unchanged — a refusal, exit `1`. A GitHub Enterprise host +such as `github.acme.com` is one of those: `gh api` without `--hostname` asks +github.com, whose same-named repository is somebody else's, so it is never +asked. ### Built-in parameters diff --git a/src/git.rs b/src/git.rs index ed13471..fe02c4d 100644 --- a/src/git.rs +++ b/src/git.rs @@ -444,6 +444,22 @@ pub(crate) fn host(url: &str) -> Option { (!host.is_empty()).then(|| host.to_lowercase()) } +/// Whether `gh` can answer for a name written against this host -- the one +/// definition of "GitHub" in this binary. +/// +/// Only GitHub, and only exactly GitHub. A GitHub Enterprise host is a +/// DIFFERENT forge that happens to share the software: asking github.com about +/// `acme/widget` seen on `github.acme.com` answers about someone else's +/// repository, and a public answer there passes a private repository here. The +/// same holds for ownership: `gh api` without `--hostname` asks github.com, so +/// an enterprise push judged by it would be judged by a stranger's repository. +pub(crate) fn is_github_host(host: &str) -> bool { + matches!( + host.to_lowercase().as_str(), + "github.com" | "www.github.com" | "raw.githubusercontent.com" + ) +} + /// `owner/repo` from any spelling of a forge url. pub(crate) fn owner_repo(url: &str) -> Option<(String, String)> { let trimmed = url.trim().trim_end_matches('/'); diff --git a/src/guard/names.rs b/src/guard/names.rs index fdb606d..0932da0 100644 --- a/src/guard/names.rs +++ b/src/guard/names.rs @@ -126,19 +126,6 @@ fn url_pattern() -> &'static Regex { }) } -/// Whether `gh` can answer for a name written against this host. -/// -/// Only GitHub, and only exactly GitHub. A GitHub Enterprise host is a -/// DIFFERENT forge that happens to share the software: asking github.com about -/// `acme/widget` seen on `github.acme.com` answers about someone else's -/// repository, and a public answer there passes a private repository here. -fn is_github_host(host: &str) -> bool { - matches!( - host.to_lowercase().as_str(), - "github.com" | "www.github.com" | "raw.githubusercontent.com" - ) -} - /// The hosts a policy has said carry no repository this guard must resolve. /// /// The field this replaces was a list of six forge hostnames written into the @@ -212,7 +199,7 @@ fn unanswerable_names(text: &str, quiet: &ForeignHosts) -> BTreeSet<(String, Str let mut found = BTreeSet::new(); for capture in url_pattern().captures_iter(text) { let host = capture[1].to_lowercase(); - if is_github_host(&host) || quiet.quiets(&host) { + if git::is_github_host(&host) || quiet.quiets(&host) { continue; } let repo = clean_repo(&capture[3]); @@ -241,7 +228,7 @@ fn unanswerable_names(text: &str, quiet: &ForeignHosts) -> BTreeSet<(String, Str fn candidates(text: &str, owners: &OwnerMatchers) -> BTreeSet<(String, String)> { let mut found: BTreeSet<(String, String)> = BTreeSet::new(); for capture in url_pattern().captures_iter(text) { - if !is_github_host(&capture[1]) { + if !git::is_github_host(&capture[1]) { continue; } let owner = capture[2].to_string(); diff --git a/src/shim.rs b/src/shim.rs index 8653138..989227d 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -1766,12 +1766,18 @@ impl Forge { /// `github.com/acme/gitlab-tools` is on GitHub and /// `example.com:org/github-mirror` is on neither. A self-hosted forge still /// counts by its name: `gitlab.example.com` is GitLab. + /// + /// GitHub is github.com and nothing else, by [`git::is_github_host`]. A + /// GitHub Enterprise host such as `github.acme.com` is a different forge, + /// and `gh api` asked without `--hostname` answers about github.com: the + /// visibility and ownership it reported would be a same-named stranger's. + /// So an enterprise host is a host no client is asked about. pub(crate) fn of_url(url: &str) -> Option { let host = git::host(url)?; - if host.contains("gitlab") { - Some(Self::GitLab) - } else if host.contains("github") { + if git::is_github_host(&host) { Some(Self::GitHub) + } else if host.contains("gitlab") { + Some(Self::GitLab) } else { // Not a guess. An unrecognised host means no client applies, and // the caller says so rather than reporting an answer nobody read. @@ -3950,6 +3956,12 @@ mod tests { Some(Forge::GitHub), ), ("GIT@GitHub.COM:acme/widget.git", Some(Forge::GitHub)), + // GitHub Enterprise is not GitHub: `gh api` without `--hostname` + // would answer about a same-named github.com repository. + ("https://github.acme.com/acme/widget.git", None), + ("git@github.acme.com:acme/widget.git", None), + ("ssh://git@github.acme.com:2222/acme/widget.git", None), + ("https://notgithub.com/acme/widget.git", None), ] { assert_eq!(Forge::of_url(url), want, "{url}"); } diff --git a/tests/guard_cli.rs b/tests/guard_cli.rs index 73ee6f4..030505d 100644 --- a/tests/guard_cli.rs +++ b/tests/guard_cli.rs @@ -1791,3 +1791,33 @@ fn a_destination_on_a_host_with_no_client_is_judged_by_the_allow_list_alone() { assert!(text.contains("someone-else/thing"), "{text}"); assert!(!text.contains("The forge"), "{text}"); } + +/// A GitHub Enterprise remote is not asked about on github.com. +/// +/// `gh api` without `--hostname` answers about github.com, so a `gh` that +/// administers `github.com/someone-else/thing` would have passed a push to +/// `github.acme.com/someone-else/thing` -- a different forge's repository. The +/// stub says yes to everything, so only a guard that never asks it refuses. +#[test] +fn an_enterprise_remote_is_not_answered_for_by_github_com() { + let root = repository(PINNED_PUSH); + gh_says( + &root, + "case \"$*\" in\n\ + 'api user --jq .login') echo someone-else ;;\n\ + 'api repos/'*' --jq .permissions.admin') echo true ;;\n\ + *) echo \"gh: unexpected call: $*\" >&2; exit 1 ;;\n\ + esac\n", + ); + + for url in [ + "https://github.acme.com/someone-else/thing.git", + "git@github.acme.com:someone-else/thing.git", + ] { + let output = push_guard(&root, &["--remote-url", url]); + assert_eq!(code(&output), 1, "{url}: {}", stderr(&output)); + let text = stderr(&output); + assert!(text.contains("someone-else/thing"), "{text}"); + assert!(!text.contains("The forge"), "{text}"); + } +} From 9c96c2e8b48d964aa595c005a5a6a5e03b3c3336 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sat, 3 Oct 2026 18:27:17 +0900 Subject: [PATCH 2/4] A remote url's host ends at a query or fragment, so an @ after one no longer makes github.com answer for a push to another host `git::host` ended a url's authority only at `/`. In `https://evil.com#@github.com/acme/widget.git` it therefore stripped `evil.com#` as userinfo and read the host as github.com, and `owner_repo` read `acme/widget` -- while git and curl connect to evil.com. A pusher who administers github.com/acme/widget had the unowned-push guard's forge question answered yes, and the off-list push to evil.com passed. The authority now ends at the first `/`, `?` or `#`, as RFC 3986 reads it, before userinfo and port are stripped. `owner_repo` reads a `scheme://` url's owner and repository from that same path, without query or fragment, so the two never disagree about one url: the `#@`/`?@` forms name host evil.com, no forge, and no owner/repo, and the push is refused for an unreadable destination. Unit rows cover the host, forge and owner/repo of both forms; an end-to-end case pushes to them with a stub `gh` that says yes and expects exit 1. All fail against the previous parsing. --- src/git.rs | 44 +++++++++++++++++++++++++++++++++++++++++--- src/shim.rs | 4 ++++ tests/guard_cli.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/git.rs b/src/git.rs index fe02c4d..f2e0cea 100644 --- a/src/git.rs +++ b/src/git.rs @@ -425,8 +425,8 @@ pub(crate) fn remote_url(root: &Path, remote: &str) -> Option { /// before any slash. A local path, or a `file://` url, names no host. pub(crate) fn host(url: &str) -> Option { let url = url.trim(); - let authority = if let Some((_, rest)) = url.split_once("://") { - rest.split('/').next().unwrap_or(rest) + let authority = if let Some((authority, _)) = url_parts(url) { + authority } else { let (authority, _) = url.split_once(':')?; if authority.contains('/') { @@ -444,6 +444,20 @@ pub(crate) fn host(url: &str) -> Option { (!host.is_empty()).then(|| host.to_lowercase()) } +/// The authority and path of a `scheme://` url, the path without any query or +/// fragment. +/// +/// The authority ends at the first `/`, `?` or `#`, as RFC 3986 and curl read +/// it. Ending it at `/` alone took `https://evil.com#@github.com/acme/widget` +/// to be github.com's `acme/widget`, while git sends the push to evil.com. +fn url_parts(url: &str) -> Option<(&str, &str)> { + let (_, rest) = url.split_once("://")?; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let (authority, tail) = rest.split_at(end); + let path = tail.split(['?', '#']).next().unwrap_or(tail); + Some((authority, path)) +} + /// Whether `gh` can answer for a name written against this host -- the one /// definition of "GitHub" in this binary. /// @@ -462,7 +476,11 @@ pub(crate) fn is_github_host(host: &str) -> bool { /// `owner/repo` from any spelling of a forge url. pub(crate) fn owner_repo(url: &str) -> Option<(String, String)> { - let trimmed = url.trim().trim_end_matches('/'); + let url = url.trim(); + // A `scheme://` url names its repository in its path and nowhere else. + let trimmed = url_parts(url) + .map_or(url, |(_, path)| path) + .trim_end_matches('/'); let without_git = trimmed.strip_suffix(".git").unwrap_or(trimmed); // scp-like (`git@host:owner/repo`) and url forms both end in owner/repo. let tail = without_git @@ -502,6 +520,14 @@ mod tests { ("git@github.com:acme/widget.git", "github.com"), ("github.com:acme/widget.git", "github.com"), ("ssh://git@[::1]:22/acme/widget.git", "::1"), + // The authority ends at a query or fragment, so an `@` after one + // is not userinfo and the host is the one git connects to. + ("https://evil.com#@github.com/acme/widget.git", "evil.com"), + ("https://evil.com?@github.com/acme/widget.git", "evil.com"), + ( + "https://u@evil.com#x@github.com/acme/widget.git", + "evil.com", + ), ] { assert_eq!(host(url).as_deref(), Some(want), "{url}"); } @@ -528,6 +554,8 @@ mod tests { "git@github.com:acme/widget.git", "ssh://git@github.com/acme/widget.git", "https://github.com/acme/widget/", + "https://github.com/acme/widget.git#main", + "https://github.com/acme/widget?tab=readme", ] { assert_eq!( owner_repo(url), @@ -537,6 +565,16 @@ mod tests { } } + #[test] + fn owner_and_repo_are_never_read_past_a_query_or_fragment() { + for url in [ + "https://evil.com#@github.com/acme/widget.git", + "https://evil.com?@github.com/acme/widget.git", + ] { + assert_eq!(owner_repo(url), None, "{url}"); + } + } + #[test] fn an_ident_splits_into_its_two_halves() { assert_eq!( diff --git a/src/shim.rs b/src/shim.rs index 989227d..afdfa8a 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -3962,6 +3962,10 @@ mod tests { ("git@github.acme.com:acme/widget.git", None), ("ssh://git@github.acme.com:2222/acme/widget.git", None), ("https://notgithub.com/acme/widget.git", None), + // An `@` after a query or fragment is not userinfo: git connects + // to evil.com. + ("https://evil.com#@github.com/acme/widget.git", None), + ("https://evil.com?@github.com/acme/widget.git", None), ] { assert_eq!(Forge::of_url(url), want, "{url}"); } diff --git a/tests/guard_cli.rs b/tests/guard_cli.rs index 030505d..e637ec5 100644 --- a/tests/guard_cli.rs +++ b/tests/guard_cli.rs @@ -1821,3 +1821,29 @@ fn an_enterprise_remote_is_not_answered_for_by_github_com() { assert!(!text.contains("The forge"), "{text}"); } } + +#[test] +fn an_at_sign_after_a_query_or_fragment_does_not_make_github_com_the_host() { + // git connects to evil.com: the `@` sits in the fragment or query, not in + // the userinfo. Reading github.com out of it let a github.com administrator + // of acme/widget push off the list to a stranger's host. + let root = repository(PINNED_PUSH); + gh_says( + &root, + "case \"$*\" in\n\ + 'api user --jq .login') echo someone-else ;;\n\ + 'api repos/'*' --jq .permissions.admin') echo true ;;\n\ + *) echo \"gh: unexpected call: $*\" >&2; exit 1 ;;\n\ + esac\n", + ); + + for url in [ + "https://evil.com#@github.com/acme/widget.git", + "https://evil.com?@github.com/acme/widget.git", + ] { + let output = push_guard(&root, &["--remote-url", url]); + assert_eq!(code(&output), 1, "{url}: {}", stderr(&output)); + let text = stderr(&output); + assert!(!text.contains("The forge"), "{text}"); + } +} From cd80345b92622c4ab490d772ec53929e81dacd48 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sat, 3 Oct 2026 18:35:07 +0900 Subject: [PATCH 3/4] A remote url with a percent-encoded authority, a bracketed scp-like host, or a file:// scheme names no host and no repository, so unowned-push refuses it as unreadable Three spellings still let `git::host` and `git::owner_repo` read github.com's acme/widget while git contacts another host, and a stub `gh` that said yes passed the push: - `ssh://evil.com%2F@github.com/acme/widget.git` (and `git+ssh://`, `git://`): git url-decodes the authority before splitting it, so the user ends at the decoded `/` and ssh connects to evil.com. - `[github.com:x@evil.com]:acme/widget.git`: git strips the brackets of a scp-like host and ssh connects to evil.com as user x. - `file://github.com/acme/widget.git`: a file:// url names no host, yet github.com was read from it. Rather than a second copy of git's parser, one predicate, `ambiguous`, fails closed: a `scheme://` authority containing `%`, any `file://` url, and a scp-like host part containing `[` yield None from both functions, and the push is refused for an unreadable destination. A percent-encoded credential in a url is refused with them. This corrects the previous commit's claim that `host` and `owner_repo` "never disagree": the contract, now written on `host`, is that they agree about a url or both yield None when it is ambiguous. Unit rows cover host, owner/repo and forge for each form, with github.com controls; one end-to-end case per form pushes with a stub `gh` that says yes and expects exit 1. All fail with `ambiguous` disabled. --- src/git.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++ src/shim.rs | 5 ++++ tests/guard_cli.rs | 38 +++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/src/git.rs b/src/git.rs index f2e0cea..736e381 100644 --- a/src/git.rs +++ b/src/git.rs @@ -423,8 +423,14 @@ pub(crate) fn remote_url(root: &Path, remote: &str) -> Option { /// for https, http, ssh, git and `git+ssh`, and the scp-like /// `[user@]host:path`, which git reads as scp-like only where the colon comes /// before any slash. A local path, or a `file://` url, names no host. +/// +/// [`host`] and [`owner_repo`] agree about a url, or both yield None when +/// [`ambiguous`] says this parser cannot be sure which host git contacts. pub(crate) fn host(url: &str) -> Option { let url = url.trim(); + if ambiguous(url) { + return None; + } let authority = if let Some((authority, _)) = url_parts(url) { authority } else { @@ -444,6 +450,25 @@ pub(crate) fn host(url: &str) -> Option { (!host.is_empty()).then(|| host.to_lowercase()) } +/// Whether a url is spelled so that git may contact a host other than the one +/// read here, so that neither a host nor an owner/repo is read from it. +/// +/// Fail-closed rather than a second copy of git's parser: git url-decodes a +/// `scheme://` authority before splitting it, so a `%2F@` can put the real +/// host in front of an `@` that seems to end the userinfo; git strips the +/// brackets of a scp-like `[host]:path` and lets ssh read whatever is inside +/// them; and a `file://` url names no host, whatever stands where one would. +fn ambiguous(url: &str) -> bool { + if let Some((authority, _)) = url_parts(url) { + let file = url + .get(..7) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file://")); + return file || authority.contains('%'); + } + url.split_once(':') + .is_some_and(|(authority, _)| !authority.contains('/') && authority.contains('[')) +} + /// The authority and path of a `scheme://` url, the path without any query or /// fragment. /// @@ -475,8 +500,13 @@ pub(crate) fn is_github_host(host: &str) -> bool { } /// `owner/repo` from any spelling of a forge url. +/// +/// None for every url [`ambiguous`] refuses, as [`host`] is. pub(crate) fn owner_repo(url: &str) -> Option<(String, String)> { let url = url.trim(); + if ambiguous(url) { + return None; + } // A `scheme://` url names its repository in its path and nowhere else. let trimmed = url_parts(url) .map_or(url, |(_, path)| path) @@ -575,6 +605,40 @@ mod tests { } } + #[test] + fn a_url_git_may_send_to_another_host_names_no_host_and_no_repository() { + for url in [ + // git url-decodes the authority, so the user ends at the `/` and + // ssh connects to evil.com. + "ssh://evil.com%2F@github.com/acme/widget.git", + "git+ssh://evil.com%2F@github.com/acme/widget.git", + "git://evil.com%2F@github.com/acme/widget.git", + "https://evil.com%2F@github.com/acme/widget.git", + // git strips the brackets and ssh reads `x@evil.com` as the host. + "[github.com:x@evil.com]:acme/widget.git", + "git@[github.com]:acme/widget.git", + // file:// names no host, whatever stands where one would. + "file://github.com/acme/widget.git", + "FILE://github.com/acme/widget.git", + ] { + assert_eq!(host(url), None, "{url}"); + assert_eq!(owner_repo(url), None, "{url}"); + } + // The controls: the same repository spelled plainly still reads. + for url in [ + "ssh://git@github.com/acme/widget.git", + "git@github.com:acme/widget.git", + "https://github.com/acme/widget.git", + "acme/widget", + ] { + assert_eq!( + owner_repo(url), + Some(("acme".to_owned(), "widget".to_owned())), + "{url}" + ); + } + } + #[test] fn an_ident_splits_into_its_two_halves() { assert_eq!( diff --git a/src/shim.rs b/src/shim.rs index afdfa8a..6550190 100644 --- a/src/shim.rs +++ b/src/shim.rs @@ -3966,6 +3966,11 @@ mod tests { // to evil.com. ("https://evil.com#@github.com/acme/widget.git", None), ("https://evil.com?@github.com/acme/widget.git", None), + // git url-decodes the authority, strips scp-like brackets, and + // reads no host from file://, so none of these is github.com. + ("ssh://evil.com%2F@github.com/acme/widget.git", None), + ("[github.com:x@evil.com]:acme/widget.git", None), + ("file://github.com/acme/widget.git", None), ] { assert_eq!(Forge::of_url(url), want, "{url}"); } diff --git a/tests/guard_cli.rs b/tests/guard_cli.rs index e637ec5..bc40ebe 100644 --- a/tests/guard_cli.rs +++ b/tests/guard_cli.rs @@ -1847,3 +1847,41 @@ fn an_at_sign_after_a_query_or_fragment_does_not_make_github_com_the_host() { assert!(!text.contains("The forge"), "{text}"); } } + +/// A push to `url`, which git sends somewhere other than github.com, is +/// refused although a stub `gh` says the pusher administers github.com's +/// acme/widget. +fn refused_though_github_com_says_yes(url: &str) { + let root = repository(PINNED_PUSH); + gh_says( + &root, + "case \"$*\" in\n\ + 'api user --jq .login') echo someone-else ;;\n\ + 'api repos/'*' --jq .permissions.admin') echo true ;;\n\ + *) echo \"gh: unexpected call: $*\" >&2; exit 1 ;;\n\ + esac\n", + ); + let output = push_guard(&root, &["--remote-url", url]); + assert_eq!(code(&output), 1, "{url}: {}", stderr(&output)); + let text = stderr(&output); + assert!(!text.contains("The forge"), "{text}"); +} + +#[test] +fn a_percent_encoded_authority_does_not_make_github_com_the_host() { + // git url-decodes the authority before splitting it, so the user ends at + // the `/` and ssh connects to evil.com. + refused_though_github_com_says_yes("ssh://evil.com%2F@github.com/acme/widget.git"); +} + +#[test] +fn a_bracketed_scp_like_host_does_not_make_github_com_the_host() { + // git strips the brackets and ssh connects to evil.com as user x. + refused_though_github_com_says_yes("[github.com:x@evil.com]:acme/widget.git"); +} + +#[test] +fn a_file_url_does_not_make_github_com_the_host() { + // A file:// url names no host: git pushes to a local path. + refused_though_github_com_says_yes("file://github.com/acme/widget.git"); +} From 72218c6b1796ce8dbb03271cd8c60da2ea01b682 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Sat, 3 Oct 2026 18:43:04 +0900 Subject: [PATCH 4/4] A file:// remote url names no host but its path still names the repository, so a push to an allowed bare repository by file:// passes again bddacc4 refused every file:// url as unreadable, so a push to file:///tmp/x/bare.git with allowed_repos = ["x/bare"] exited 1 where the same path spelled plainly passed, and the visibility guard, names and the shim lost origin's owner and repository the same way. host() now returns None for file:// and owner_repo() reads its path like a plain path, so file://github.com/... asks no forge and is judged by the allow-list alone. --- src/git.rs | 38 ++++++++++++++++++++++++++--------- tests/guard_cli.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/git.rs b/src/git.rs index 736e381..302eeb6 100644 --- a/src/git.rs +++ b/src/git.rs @@ -428,7 +428,9 @@ pub(crate) fn remote_url(root: &Path, remote: &str) -> Option { /// [`ambiguous`] says this parser cannot be sure which host git contacts. pub(crate) fn host(url: &str) -> Option { let url = url.trim(); - if ambiguous(url) { + // A `file://` url names no host, whatever stands where one would: git + // pushes it to a local path. + if ambiguous(url) || is_file_url(url) { return None; } let authority = if let Some((authority, _)) = url_parts(url) { @@ -457,18 +459,21 @@ pub(crate) fn host(url: &str) -> Option { /// `scheme://` authority before splitting it, so a `%2F@` can put the real /// host in front of an `@` that seems to end the userinfo; git strips the /// brackets of a scp-like `[host]:path` and lets ssh read whatever is inside -/// them; and a `file://` url names no host, whatever stands where one would. +/// them. fn ambiguous(url: &str) -> bool { if let Some((authority, _)) = url_parts(url) { - let file = url - .get(..7) - .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file://")); - return file || authority.contains('%'); + return authority.contains('%'); } url.split_once(':') .is_some_and(|(authority, _)| !authority.contains('/') && authority.contains('[')) } +/// Whether a url is spelled `file://`, in any case. +fn is_file_url(url: &str) -> bool { + url.get(..7) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file://")) +} + /// The authority and path of a `scheme://` url, the path without any query or /// fragment. /// @@ -576,6 +581,24 @@ mod tests { } } + #[test] + fn a_file_url_names_no_host_but_its_path_still_names_a_repository() { + // git pushes a file:// url to a local path, so no forge answers for + // it, but its path is read as a plain path is. + for (url, owner, repo) in [ + ("file://github.com/acme/widget.git", "acme", "widget"), + ("FILE://github.com/acme/widget.git", "acme", "widget"), + ("file:///tmp/x/bare.git", "x", "bare"), + ] { + assert_eq!(host(url), None, "{url}"); + assert_eq!( + owner_repo(url), + Some((owner.to_owned(), repo.to_owned())), + "{url}" + ); + } + } + #[test] fn owner_and_repo_come_out_of_every_url_spelling() { for url in [ @@ -617,9 +640,6 @@ mod tests { // git strips the brackets and ssh reads `x@evil.com` as the host. "[github.com:x@evil.com]:acme/widget.git", "git@[github.com]:acme/widget.git", - // file:// names no host, whatever stands where one would. - "file://github.com/acme/widget.git", - "FILE://github.com/acme/widget.git", ] { assert_eq!(host(url), None, "{url}"); assert_eq!(owner_repo(url), None, "{url}"); diff --git a/tests/guard_cli.rs b/tests/guard_cli.rs index bc40ebe..cc1544c 100644 --- a/tests/guard_cli.rs +++ b/tests/guard_cli.rs @@ -1880,8 +1880,53 @@ fn a_bracketed_scp_like_host_does_not_make_github_com_the_host() { refused_though_github_com_says_yes("[github.com:x@evil.com]:acme/widget.git"); } +/// A `file://` url names no host, so no forge is asked about it. +/// +/// git pushes `file://github.com/...` to a local path, so github.com's answer +/// about the repository its path names is an answer about somewhere else. The +/// allow-list alone judges it: an owner not on it is refused, and the `gh` +/// that would have said yes is never run. #[test] fn a_file_url_does_not_make_github_com_the_host() { - // A file:// url names no host: git pushes to a local path. - refused_though_github_com_says_yes("file://github.com/acme/widget.git"); + let root = repository(PINNED_PUSH); + let asked = root.join("gh-was-asked"); + gh_says( + &root, + &format!( + "touch '{}'\n\ + case \"$*\" in\n\ + 'api user --jq .login') echo someone-else ;;\n\ + 'api repos/'*' --jq .permissions.admin') echo true ;;\n\ + *) echo \"gh: unexpected call: $*\" >&2; exit 1 ;;\n\ + esac\n", + asked.display() + ), + ); + let url = "file://github.com/someone-else/thing.git"; + let output = push_guard(&root, &["--remote-url", url]); + assert_eq!(code(&output), 1, "{url}: {}", stderr(&output)); + assert!(!asked.exists(), "gh was asked about {url}"); +} + +/// A `file://` url to a bare repository is read by its path, as the same path +/// spelled plainly is, so the allow-list admits it. +#[test] +fn a_file_url_to_an_allowed_bare_repository_passes() { + let root = repository( + "[rule.prevent-public-push]\nbuiltin = \"prevent-public-push\"\n\ + allowed_repos = [\"x/bare\"]\n\n\ + [rule.prevent-public-push.git]\nhooks = [\"pre-push\"]\n", + ); + gh_says(&root, GH_SAYS_NO); + let bare = root.join("x/bare.git"); + std::fs::create_dir_all(&bare).unwrap(); + support::git(&bare, &["init", "-q", "--bare"]); + + for url in [ + format!("file://{}", bare.display()), + bare.display().to_string(), + ] { + let output = push_guard(&root, &["--remote-url", &url]); + assert_eq!(code(&output), 0, "{url}: {}", stderr(&output)); + } }