Skip to main content

rustferry_github/
git_endpoint.rs

1//! Canonical GitHub Git endpoints and one-shot local remote discovery.
2
3use std::{error::Error, ffi::OsString, fmt};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
6
7use crate::transport::Repository;
8
9const MAX_ENDPOINT_BYTES: usize = 512;
10const MAX_DISCOVERY_BYTES: usize = 16 * 1024;
11
12/// Git transport selected by a validated GitHub endpoint.
13#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
14pub enum GithubGitTransport {
15    /// Credential-free HTTPS URL authenticated separately by Git Credential Manager.
16    Https,
17    /// Canonical `git@github.com` SSH transport.
18    Ssh,
19}
20
21/// A canonical, credential-free GitHub.com Git endpoint.
22#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
23pub struct GithubGitEndpoint {
24    repository: Repository,
25    transport: GithubGitTransport,
26    canonical_url: String,
27}
28
29impl GithubGitEndpoint {
30    /// Parse a conservative GitHub.com HTTPS or SSH endpoint.
31    ///
32    /// # Errors
33    ///
34    /// Rejects credentials, non-GitHub hosts, ports, percent escapes, query/fragment syntax,
35    /// extra path components, Unicode, and malformed repository identities.
36    pub fn parse(value: &str) -> Result<Self, GithubGitEndpointError> {
37        if value.is_empty()
38            || value.len() > MAX_ENDPOINT_BYTES
39            || !value.is_ascii()
40            || value.trim() != value
41            || value
42                .bytes()
43                .any(|byte| byte.is_ascii_control() || matches!(byte, b'?' | b'#' | b'%' | b'\\'))
44        {
45            return Err(GithubGitEndpointError::InvalidSyntax);
46        }
47
48        let (transport, path) = if let Some(path) = value.strip_prefix("https://github.com/") {
49            (GithubGitTransport::Https, path)
50        } else if let Some(path) = value.strip_prefix("git@github.com:") {
51            (GithubGitTransport::Ssh, path)
52        } else if let Some(path) = value.strip_prefix("ssh://git@github.com/") {
53            (GithubGitTransport::Ssh, path)
54        } else {
55            return Err(GithubGitEndpointError::UnsupportedTransport);
56        };
57
58        let path = path.strip_suffix('/').unwrap_or(path);
59        if path.ends_with('/') {
60            return Err(GithubGitEndpointError::InvalidSyntax);
61        }
62        let path = path.strip_suffix(".git").unwrap_or(path);
63        let (owner, name) = path
64            .split_once('/')
65            .ok_or(GithubGitEndpointError::InvalidRepository)?;
66        if owner.is_empty() || name.is_empty() || name.contains('/') {
67            return Err(GithubGitEndpointError::InvalidRepository);
68        }
69        let owner = owner.to_ascii_lowercase();
70        let name = name.to_ascii_lowercase();
71        let repository = Repository::new(owner.clone(), name.clone())
72            .map_err(|_| GithubGitEndpointError::InvalidRepository)?;
73        let canonical_url = match transport {
74            GithubGitTransport::Https => format!("https://github.com/{owner}/{name}"),
75            GithubGitTransport::Ssh => format!("git@github.com:{owner}/{name}"),
76        };
77        Ok(Self {
78            repository,
79            transport,
80            canonical_url,
81        })
82    }
83
84    /// Exact normalized GitHub repository identity.
85    pub const fn repository(&self) -> &Repository {
86        &self.repository
87    }
88
89    /// Selected network transport.
90    pub const fn transport(&self) -> GithubGitTransport {
91        self.transport
92    }
93
94    /// Canonical credential-free URL rendered by `RustFerry` rather than copied from Git config.
95    pub fn canonical_url(&self) -> &str {
96        &self.canonical_url
97    }
98
99    /// Canonical lowercase `owner/repository` slug.
100    pub fn repository_slug(&self) -> String {
101        format!("{}/{}", self.repository.owner(), self.repository.name())
102    }
103}
104
105impl fmt::Display for GithubGitEndpoint {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        formatter.write_str(self.canonical_url())
108    }
109}
110
111impl Serialize for GithubGitEndpoint {
112    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
113    where
114        S: Serializer,
115    {
116        serializer.serialize_str(self.canonical_url())
117    }
118}
119
120impl<'de> Deserialize<'de> for GithubGitEndpoint {
121    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
122    where
123        D: Deserializer<'de>,
124    {
125        let value = String::deserialize(deserializer)?;
126        let endpoint = Self::parse(&value).map_err(D::Error::custom)?;
127        if endpoint.canonical_url() != value {
128            return Err(D::Error::custom("GitHub Git endpoint is not canonical"));
129        }
130        Ok(endpoint)
131    }
132}
133
134/// Stable GitHub endpoint validation failure without echoing untrusted URL text.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum GithubGitEndpointError {
137    /// Endpoint syntax or byte repertoire is unsafe.
138    InvalidSyntax,
139    /// Scheme, host, user, or SSH form is unsupported.
140    UnsupportedTransport,
141    /// Owner/repository path is malformed.
142    InvalidRepository,
143}
144
145impl fmt::Display for GithubGitEndpointError {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::InvalidSyntax => formatter.write_str("GitHub Git endpoint syntax is invalid"),
149            Self::UnsupportedTransport => {
150                formatter.write_str("GitHub Git endpoint transport is unsupported")
151            }
152            Self::InvalidRepository => {
153                formatter.write_str("GitHub Git endpoint repository is invalid")
154            }
155        }
156    }
157}
158
159impl Error for GithubGitEndpointError {}
160
161/// Conservative local Git remote name used only during one-shot discovery.
162#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
163pub struct GitRemoteName(String);
164
165impl GitRemoteName {
166    /// Validate a local remote name that can be embedded in an anchored Git-config key regex.
167    ///
168    /// # Errors
169    ///
170    /// Rejects empty, option-like, long, control, regex, ref, and path syntax.
171    pub fn new(value: impl Into<String>) -> Result<Self, GitRemoteDiscoveryError> {
172        let value = value.into();
173        if value.is_empty()
174            || value.len() > 64
175            || value.starts_with('-')
176            || !value
177                .bytes()
178                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
179        {
180            return Err(GitRemoteDiscoveryError::InvalidRemoteName);
181        }
182        Ok(Self(value))
183    }
184
185    /// Validated name as configured locally.
186    pub fn as_str(&self) -> &str {
187        &self.0
188    }
189
190    /// Fixed argument vector for a single local, no-include config snapshot.
191    pub fn discovery_arguments(&self) -> Vec<OsString> {
192        let escaped = self.0.replace('.', "\\.");
193        vec![
194            OsString::from("config"),
195            OsString::from("--local"),
196            OsString::from("--no-includes"),
197            OsString::from("-z"),
198            OsString::from("--get-regexp"),
199            OsString::from(format!("^remote\\.{escaped}\\.(url|pushurl)$")),
200        ]
201    }
202}
203
204/// Frozen effective fetch/push endpoints from one local remote-config read.
205#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct GithubRemoteSnapshot {
207    fetch: GithubGitEndpoint,
208    push: GithubGitEndpoint,
209}
210
211impl GithubRemoteSnapshot {
212    /// Bind already parsed canonical fetch and push endpoints.
213    ///
214    /// # Errors
215    ///
216    /// Rejects endpoints that identify different GitHub repositories.
217    pub fn new(
218        fetch: GithubGitEndpoint,
219        push: GithubGitEndpoint,
220    ) -> Result<Self, GitRemoteDiscoveryError> {
221        if fetch.repository() != push.repository() {
222            return Err(GitRemoteDiscoveryError::RepositoryMismatch);
223        }
224        Ok(Self { fetch, push })
225    }
226
227    /// Parse one `git config --local --no-includes -z --get-regexp` response.
228    ///
229    /// Git emits each matching key and value as `key\nvalue\0`. Exactly one URL is required;
230    /// zero or one push URL is accepted, with an absent push URL inheriting the fetch URL.
231    /// Multiple values fail closed instead of using Git's URL selection rules.
232    ///
233    /// # Errors
234    ///
235    /// Rejects oversized/malformed output, missing or duplicate values, unexpected keys, unsafe
236    /// endpoints, or differing fetch/push repository identities.
237    pub fn from_local_config_output(
238        remote: &GitRemoteName,
239        output: &[u8],
240    ) -> Result<Self, GitRemoteDiscoveryError> {
241        if output.is_empty() || output.len() > MAX_DISCOVERY_BYTES || !output.ends_with(&[0]) {
242            return Err(GitRemoteDiscoveryError::MalformedOutput);
243        }
244        let fetch_key = format!("remote.{}.url", remote.as_str());
245        let push_key = format!("remote.{}.pushurl", remote.as_str());
246        let mut fetch_value = None;
247        let mut push_value = None;
248        let mut records = 0_u8;
249        for record in output[..output.len() - 1].split(|byte| *byte == 0) {
250            records = records
251                .checked_add(1)
252                .ok_or(GitRemoteDiscoveryError::MalformedOutput)?;
253            if records > 2 || record.is_empty() {
254                return Err(GitRemoteDiscoveryError::DuplicateValue);
255            }
256            let split = record
257                .iter()
258                .position(|byte| *byte == b'\n')
259                .ok_or(GitRemoteDiscoveryError::MalformedOutput)?;
260            let key = std::str::from_utf8(&record[..split])
261                .map_err(|_| GitRemoteDiscoveryError::MalformedOutput)?;
262            let value = std::str::from_utf8(&record[split + 1..])
263                .map_err(|_| GitRemoteDiscoveryError::MalformedOutput)?;
264            if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_control()) {
265                return Err(GitRemoteDiscoveryError::MalformedOutput);
266            }
267            if key == fetch_key {
268                set_once(&mut fetch_value, value)?;
269            } else if key == push_key {
270                set_once(&mut push_value, value)?;
271            } else {
272                return Err(GitRemoteDiscoveryError::UnexpectedKey);
273            }
274        }
275        let fetch =
276            GithubGitEndpoint::parse(fetch_value.ok_or(GitRemoteDiscoveryError::MissingFetchUrl)?)
277                .map_err(GitRemoteDiscoveryError::Endpoint)?;
278        let push = push_value.map_or_else(
279            || Ok(fetch.clone()),
280            |value| GithubGitEndpoint::parse(value).map_err(GitRemoteDiscoveryError::Endpoint),
281        )?;
282        Self::new(fetch, push)
283    }
284
285    /// Frozen canonical fetch endpoint.
286    pub const fn fetch(&self) -> &GithubGitEndpoint {
287        &self.fetch
288    }
289
290    /// Frozen canonical push endpoint.
291    pub const fn push(&self) -> &GithubGitEndpoint {
292        &self.push
293    }
294}
295
296fn set_once<'a>(slot: &mut Option<&'a str>, value: &'a str) -> Result<(), GitRemoteDiscoveryError> {
297    if slot.replace(value).is_some() {
298        Err(GitRemoteDiscoveryError::DuplicateValue)
299    } else {
300        Ok(())
301    }
302}
303
304/// Stable one-shot Git remote-discovery failure.
305#[derive(Clone, Copy, Debug, Eq, PartialEq)]
306pub enum GitRemoteDiscoveryError {
307    /// Local remote name is unsafe.
308    InvalidRemoteName,
309    /// Git output framing, size, encoding, or value bytes are invalid.
310    MalformedOutput,
311    /// Output contained a key outside the exact selected remote.
312    UnexpectedKey,
313    /// Effective fetch URL is absent.
314    MissingFetchUrl,
315    /// URL or push URL occurs more than once.
316    DuplicateValue,
317    /// Endpoint itself is invalid.
318    Endpoint(GithubGitEndpointError),
319    /// Fetch and push endpoints identify different repositories.
320    RepositoryMismatch,
321}
322
323impl fmt::Display for GitRemoteDiscoveryError {
324    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            Self::InvalidRemoteName => formatter.write_str("Git remote name is invalid"),
327            Self::MalformedOutput => formatter.write_str("Git remote output is malformed"),
328            Self::UnexpectedKey => formatter.write_str("Git returned an unexpected remote key"),
329            Self::MissingFetchUrl => formatter.write_str("Git remote has no fetch URL"),
330            Self::DuplicateValue => formatter.write_str("Git remote has duplicate URL values"),
331            Self::Endpoint(error) => write!(formatter, "Git remote endpoint is invalid: {error}"),
332            Self::RepositoryMismatch => {
333                formatter.write_str("Git remote fetch and push repositories differ")
334            }
335        }
336    }
337}
338
339impl Error for GitRemoteDiscoveryError {}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn endpoint_parser_accepts_only_canonical_github_transports() {
347        for (value, transport, canonical) in [
348            (
349                "https://github.com/ShiroKSH/RustFerry.git",
350                GithubGitTransport::Https,
351                "https://github.com/shiroksh/rustferry",
352            ),
353            (
354                "git@github.com:ShiroKSH/RustFerry.git",
355                GithubGitTransport::Ssh,
356                "git@github.com:shiroksh/rustferry",
357            ),
358            (
359                "ssh://git@github.com/ShiroKSH/RustFerry/",
360                GithubGitTransport::Ssh,
361                "git@github.com:shiroksh/rustferry",
362            ),
363        ] {
364            let endpoint = GithubGitEndpoint::parse(value).expect("valid endpoint");
365            assert_eq!(endpoint.transport(), transport);
366            assert_eq!(endpoint.canonical_url(), canonical);
367            assert_eq!(endpoint.repository_slug(), "shiroksh/rustferry");
368        }
369    }
370
371    #[test]
372    fn endpoint_serde_accepts_only_its_canonical_string_form() {
373        let endpoint = GithubGitEndpoint::parse("git@github.com:owner/repo").expect("endpoint");
374        assert_eq!(
375            serde_json::to_string(&endpoint).expect("endpoint JSON"),
376            r#""git@github.com:owner/repo""#
377        );
378        assert_eq!(
379            serde_json::from_str::<GithubGitEndpoint>(r#""git@github.com:owner/repo""#)
380                .expect("canonical endpoint"),
381            endpoint
382        );
383        assert!(
384            serde_json::from_str::<GithubGitEndpoint>(r#""ssh://git@github.com/Owner/Repo.git""#)
385                .is_err()
386        );
387    }
388
389    #[test]
390    fn endpoint_parser_rejects_credentials_rewrites_and_lookalikes() {
391        for value in [
392            "http://github.com/owner/repo",
393            "https://token@github.com/owner/repo",
394            "https://github.example/owner/repo",
395            "https://github.com.evil/owner/repo",
396            "https://github.com:443/owner/repo",
397            "https://github.com/owner/repo/extra",
398            "https://github.com/owner/repo?token=value",
399            "https://github.com/owner/re%70o",
400            "git@github.com:owner/repo -oProxyCommand=evil",
401            "git@github.com:owner\\repo",
402            "ssh://other@github.com/owner/repo",
403            "ssh://git@github.com:22/owner/repo",
404            "HTTPS://github.com/owner/repo",
405            " https://github.com/owner/repo",
406            "https://github.com/owner/répo",
407        ] {
408            assert!(GithubGitEndpoint::parse(value).is_err(), "{value}");
409        }
410    }
411
412    #[test]
413    fn discovery_freezes_one_fetch_and_optional_push_url() {
414        let remote = GitRemoteName::new("origin").expect("remote");
415        let output = b"remote.origin.url\nhttps://github.com/Owner/Repo.git\0remote.origin.pushurl\ngit@github.com:owner/repo.git\0";
416        let snapshot = GithubRemoteSnapshot::from_local_config_output(&remote, output)
417            .expect("remote snapshot");
418        assert_eq!(snapshot.fetch().transport(), GithubGitTransport::Https);
419        assert_eq!(snapshot.push().transport(), GithubGitTransport::Ssh);
420        assert_eq!(snapshot.fetch().repository(), snapshot.push().repository());
421
422        let inherited = GithubRemoteSnapshot::from_local_config_output(
423            &remote,
424            b"remote.origin.url\nhttps://github.com/owner/repo\0",
425        )
426        .expect("inherited push URL");
427        assert_eq!(inherited.fetch(), inherited.push());
428    }
429
430    #[test]
431    fn discovery_rejects_duplicate_and_cross_repository_values() {
432        let remote = GitRemoteName::new("origin").expect("remote");
433        for (output, expected) in [
434            (
435                b"remote.origin.url\nhttps://github.com/owner/repo\0remote.origin.url\nhttps://github.com/owner/repo\0"
436                    .as_slice(),
437                GitRemoteDiscoveryError::DuplicateValue,
438            ),
439            (
440                b"remote.origin.url\nhttps://github.com/owner/repo\0remote.origin.pushurl\nhttps://github.com/attacker/repo\0"
441                    .as_slice(),
442                GitRemoteDiscoveryError::RepositoryMismatch,
443            ),
444            (
445                b"remote.origin.url\nhttps://github.com/owner/repo\0remote.other.pushurl\nhttps://github.com/owner/repo\0"
446                    .as_slice(),
447                GitRemoteDiscoveryError::UnexpectedKey,
448            ),
449        ] {
450            assert_eq!(
451                GithubRemoteSnapshot::from_local_config_output(&remote, output),
452                Err(expected)
453            );
454        }
455    }
456
457    #[test]
458    fn discovery_arguments_disable_includes_in_one_local_read() {
459        let remote = GitRemoteName::new("safe.remote").expect("remote");
460        let arguments = remote.discovery_arguments();
461        assert_eq!(
462            arguments,
463            [
464                "config",
465                "--local",
466                "--no-includes",
467                "-z",
468                "--get-regexp",
469                "^remote\\.safe\\.remote\\.(url|pushurl)$",
470            ]
471            .map(OsString::from)
472        );
473    }
474}