1use 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#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
14pub enum GithubGitTransport {
15 Https,
17 Ssh,
19}
20
21#[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 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 pub const fn repository(&self) -> &Repository {
86 &self.repository
87 }
88
89 pub const fn transport(&self) -> GithubGitTransport {
91 self.transport
92 }
93
94 pub fn canonical_url(&self) -> &str {
96 &self.canonical_url
97 }
98
99 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum GithubGitEndpointError {
137 InvalidSyntax,
139 UnsupportedTransport,
141 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
163pub struct GitRemoteName(String);
164
165impl GitRemoteName {
166 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 pub fn as_str(&self) -> &str {
187 &self.0
188 }
189
190 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#[derive(Clone, Debug, Eq, PartialEq)]
206pub struct GithubRemoteSnapshot {
207 fetch: GithubGitEndpoint,
208 push: GithubGitEndpoint,
209}
210
211impl GithubRemoteSnapshot {
212 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 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 pub const fn fetch(&self) -> &GithubGitEndpoint {
287 &self.fetch
288 }
289
290 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
306pub enum GitRemoteDiscoveryError {
307 InvalidRemoteName,
309 MalformedOutput,
311 UnexpectedKey,
313 MissingFetchUrl,
315 DuplicateValue,
317 Endpoint(GithubGitEndpointError),
319 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}