1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
4
5use sley_core::{GitError, ObjectFormat, Result};
6use sley_protocol::*;
7use std::io::{Read, Write};
8
9pub mod credential;
10
11pub use credential::{
12 cmd_credential_cache, cmd_credential_cache_daemon, cmd_credential_store,
13 credential_announce_capabilities, credential_approve, credential_fill, credential_next_state,
14 credential_read, credential_reject, credential_set_all_capabilities, credential_write,
15 CredentialOpType, GitCredential, TIME_MAX,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ServiceRequest {
20 pub service: GitService,
21 pub path: String,
22 pub host: Option<String>,
23 pub parameters: Vec<String>,
24 pub protocol: Option<ProtocolVersion>,
25 pub extra_parameters: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ServiceAnnouncement {
30 pub service: GitService,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ServiceDiscoveryPayload {
35 AdvertisedRefs(RefAdvertisementSet),
36 ProtocolV2(TransportHandshake),
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ServiceDiscoveryResponse {
41 pub announcement: ServiceAnnouncement,
42 pub payload: ServiceDiscoveryPayload,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Default)]
46pub struct GitProtocolHeader {
47 pub protocol: Option<ProtocolVersion>,
48 pub extra_parameters: Vec<String>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum RemoteTransport {
53 Local,
54 File,
55 Ext,
56 Ssh,
57 Git,
58 Http,
59 Https,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct RemoteUrl {
64 pub transport: RemoteTransport,
65 pub user: Option<String>,
66 pub password: Option<String>,
67 pub host: Option<String>,
68 pub port: Option<u16>,
69 pub path: String,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum SshCommandVariant {
74 OpenSsh,
75 Plink,
76 TortoisePlink,
77 Simple,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum SshIpVersion {
82 V4,
83 V6,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct SshProcessCommand {
88 pub program: String,
89 pub args: Vec<String>,
90}
91
92pub fn parse_service_request(payload: &[u8]) -> Result<ServiceRequest> {
93 validate_service_payload("service request", payload)?;
94 let (command, parameters) = match payload.iter().position(|byte| *byte == 0) {
95 Some(idx) => {
96 if !payload.ends_with(&[0]) {
97 return Err(GitError::InvalidFormat(
98 "service request parameters must be NUL terminated".into(),
99 ));
100 }
101 (&payload[..idx], Some(&payload[idx + 1..payload.len() - 1]))
102 }
103 None => (payload, None),
104 };
105 let command =
106 std::str::from_utf8(command).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
107 let (service, path) = command
108 .split_once(' ')
109 .ok_or_else(|| GitError::InvalidFormat("service request is missing path".into()))?;
110 validate_service_field("service request path", path)?;
111
112 let mut request = ServiceRequest {
113 service: parse_git_service(service)?,
114 path: path.to_string(),
115 host: None,
116 parameters: Vec::new(),
117 protocol: None,
118 extra_parameters: Vec::new(),
119 };
120
121 let Some(parameters) = parameters else {
122 return Ok(request);
123 };
124 let mut in_extra = false;
125 for parameter in parameters.split(|byte| *byte == 0) {
126 if parameter.is_empty() {
127 in_extra = true;
128 continue;
129 }
130 validate_service_payload("service request parameter", parameter)?;
131 let parameter = std::str::from_utf8(parameter)
132 .map_err(|err| GitError::InvalidFormat(err.to_string()))?;
133 if !in_extra {
134 if let Some(host) = parameter.strip_prefix("host=") {
135 if request.host.is_some() {
136 return Err(GitError::InvalidFormat(
137 "service request has duplicate host".into(),
138 ));
139 }
140 validate_service_field("service request host", host)?;
141 request.host = Some(host.to_string());
142 } else {
143 validate_service_field("service request parameter", parameter)?;
144 request.parameters.push(parameter.to_string());
145 }
146 continue;
147 }
148
149 if let Some(version) = parameter.strip_prefix("version=") {
150 if request.protocol.is_some() {
151 return Err(GitError::InvalidFormat(
152 "service request has duplicate protocol version".into(),
153 ));
154 }
155 request.protocol = Some(parse_service_protocol_version(version)?);
156 } else {
157 validate_service_field("service request extra parameter", parameter)?;
158 request.extra_parameters.push(parameter.to_string());
159 }
160 }
161 Ok(request)
162}
163
164pub fn encode_service_request(request: &ServiceRequest) -> Result<Vec<u8>> {
165 validate_service_field("service request path", &request.path)?;
166 let mut out = Vec::new();
167 out.extend_from_slice(request.service.as_str().as_bytes());
168 out.push(b' ');
169 out.extend_from_slice(request.path.as_bytes());
170
171 let has_parameters = request.host.is_some()
172 || !request.parameters.is_empty()
173 || request.protocol.is_some()
174 || !request.extra_parameters.is_empty();
175 if has_parameters {
176 out.push(0);
177 }
178 if let Some(host) = &request.host {
179 validate_service_field("service request host", host)?;
180 out.extend_from_slice(b"host=");
181 out.extend_from_slice(host.as_bytes());
182 out.push(0);
183 }
184 for parameter in &request.parameters {
185 validate_service_field("service request parameter", parameter)?;
186 out.extend_from_slice(parameter.as_bytes());
187 out.push(0);
188 }
189 if request.protocol.is_some() || !request.extra_parameters.is_empty() {
190 out.push(0);
191 if let Some(protocol) = request.protocol {
192 let version = match protocol {
193 ProtocolVersion::V0 => {
194 return Err(GitError::InvalidFormat(
195 "service request must not encode protocol v0 as an extra parameter".into(),
196 ));
197 }
198 ProtocolVersion::V1 => "version=1",
199 ProtocolVersion::V2 => "version=2",
200 };
201 out.extend_from_slice(version.as_bytes());
202 out.push(0);
203 }
204 for parameter in &request.extra_parameters {
205 validate_service_field("service request extra parameter", parameter)?;
206 out.extend_from_slice(parameter.as_bytes());
207 out.push(0);
208 }
209 }
210 Ok(out)
211}
212
213pub fn read_service_request(reader: &mut impl Read) -> Result<ServiceRequest> {
214 let Some(frame) = read_pkt_line_frame(reader)? else {
215 return Err(GitError::InvalidFormat(
216 "pkt-line stream ended before service request".into(),
217 ));
218 };
219 match frame {
220 PktLineFrame::Data(payload) => parse_service_request(&payload),
221 PktLineFrame::Flush | PktLineFrame::Delimiter | PktLineFrame::ResponseEnd => Err(
222 GitError::InvalidFormat("service request must be a data packet".into()),
223 ),
224 }
225}
226
227pub fn write_service_request(writer: &mut impl Write, request: &ServiceRequest) -> Result<()> {
228 write_pkt_line_payload(writer, &encode_service_request(request)?)
229}
230
231pub fn parse_service_announcement(payload: &[u8]) -> Result<ServiceAnnouncement> {
232 let text = parse_protocol_v2_line_text("service announcement", payload)?;
233 let service = text
234 .strip_prefix("# service=")
235 .ok_or_else(|| GitError::InvalidFormat("service announcement is missing service".into()))?;
236 Ok(ServiceAnnouncement {
237 service: parse_git_service(service)?,
238 })
239}
240
241pub fn encode_service_announcement(announcement: &ServiceAnnouncement) -> Result<Vec<u8>> {
242 Ok(line_from_str(&format!(
243 "# service={}",
244 announcement.service.as_str()
245 )))
246}
247
248pub fn parse_service_announcement_stream(frames: &[PktLineFrame]) -> Result<ServiceAnnouncement> {
249 match frames {
250 [PktLineFrame::Data(payload), PktLineFrame::Flush] => parse_service_announcement(payload),
251 [PktLineFrame::Data(_), ..] => Err(GitError::InvalidFormat(
252 "service announcement stream must contain only announcement and flush".into(),
253 )),
254 [] => Err(GitError::InvalidFormat(
255 "service announcement stream is empty".into(),
256 )),
257 _ => Err(GitError::InvalidFormat(
258 "service announcement stream must start with a data packet".into(),
259 )),
260 }
261}
262
263pub fn encode_service_announcement_stream(
264 announcement: &ServiceAnnouncement,
265) -> Result<Vec<PktLineFrame>> {
266 Ok(vec![
267 PktLineFrame::data(encode_service_announcement(announcement)?)?,
268 PktLineFrame::Flush,
269 ])
270}
271
272pub fn read_service_announcement(reader: &mut impl Read) -> Result<ServiceAnnouncement> {
273 let frames = read_pkt_line_frames_until_flush(reader)?;
274 parse_service_announcement_stream(&frames)
275}
276
277pub fn write_service_announcement(
278 writer: &mut impl Write,
279 announcement: &ServiceAnnouncement,
280) -> Result<()> {
281 write_pkt_line_payload(writer, &encode_service_announcement(announcement)?)?;
282 writer.write_all(b"0000")?;
283 Ok(())
284}
285
286pub fn parse_service_discovery_response(
287 format: ObjectFormat,
288 frames: &[PktLineFrame],
289) -> Result<ServiceDiscoveryResponse> {
290 let announcement_end = frames
291 .iter()
292 .position(|frame| matches!(frame, PktLineFrame::Flush))
293 .ok_or_else(|| {
294 GitError::InvalidFormat("service discovery response missing announcement flush".into())
295 })?;
296 let announcement = parse_service_announcement_stream(&frames[..=announcement_end])?;
297 let payload_frames = &frames[announcement_end + 1..];
298 if payload_frames.is_empty() {
299 return Err(GitError::InvalidFormat(
300 "service discovery response missing payload".into(),
301 ));
302 }
303 let payload = match payload_frames.first() {
304 Some(PktLineFrame::Data(payload)) if trim_trailing_lf(payload) == b"version 2" => {
305 ServiceDiscoveryPayload::ProtocolV2(parse_protocol_v2_advertisement(payload_frames)?)
306 }
307 Some(_) => ServiceDiscoveryPayload::AdvertisedRefs(parse_ref_advertisement_set(
308 format,
309 payload_frames,
310 )?),
311 None => unreachable!("payload_frames is non-empty"),
312 };
313 Ok(ServiceDiscoveryResponse {
314 announcement,
315 payload,
316 })
317}
318
319pub fn encode_service_discovery_response(
320 response: &ServiceDiscoveryResponse,
321) -> Result<Vec<PktLineFrame>> {
322 let mut frames = encode_service_announcement_stream(&response.announcement)?;
323 match &response.payload {
324 ServiceDiscoveryPayload::AdvertisedRefs(refs) => {
325 frames.extend(encode_ref_advertisement_set(refs)?);
326 }
327 ServiceDiscoveryPayload::ProtocolV2(handshake) => {
328 frames.extend(encode_protocol_v2_advertisement(handshake)?);
329 }
330 }
331 Ok(frames)
332}
333
334pub fn read_service_discovery_response(
335 format: ObjectFormat,
336 reader: &mut impl Read,
337) -> Result<ServiceDiscoveryResponse> {
338 let mut frames = read_pkt_line_frames_until_flush(reader)?;
339 if let Some(PktLineFrame::Data(payload)) = frames.first() {
342 if trim_trailing_lf(payload) == b"version 2" {
343 return Ok(ServiceDiscoveryResponse {
344 announcement: ServiceAnnouncement {
345 service: GitService::UploadPack,
346 },
347 payload: ServiceDiscoveryPayload::ProtocolV2(parse_protocol_v2_advertisement(
348 &frames,
349 )?),
350 });
351 }
352 }
353 frames.extend(read_pkt_line_frames_until_flush(reader)?);
354 parse_service_discovery_response(format, &frames)
355}
356
357pub fn write_service_discovery_response(
358 writer: &mut impl Write,
359 response: &ServiceDiscoveryResponse,
360) -> Result<()> {
361 write_service_announcement(writer, &response.announcement)?;
362 match &response.payload {
363 ServiceDiscoveryPayload::AdvertisedRefs(refs) => write_ref_advertisement_set(writer, refs),
364 ServiceDiscoveryPayload::ProtocolV2(handshake) => {
365 write_protocol_v2_advertisement(writer, handshake)
366 }
367 }
368}
369
370pub fn parse_remote_url(value: &str) -> Result<RemoteUrl> {
371 validate_remote_url_value(value)?;
372 if let Some(command) = value.strip_prefix("ext::") {
373 if command.is_empty() {
374 return Err(GitError::InvalidFormat(
375 "ext remote command is empty".into(),
376 ));
377 }
378 validate_remote_path("ext remote command", command)?;
379 return Ok(RemoteUrl {
380 transport: RemoteTransport::Ext,
381 user: None,
382 password: None,
383 host: None,
384 port: None,
385 path: command.to_string(),
386 });
387 }
388 if let Some((scheme, rest)) = value.split_once("://") {
389 return parse_remote_url_with_scheme(scheme, rest);
390 }
391 if let Some(colon) = scp_like_separator(value) {
392 let (authority, path) = value.split_at(colon);
393 let path = &path[1..];
394 validate_remote_path("remote path", path)?;
395 let (user, host, port) = parse_scp_like_authority(authority)?;
396 return Ok(RemoteUrl {
397 transport: RemoteTransport::Ssh,
398 user,
399 password: None,
400 host: Some(host),
401 port,
402 path: path.to_string(),
403 });
404 }
405 validate_remote_path("local remote path", value)?;
406 Ok(RemoteUrl {
407 transport: RemoteTransport::Local,
408 user: None,
409 password: None,
410 host: None,
411 port: None,
412 path: value.to_string(),
413 })
414}
415
416pub fn parse_git_credential(input: &[u8]) -> Result<GitCredential> {
417 parse_legacy_git_credential_impl(input)
418}
419
420pub fn encode_git_credential(credential: &GitCredential) -> Result<Vec<u8>> {
421 encode_legacy_git_credential_impl(credential)
422}
423
424pub(crate) fn parse_legacy_git_credential_impl(input: &[u8]) -> Result<GitCredential> {
425 let mut credential = GitCredential::default();
426 let mut lines = input.split(|byte| *byte == b'\n');
427 let mut finished = false;
428 while let Some(line) = lines.next() {
429 let line = line.strip_suffix(b"\r").unwrap_or(line);
430 if line.is_empty() {
431 finished = true;
432 break;
433 }
434 let line = std::str::from_utf8(line).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
435 let Some((key, value)) = line.split_once('=') else {
436 return Err(GitError::InvalidFormat("credential line is missing = delimiter".into()));
437 };
438 if key.is_empty() {
439 return Err(GitError::InvalidFormat("credential key is empty".into()));
440 }
441 validate_credential_key(key)?;
442 validate_credential_value("credential value", value)?;
443 match key {
444 "protocol" => set_credential_field(&mut credential.protocol, key, value)?,
445 "host" => set_credential_field(&mut credential.host, key, value)?,
446 "path" => set_credential_field(&mut credential.path, key, value)?,
447 "username" => set_credential_field(&mut credential.username, key, value)?,
448 "password" => set_credential_field(&mut credential.password, key, value)?,
449 "password_expiry_utc" => {
450 if credential.password_expiry_utc != TIME_MAX {
451 return Err(GitError::InvalidFormat(format!(
452 "credential key {key} appears more than once"
453 )));
454 }
455 credential.password_expiry_utc = value.parse().unwrap_or(TIME_MAX);
456 }
457 "oauth_refresh_token" => {
458 set_credential_field(&mut credential.oauth_refresh_token, key, value)?
459 }
460 "url" => set_credential_field(&mut credential.url, key, value)?,
461 "wwwauth[]" => credential.wwwauth.push(value.to_string()),
462 "quit" => credential.quit = !value.is_empty() && value != "0" && value != "false",
463 _ => {
464 if is_known_credential_key(key) {
465 return Err(GitError::InvalidFormat(format!(
466 "credential key {key} appears more than once"
467 )));
468 }
469 credential.extra.push((key.to_string(), value.to_string()));
470 }
471 }
472 }
473 if !finished {
474 return Err(GitError::InvalidFormat(
475 "credential payload must end with a blank line".into(),
476 ));
477 }
478 if let Some(rest) = lines.next()
479 && (!rest.is_empty() || lines.next().is_some())
480 {
481 return Err(GitError::InvalidFormat(
482 "credential payload has trailing data after blank line".into(),
483 ));
484 }
485 Ok(credential)
486}
487
488pub(crate) fn encode_legacy_git_credential_impl(credential: &GitCredential) -> Result<Vec<u8>> {
489 let mut out = Vec::new();
490 encode_credential_field(&mut out, "protocol", credential.protocol.as_deref())?;
491 encode_credential_field(&mut out, "host", credential.host.as_deref())?;
492 encode_credential_field(&mut out, "path", credential.path.as_deref())?;
493 encode_credential_field(&mut out, "username", credential.username.as_deref())?;
494 encode_credential_field(&mut out, "password", credential.password.as_deref())?;
495 if credential.password_expiry_utc != TIME_MAX {
496 encode_credential_field(
497 &mut out,
498 "password_expiry_utc",
499 Some(&credential.password_expiry_utc.to_string()),
500 )?;
501 }
502 encode_credential_field(
503 &mut out,
504 "oauth_refresh_token",
505 credential.oauth_refresh_token.as_deref(),
506 )?;
507 encode_credential_field(&mut out, "url", credential.url.as_deref())?;
508 for value in &credential.wwwauth {
509 encode_credential_field(&mut out, "wwwauth[]", Some(value.as_str()))?;
510 }
511 if credential.quit {
512 encode_credential_field(&mut out, "quit", Some("true"))?;
513 }
514 for (key, value) in &credential.extra {
515 if is_known_credential_key(key) {
516 return Err(GitError::InvalidFormat(format!(
517 "credential extra key {key} conflicts with a known field"
518 )));
519 }
520 encode_credential_field(&mut out, key, Some(value.as_str()))?;
521 }
522 out.push(b'\n');
523 Ok(out)
524}
525
526const MAX_GIT_CREDENTIAL_RESPONSE_BYTES: usize = 64 * 1024;
527
528pub fn read_git_credential(reader: &mut impl Read) -> Result<GitCredential> {
529 let mut input = Vec::new();
530 reader
531 .take((MAX_GIT_CREDENTIAL_RESPONSE_BYTES as u64).saturating_add(1))
532 .read_to_end(&mut input)?;
533 if input.len() > MAX_GIT_CREDENTIAL_RESPONSE_BYTES {
534 return Err(GitError::InvalidFormat(format!(
535 "credential helper response exceeds maximum size of {} bytes (64 KiB)",
536 MAX_GIT_CREDENTIAL_RESPONSE_BYTES
537 )));
538 }
539 parse_git_credential(&input)
540}
541
542pub fn write_git_credential(writer: &mut impl Write, credential: &GitCredential) -> Result<()> {
543 credential_write(credential, writer, CredentialOpType::Response)
544}
545
546pub fn git_credential_basic_authorization(credential: &GitCredential) -> Result<Option<String>> {
547 let (Some(username), Some(password)) = (
548 credential.username.as_deref(),
549 credential.password.as_deref(),
550 ) else {
551 return Ok(None);
552 };
553 validate_http_auth_component("credential username", username)?;
554 if username.bytes().any(|byte| byte == b':') {
555 return Err(GitError::InvalidFormat(
556 "credential username contains a delimiter byte".into(),
557 ));
558 }
559 validate_http_auth_component("credential password", password)?;
560 let mut token = Vec::with_capacity(username.len() + 1 + password.len());
561 token.extend_from_slice(username.as_bytes());
562 token.push(b':');
563 token.extend_from_slice(password.as_bytes());
564 Ok(Some(format!("Basic {}", encode_base64(&token))))
565}
566
567pub fn git_credential_bearer_authorization(token: &str) -> Result<String> {
568 validate_http_auth_component("bearer token", token)?;
569 if token.is_empty() {
570 return Err(GitError::InvalidFormat("bearer token is empty".into()));
571 }
572 Ok(format!("Bearer {token}"))
573}
574
575pub fn http_smart_info_refs_url(remote: &RemoteUrl, service: GitService) -> Result<String> {
582 let origin = http_remote_origin(remote)?;
583 let path = smart_http_info_refs_path(&remote.path, service)?;
584 Ok(format!("{origin}{path}"))
585}
586
587pub fn http_smart_rpc_url(remote: &RemoteUrl, service: GitService) -> Result<String> {
594 let origin = http_remote_origin(remote)?;
595 let path = smart_http_rpc_path(&remote.path, service)?;
596 Ok(format!("{origin}{path}"))
597}
598
599fn http_remote_origin(remote: &RemoteUrl) -> Result<String> {
602 let scheme = match remote.transport {
603 RemoteTransport::Http => "http",
604 RemoteTransport::Https => "https",
605 _ => {
606 return Err(GitError::InvalidFormat(
607 "smart HTTP URL requires an http or https remote".into(),
608 ));
609 }
610 };
611 let host = remote
612 .host
613 .as_deref()
614 .ok_or_else(|| GitError::InvalidFormat("smart HTTP remote is missing a host".into()))?;
615 let host = if host.contains(':') && !host.starts_with('[') {
616 format!("[{host}]")
617 } else {
618 host.to_string()
619 };
620 match remote.port {
621 Some(port) => Ok(format!("{scheme}://{host}:{port}")),
622 None => Ok(format!("{scheme}://{host}")),
623 }
624}
625
626pub fn ssh_service_command(service: GitService, repository_path: &str) -> Result<String> {
627 validate_ssh_repository_path(repository_path)?;
628 let repository_path = ssh_service_repository_path(repository_path);
629 Ok(format!(
630 "{} {}",
631 service.as_str(),
632 quote_ssh_repository_path(repository_path)
633 ))
634}
635
636pub fn ssh_process_command(
637 remote: &RemoteUrl,
638 service: GitService,
639 program: impl Into<String>,
640 variant: SshCommandVariant,
641) -> Result<SshProcessCommand> {
642 if remote.transport != RemoteTransport::Ssh {
643 return Err(GitError::InvalidFormat(
644 "SSH process command requires an SSH remote".into(),
645 ));
646 }
647 let program = program.into();
648 validate_ssh_program(&program)?;
649 let args = ssh_process_args(remote, service, variant)?;
650 Ok(SshProcessCommand { program, args })
651}
652
653pub fn ssh_process_args(
654 remote: &RemoteUrl,
655 service: GitService,
656 variant: SshCommandVariant,
657) -> Result<Vec<String>> {
658 ssh_process_args_with_ip(remote, service, variant, None)
659}
660
661pub fn ssh_process_args_with_ip(
662 remote: &RemoteUrl,
663 service: GitService,
664 variant: SshCommandVariant,
665 ip_version: Option<SshIpVersion>,
666) -> Result<Vec<String>> {
667 if remote.transport != RemoteTransport::Ssh {
668 return Err(GitError::InvalidFormat(
669 "SSH process arguments require an SSH remote".into(),
670 ));
671 }
672 let mut args = Vec::new();
673 if let Some(ip_version) = ip_version {
674 match variant {
675 SshCommandVariant::OpenSsh
676 | SshCommandVariant::Plink
677 | SshCommandVariant::TortoisePlink => {
678 args.push(
679 match ip_version {
680 SshIpVersion::V4 => "-4",
681 SshIpVersion::V6 => "-6",
682 }
683 .into(),
684 );
685 }
686 SshCommandVariant::Simple => {
687 return Err(GitError::InvalidFormat(
688 "simple SSH variant cannot pass an IP version".into(),
689 ));
690 }
691 }
692 }
693 if matches!(variant, SshCommandVariant::TortoisePlink) {
694 args.push("-batch".into());
695 }
696 if let Some(port) = remote.port {
697 match variant {
698 SshCommandVariant::OpenSsh => {
699 args.push("-p".into());
700 args.push(port.to_string());
701 }
702 SshCommandVariant::Plink | SshCommandVariant::TortoisePlink => {
703 args.push("-P".into());
704 args.push(port.to_string());
705 }
706 SshCommandVariant::Simple => {
707 return Err(GitError::InvalidFormat(
708 "simple SSH variant cannot pass a port".into(),
709 ));
710 }
711 }
712 }
713 args.push(ssh_host_argument(remote)?);
714 args.push(ssh_service_command(service, &remote.path)?);
715 Ok(args)
716}
717
718pub fn ssh_host_argument(remote: &RemoteUrl) -> Result<String> {
719 if remote.transport != RemoteTransport::Ssh {
720 return Err(GitError::InvalidFormat(
721 "SSH host argument requires an SSH remote".into(),
722 ));
723 }
724 let host = remote
725 .host
726 .as_deref()
727 .ok_or_else(|| GitError::InvalidFormat("SSH remote is missing a host".into()))?;
728 validate_remote_host(host)?;
729 if let Some(user) = &remote.user {
730 validate_ssh_user(user)?;
731 Ok(format!("{user}@{host}"))
732 } else {
733 Ok(host.to_string())
734 }
735}
736
737pub fn encode_git_protocol_header(header: &GitProtocolHeader) -> Result<Option<String>> {
738 let mut fields = Vec::new();
739 if let Some(protocol) = header.protocol {
740 let version = match protocol {
741 ProtocolVersion::V0 => {
742 return Err(GitError::InvalidFormat(
743 "Git-Protocol header must not encode protocol v0".into(),
744 ));
745 }
746 ProtocolVersion::V1 => "version=1",
747 ProtocolVersion::V2 => "version=2",
748 };
749 fields.push(version.to_string());
750 }
751 for parameter in &header.extra_parameters {
752 validate_git_protocol_header_parameter(parameter)?;
753 fields.push(parameter.clone());
754 }
755 Ok((!fields.is_empty()).then(|| fields.join(":")))
756}
757
758pub fn parse_git_protocol_header(value: &str) -> Result<GitProtocolHeader> {
759 if value.is_empty() {
760 return Err(GitError::InvalidFormat(
761 "Git-Protocol header is empty".into(),
762 ));
763 }
764 let mut header = GitProtocolHeader::default();
765 for parameter in value.split(':') {
766 validate_git_protocol_header_parameter(parameter)?;
767 if let Some(version) = parameter.strip_prefix("version=") {
768 if header.protocol.is_some() {
769 return Err(GitError::InvalidFormat(
770 "Git-Protocol header has duplicate protocol version".into(),
771 ));
772 }
773 header.protocol = Some(parse_service_protocol_version(version)?);
774 } else {
775 header.extra_parameters.push(parameter.to_string());
776 }
777 }
778 Ok(header)
779}
780
781fn validate_remote_url_value(value: &str) -> Result<()> {
782 if value.is_empty() {
783 return Err(GitError::InvalidFormat("remote URL is empty".into()));
784 }
785 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
786 return Err(GitError::InvalidFormat(
787 "remote URL contains a delimiter byte".into(),
788 ));
789 }
790 Ok(())
791}
792
793fn validate_remote_path(label: &str, value: &str) -> Result<()> {
794 if value.is_empty() {
795 return Err(GitError::InvalidFormat(format!("{label} is empty")));
796 }
797 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
798 return Err(GitError::InvalidFormat(format!(
799 "{label} contains a delimiter byte"
800 )));
801 }
802 Ok(())
803}
804
805fn set_credential_field(slot: &mut Option<String>, key: &str, value: &str) -> Result<()> {
806 if slot.is_some() {
807 return Err(GitError::InvalidFormat(format!(
808 "credential key {key} appears more than once"
809 )));
810 }
811 *slot = Some(value.to_string());
812 Ok(())
813}
814
815fn encode_credential_field(out: &mut impl Write, key: &str, value: Option<&str>) -> Result<()> {
816 let Some(value) = value else {
817 return Ok(());
818 };
819 validate_credential_key(key)?;
820 validate_credential_value("credential value", value)?;
821 out.write_all(key.as_bytes())?;
822 out.write_all(b"=")?;
823 out.write_all(value.as_bytes())?;
824 out.write_all(b"\n")?;
825 Ok(())
826}
827
828fn validate_credential_key(value: &str) -> Result<()> {
829 if value.is_empty() {
830 return Err(GitError::InvalidFormat("credential key is empty".into()));
831 }
832 if value
833 .bytes()
834 .any(|byte| matches!(byte, b'=' | b'\n' | b'\r' | 0))
835 {
836 return Err(GitError::InvalidFormat(
837 "credential key contains a delimiter byte".into(),
838 ));
839 }
840 Ok(())
841}
842
843fn validate_credential_value(label: &str, value: &str) -> Result<()> {
844 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
845 return Err(GitError::InvalidFormat(format!(
846 "{label} contains a delimiter byte"
847 )));
848 }
849 Ok(())
850}
851
852fn is_known_credential_key(value: &str) -> bool {
853 matches!(
854 value,
855 "protocol"
856 | "host"
857 | "path"
858 | "username"
859 | "password"
860 | "password_expiry_utc"
861 | "oauth_refresh_token"
862 | "url"
863 | "wwwauth[]"
864 | "quit"
865 )
866}
867
868fn validate_http_auth_component(label: &str, value: &str) -> Result<()> {
869 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
870 return Err(GitError::InvalidFormat(format!(
871 "{label} contains a delimiter byte"
872 )));
873 }
874 Ok(())
875}
876
877fn encode_base64(input: &[u8]) -> String {
878 const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
879 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
880 for chunk in input.chunks(3) {
881 let first = chunk[0];
882 let second = chunk.get(1).copied().unwrap_or(0);
883 let third = chunk.get(2).copied().unwrap_or(0);
884 out.push(TABLE[(first >> 2) as usize] as char);
885 out.push(TABLE[(((first & 0b0000_0011) << 4) | (second >> 4)) as usize] as char);
886 if chunk.len() > 1 {
887 out.push(TABLE[(((second & 0b0000_1111) << 2) | (third >> 6)) as usize] as char);
888 } else {
889 out.push('=');
890 }
891 if chunk.len() > 2 {
892 out.push(TABLE[(third & 0b0011_1111) as usize] as char);
893 } else {
894 out.push('=');
895 }
896 }
897 out
898}
899
900fn parse_remote_url_with_scheme(scheme: &str, rest: &str) -> Result<RemoteUrl> {
901 let scheme = scheme.to_ascii_lowercase();
902 match scheme.as_str() {
903 "file" => {
904 validate_remote_path("file remote path", rest)?;
905 if !rest.starts_with('/') {
906 return Err(GitError::InvalidFormat(
907 "file remote path must start with /".into(),
908 ));
909 }
910 Ok(RemoteUrl {
911 transport: RemoteTransport::File,
912 user: None,
913 password: None,
914 host: None,
915 port: None,
916 path: rest.to_string(),
917 })
918 }
919 "ssh" | "git+ssh" | "ssh+git" | "git" | "http" | "https" => {
920 let is_http = scheme == "http" || scheme == "https";
921 let empty_port_is_absent = matches!(scheme.as_str(), "ssh" | "git+ssh" | "ssh+git");
922 let (authority, path) = split_remote_authority_and_path(rest)?;
923 let (user, password, host, port) =
926 if matches!(scheme.as_str(), "ssh" | "git+ssh" | "ssh+git") {
927 let (user, host, port) = parse_ssh_scheme_authority(authority)?;
928 (user, None, host, port)
929 } else {
930 parse_remote_authority(authority, true, is_http, empty_port_is_absent)?
931 };
932 let path = if matches!(scheme.as_str(), "ssh" | "git+ssh" | "ssh+git") {
933 percent_decode_remote_path(&path)?
934 } else {
935 path
936 };
937 Ok(RemoteUrl {
938 transport: match scheme.as_str() {
939 "ssh" | "git+ssh" | "ssh+git" => RemoteTransport::Ssh,
940 "git" => RemoteTransport::Git,
941 "http" => RemoteTransport::Http,
942 "https" => RemoteTransport::Https,
943 _ => unreachable!("matched remote URL scheme"),
944 },
945 user,
946 password,
947 host: Some(host),
948 port,
949 path,
950 })
951 }
952 _ => Err(GitError::InvalidFormat(format!(
953 "unsupported remote URL scheme {scheme}"
954 ))),
955 }
956}
957
958fn split_remote_authority_and_path(value: &str) -> Result<(&str, String)> {
959 let slash = value
960 .find('/')
961 .ok_or_else(|| GitError::InvalidFormat("remote URL is missing a path".into()))?;
962 let (authority, path) = value.split_at(slash);
963 if authority.is_empty() {
964 return Err(GitError::InvalidFormat(
965 "remote URL is missing a host".into(),
966 ));
967 }
968 validate_remote_path("remote path", path)?;
969 Ok((authority, path.to_string()))
970}
971
972fn percent_decode_remote_path(value: &str) -> Result<String> {
973 let bytes = value.as_bytes();
974 let mut out = Vec::with_capacity(bytes.len());
975 let mut idx = 0;
976 while idx < bytes.len() {
977 if bytes[idx] == b'%' {
978 if idx + 2 >= bytes.len() {
979 return Err(GitError::InvalidFormat(format!(
980 "invalid percent-encoded remote path {value:?}"
981 )));
982 }
983 let high = percent_hex_value(bytes[idx + 1]).ok_or_else(|| {
984 GitError::InvalidFormat(format!("invalid percent-encoded remote path {value:?}"))
985 })?;
986 let low = percent_hex_value(bytes[idx + 2]).ok_or_else(|| {
987 GitError::InvalidFormat(format!("invalid percent-encoded remote path {value:?}"))
988 })?;
989 out.push((high << 4) | low);
990 idx += 3;
991 } else {
992 out.push(bytes[idx]);
993 idx += 1;
994 }
995 }
996 let decoded = String::from_utf8(out).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
997 validate_remote_path("remote path", &decoded)?;
998 Ok(decoded)
999}
1000
1001fn percent_hex_value(byte: u8) -> Option<u8> {
1002 match byte {
1003 b'0'..=b'9' => Some(byte - b'0'),
1004 b'a'..=b'f' => Some(byte - b'a' + 10),
1005 b'A'..=b'F' => Some(byte - b'A' + 10),
1006 _ => None,
1007 }
1008}
1009
1010type ParsedAuthority = (Option<String>, Option<String>, String, Option<u16>);
1013
1014fn parse_remote_authority(
1015 value: &str,
1016 allow_port: bool,
1017 split_password: bool,
1018 empty_port_is_absent: bool,
1019) -> Result<ParsedAuthority> {
1020 if value.is_empty() {
1021 return Err(GitError::InvalidFormat(
1022 "remote URL is missing a host".into(),
1023 ));
1024 }
1025 let (user, password, host_port) = match value.rsplit_once('@') {
1026 Some((userinfo, host_port)) => {
1027 if userinfo.is_empty() {
1028 return Err(GitError::InvalidFormat("remote URL user is empty".into()));
1029 }
1030 if split_password {
1031 match userinfo.split_once(':') {
1035 Some((user, password)) => (
1036 Some(user.to_string()),
1037 Some(password.to_string()),
1038 host_port,
1039 ),
1040 None => (Some(userinfo.to_string()), None, host_port),
1041 }
1042 } else {
1043 (Some(userinfo.to_string()), None, host_port)
1046 }
1047 }
1048 None => (None, None, value),
1049 };
1050 let (host, port) = parse_remote_host_port(host_port, allow_port, empty_port_is_absent)?;
1051 validate_remote_host(&host)?;
1052 Ok((user, password, host, port))
1053}
1054
1055fn parse_ssh_scheme_authority(value: &str) -> Result<(Option<String>, String, Option<u16>)> {
1056 if value.is_empty() {
1057 return Err(GitError::InvalidFormat(
1058 "remote URL is missing a host".into(),
1059 ));
1060 }
1061 if let Some(rest) = value.strip_prefix('[') {
1062 let end = rest
1063 .find(']')
1064 .ok_or_else(|| GitError::InvalidFormat("remote URL IPv6 host is missing ]".into()))?;
1065 let bracketed = &rest[..end];
1066 let suffix = &rest[end + 1..];
1067 let (user, host) = split_ssh_user_host(bracketed)?;
1068 let port = parse_optional_ssh_port_suffix(suffix)?;
1069 validate_remote_host(host)?;
1070 return Ok((user.map(str::to_string), host.to_string(), port));
1071 }
1072
1073 let (user, host_port) = match value.rsplit_once('@') {
1074 Some((userinfo, host_port)) => {
1075 if userinfo.is_empty() {
1076 return Err(GitError::InvalidFormat("remote URL user is empty".into()));
1077 }
1078 (Some(userinfo), host_port)
1079 }
1080 None => (None, value),
1081 };
1082 validate_optional_ssh_user(user)?;
1083
1084 if let Some(rest) = host_port.strip_prefix('[') {
1085 let end = rest
1086 .find(']')
1087 .ok_or_else(|| GitError::InvalidFormat("remote URL IPv6 host is missing ]".into()))?;
1088 let host = &rest[..end];
1089 let suffix = &rest[end + 1..];
1090 let port = parse_optional_ssh_port_suffix(suffix)?;
1091 validate_remote_host(host)?;
1092 return Ok((user.map(str::to_string), host.to_string(), port));
1093 }
1094
1095 if host_port.contains(']') {
1096 return Err(GitError::InvalidFormat(
1097 "remote URL has invalid bracketed host".into(),
1098 ));
1099 }
1100 let (host, port) = if host_port.matches(':').count() <= 1 {
1101 match host_port.rsplit_once(':') {
1102 Some((host, "")) => (host, None),
1103 Some((host, port)) if port.bytes().all(|byte| byte.is_ascii_digit()) => {
1104 (host, Some(parse_remote_port(port)?))
1105 }
1106 Some((_, _)) => {
1107 return Err(GitError::InvalidFormat(
1108 "remote URL port must be numeric".into(),
1109 ));
1110 }
1111 None => (host_port, None),
1112 }
1113 } else {
1114 (host_port, None)
1115 };
1116 validate_remote_host(host)?;
1117 Ok((user.map(str::to_string), host.to_string(), port))
1118}
1119
1120fn split_ssh_user_host(value: &str) -> Result<(Option<&str>, &str)> {
1121 match value.rsplit_once('@') {
1122 Some((user, host)) => {
1123 if user.is_empty() {
1124 return Err(GitError::InvalidFormat("remote URL user is empty".into()));
1125 }
1126 validate_optional_ssh_user(Some(user))?;
1127 Ok((Some(user), host))
1128 }
1129 None => Ok((None, value)),
1130 }
1131}
1132
1133fn validate_optional_ssh_user(user: Option<&str>) -> Result<()> {
1134 if let Some(user) = user {
1135 validate_ssh_user(user)?;
1136 }
1137 Ok(())
1138}
1139
1140fn parse_optional_ssh_port_suffix(suffix: &str) -> Result<Option<u16>> {
1141 if suffix.is_empty() || suffix == ":" {
1142 return Ok(None);
1143 }
1144 let Some(port) = suffix.strip_prefix(':') else {
1145 return Err(GitError::InvalidFormat(
1146 "remote URL has invalid bracketed host suffix".into(),
1147 ));
1148 };
1149 Ok(Some(parse_remote_port(port)?))
1150}
1151
1152fn parse_remote_host_port(
1153 value: &str,
1154 allow_port: bool,
1155 empty_port_is_absent: bool,
1156) -> Result<(String, Option<u16>)> {
1157 if let Some(rest) = value.strip_prefix('[') {
1158 let end = rest
1159 .find(']')
1160 .ok_or_else(|| GitError::InvalidFormat("remote URL IPv6 host is missing ]".into()))?;
1161 let host = &rest[..end];
1162 let suffix = &rest[end + 1..];
1163 let port = if let Some(port) = suffix.strip_prefix(':') {
1164 if !allow_port {
1165 return Err(GitError::InvalidFormat(
1166 "remote URL must not include a port".into(),
1167 ));
1168 }
1169 if port.is_empty() && empty_port_is_absent {
1170 return Ok((host.to_string(), None));
1171 }
1172 Some(parse_remote_port(port)?)
1173 } else if suffix.is_empty() {
1174 None
1175 } else {
1176 return Err(GitError::InvalidFormat(
1177 "remote URL has invalid bracketed host suffix".into(),
1178 ));
1179 };
1180 return Ok((host.to_string(), port));
1181 }
1182 if value.contains(']') {
1183 return Err(GitError::InvalidFormat(
1184 "remote URL has invalid bracketed host".into(),
1185 ));
1186 }
1187 if let Some((host, port)) = value.rsplit_once(':') {
1188 if value[..host.len()].contains(':') {
1189 return Err(GitError::InvalidFormat(
1190 "remote URL IPv6 host must be bracketed".into(),
1191 ));
1192 }
1193 if port.is_empty() && empty_port_is_absent {
1194 return Ok((host.to_string(), None));
1195 }
1196 if port.bytes().all(|byte| byte.is_ascii_digit()) {
1197 if !allow_port {
1198 return Err(GitError::InvalidFormat(
1199 "remote URL must not include a port".into(),
1200 ));
1201 }
1202 return Ok((host.to_string(), Some(parse_remote_port(port)?)));
1203 }
1204 return Err(GitError::InvalidFormat(
1205 "remote URL port must be numeric".into(),
1206 ));
1207 }
1208 Ok((value.to_string(), None))
1209}
1210
1211fn parse_scp_like_authority(value: &str) -> Result<(Option<String>, String, Option<u16>)> {
1212 if let Some(rest) = value.strip_prefix('[') {
1213 let end = rest
1214 .find(']')
1215 .ok_or_else(|| GitError::InvalidFormat("remote URL IPv6 host is missing ]".into()))?;
1216 if end + 1 != rest.len() {
1217 return Err(GitError::InvalidFormat(
1218 "remote URL has invalid bracketed host suffix".into(),
1219 ));
1220 }
1221 let bracketed = &rest[..end];
1222 let (user, host_port) = match bracketed.rsplit_once('@') {
1223 Some((userinfo, host_port)) => {
1224 if userinfo.is_empty() {
1225 return Err(GitError::InvalidFormat("remote URL user is empty".into()));
1226 }
1227 (Some(userinfo.to_string()), host_port)
1228 }
1229 None => (None, bracketed),
1230 };
1231 let (host, port) = split_scp_like_bracketed_host_port(host_port)?;
1232 validate_remote_host(&host)?;
1233 return Ok((user, host, port));
1234 }
1235 let (user, _password, host, port) = parse_remote_authority(value, false, false, false)?;
1236 if port.is_some() {
1237 return Err(GitError::InvalidFormat(
1238 "scp-like SSH remote must not include a port".into(),
1239 ));
1240 }
1241 Ok((user, host, None))
1242}
1243
1244fn split_scp_like_bracketed_host_port(value: &str) -> Result<(String, Option<u16>)> {
1245 if let Some((host, port)) = value.rsplit_once(':')
1246 && !host.contains(':')
1247 && port.bytes().all(|byte| byte.is_ascii_digit())
1248 {
1249 return Ok((host.to_string(), Some(parse_remote_port(port)?)));
1250 }
1251 Ok((value.to_string(), None))
1252}
1253
1254fn parse_remote_port(value: &str) -> Result<u16> {
1255 if value.is_empty() {
1256 return Err(GitError::InvalidFormat("remote URL port is empty".into()));
1257 }
1258 value
1259 .parse::<u16>()
1260 .map_err(|err| GitError::InvalidFormat(err.to_string()))
1261}
1262
1263fn validate_remote_host(value: &str) -> Result<()> {
1264 if value.is_empty() {
1265 return Err(GitError::InvalidFormat("remote URL host is empty".into()));
1266 }
1267 if value
1268 .bytes()
1269 .any(|byte| matches!(byte, b'/' | b'?' | b'#' | b' ' | b'\t' | b'\n' | b'\r' | 0))
1270 {
1271 return Err(GitError::InvalidFormat(
1272 "remote URL host contains a delimiter byte".into(),
1273 ));
1274 }
1275 Ok(())
1276}
1277
1278fn scp_like_separator(value: &str) -> Option<usize> {
1279 let colon = if let Some(rest) = value.strip_prefix('[') {
1280 let close = rest.find(']')?;
1281 let colon = close + 2;
1282 if value.as_bytes().get(colon) == Some(&b':') {
1283 colon
1284 } else {
1285 return None;
1286 }
1287 } else {
1288 value.find(':')?
1289 };
1290 if value[..colon].contains('/') {
1291 return None;
1292 }
1293 if colon == 1
1294 && value
1295 .as_bytes()
1296 .first()
1297 .is_some_and(|byte| byte.is_ascii_alphabetic())
1298 && (value.as_bytes().get(2) == Some(&b'/') || cfg!(windows))
1299 {
1300 return None;
1301 }
1302 Some(colon)
1303}
1304
1305fn validate_ssh_repository_path(value: &str) -> Result<()> {
1306 if value.is_empty() {
1307 return Err(GitError::InvalidFormat(
1308 "SSH repository path is empty".into(),
1309 ));
1310 }
1311 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
1312 return Err(GitError::InvalidFormat(
1313 "SSH repository path contains a delimiter byte".into(),
1314 ));
1315 }
1316 Ok(())
1317}
1318
1319fn validate_ssh_program(value: &str) -> Result<()> {
1320 if value.is_empty() {
1321 return Err(GitError::InvalidFormat("SSH program is empty".into()));
1322 }
1323 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
1324 return Err(GitError::InvalidFormat(
1325 "SSH program contains a delimiter byte".into(),
1326 ));
1327 }
1328 Ok(())
1329}
1330
1331fn validate_ssh_user(value: &str) -> Result<()> {
1332 if value.is_empty() {
1333 return Err(GitError::InvalidFormat("SSH user is empty".into()));
1334 }
1335 if value
1336 .bytes()
1337 .any(|byte| matches!(byte, b'/' | b'?' | b'#' | b' ' | b'\t' | b'\n' | b'\r' | 0))
1338 {
1339 return Err(GitError::InvalidFormat(
1340 "SSH user contains a delimiter byte".into(),
1341 ));
1342 }
1343 Ok(())
1344}
1345
1346fn quote_ssh_repository_path(value: &str) -> String {
1347 let mut quoted = String::from("'");
1348 for ch in value.chars() {
1349 if ch == '\'' {
1350 quoted.push_str("'\\''");
1351 } else {
1352 quoted.push(ch);
1353 }
1354 }
1355 quoted.push('\'');
1356 quoted
1357}
1358
1359fn ssh_service_repository_path(value: &str) -> &str {
1360 if value.starts_with("/~") {
1361 &value[1..]
1362 } else {
1363 value
1364 }
1365}
1366
1367fn validate_git_protocol_header_parameter(value: &str) -> Result<()> {
1368 if value.is_empty() {
1369 return Err(GitError::InvalidFormat(
1370 "Git-Protocol header parameter is empty".into(),
1371 ));
1372 }
1373 if value
1374 .bytes()
1375 .any(|byte| matches!(byte, b':' | b'\n' | b'\r' | 0))
1376 {
1377 return Err(GitError::InvalidFormat(
1378 "Git-Protocol header parameter contains a delimiter byte".into(),
1379 ));
1380 }
1381 Ok(())
1382}
1383
1384fn validate_service_payload(label: &str, value: &[u8]) -> Result<()> {
1385 if value.is_empty() {
1386 return Err(GitError::InvalidFormat(format!("{label} is empty")));
1387 }
1388 if value.iter().any(|byte| matches!(*byte, b'\n' | b'\r')) {
1389 return Err(GitError::InvalidFormat(format!(
1390 "{label} contains a delimiter byte"
1391 )));
1392 }
1393 Ok(())
1394}
1395
1396fn validate_service_field(label: &str, value: &str) -> Result<()> {
1397 if value.is_empty() {
1398 return Err(GitError::InvalidFormat(format!("{label} is empty")));
1399 }
1400 if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
1401 return Err(GitError::InvalidFormat(format!(
1402 "{label} contains a delimiter byte"
1403 )));
1404 }
1405 Ok(())
1406}
1407
1408fn parse_service_protocol_version(value: &str) -> Result<ProtocolVersion> {
1409 match value {
1410 "1" => Ok(ProtocolVersion::V1),
1411 "2" => Ok(ProtocolVersion::V2),
1412 other => Err(GitError::InvalidFormat(format!(
1413 "unsupported service request protocol version {other}"
1414 ))),
1415 }
1416}
1417
1418#[cfg(feature = "http-client")]
1420pub const HTTP_USER_AGENT: &str = "git/2.54.0 (sley)";
1421
1422#[cfg(feature = "http-client")]
1428pub struct HttpResponse {
1429 pub status: u16,
1430 pub content_type: Option<String>,
1431 pub body: Box<dyn std::io::Read + Send>,
1432}
1433
1434#[cfg(feature = "http-client")]
1448pub trait HttpClient {
1449 fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse>;
1451
1452 fn post(
1455 &self,
1456 url: &str,
1457 content_type: &str,
1458 headers: &[(&str, &str)],
1459 body: &[u8],
1460 ) -> Result<HttpResponse>;
1461
1462 fn post_reader(
1470 &self,
1471 url: &str,
1472 content_type: &str,
1473 headers: &[(&str, &str)],
1474 body: &mut dyn std::io::Read,
1475 ) -> Result<HttpResponse> {
1476 let mut buffered = Vec::new();
1477 body.read_to_end(&mut buffered)
1478 .map_err(|err| GitError::Io(err.to_string()))?;
1479 self.post(url, content_type, headers, &buffered)
1480 }
1481}
1482
1483#[cfg(feature = "http-client")]
1485pub struct UreqHttpClient {
1486 agent: ureq::Agent,
1487}
1488
1489#[cfg(feature = "http-client")]
1490impl UreqHttpClient {
1491 pub fn new() -> Self {
1492 let mut builder = ureq::Agent::config_builder()
1496 .http_status_as_error(false)
1497 .user_agent(HTTP_USER_AGENT);
1498 if let Some(tls_config) = ureq_tls_config() {
1499 builder = builder.tls_config(tls_config);
1500 }
1501 Self {
1502 agent: builder.build().into(),
1503 }
1504 }
1505}
1506
1507#[cfg(feature = "http-client")]
1509fn ureq_tls_config() -> Option<ureq::tls::TlsConfig> {
1510 #[cfg(feature = "tls-platform-verifier")]
1511 {
1512 use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
1513 Some(
1514 TlsConfig::builder()
1515 .provider(TlsProvider::Rustls)
1516 .root_certs(RootCerts::PlatformVerifier)
1517 .build(),
1518 )
1519 }
1520 #[cfg(all(feature = "tls-native-tls", not(feature = "tls-platform-verifier")))]
1521 {
1522 use ureq::tls::{TlsConfig, TlsProvider};
1523 Some(
1524 TlsConfig::builder()
1525 .provider(TlsProvider::NativeTls)
1526 .build(),
1527 )
1528 }
1529 #[cfg(not(any(
1530 feature = "tls-platform-verifier",
1531 all(feature = "tls-native-tls", not(feature = "tls-platform-verifier"))
1532 )))]
1533 {
1534 None
1536 }
1537}
1538
1539#[cfg(feature = "http-client")]
1540impl Default for UreqHttpClient {
1541 fn default() -> Self {
1542 Self::new()
1543 }
1544}
1545
1546#[cfg(feature = "http-client")]
1547impl HttpClient for UreqHttpClient {
1548 fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse> {
1549 let mut request = self.agent.get(url);
1550 for (name, value) in headers {
1551 request = request.header(*name, *value);
1552 }
1553 let response = request
1554 .call()
1555 .map_err(|err| http_transport_error(url, &err))?;
1556 Ok(http_response_from_ureq(response))
1557 }
1558
1559 fn post(
1560 &self,
1561 url: &str,
1562 content_type: &str,
1563 headers: &[(&str, &str)],
1564 body: &[u8],
1565 ) -> Result<HttpResponse> {
1566 let mut request = self.agent.post(url).header("Content-Type", content_type);
1567 for (name, value) in headers {
1568 request = request.header(*name, *value);
1569 }
1570 let response = request
1571 .send(body)
1572 .map_err(|err| http_transport_error(url, &err))?;
1573 Ok(http_response_from_ureq(response))
1574 }
1575
1576 fn post_reader(
1577 &self,
1578 url: &str,
1579 content_type: &str,
1580 headers: &[(&str, &str)],
1581 body: &mut dyn std::io::Read,
1582 ) -> Result<HttpResponse> {
1583 let mut request = self.agent.post(url).header("Content-Type", content_type);
1584 for (name, value) in headers {
1585 request = request.header(*name, *value);
1586 }
1587 let response = request
1590 .send(ureq::SendBody::from_reader(body))
1591 .map_err(|err| http_transport_error(url, &err))?;
1592 Ok(http_response_from_ureq(response))
1593 }
1594}
1595
1596#[cfg(feature = "http-client")]
1599fn http_response_from_ureq(response: ureq::http::Response<ureq::Body>) -> HttpResponse {
1600 let status = response.status().as_u16();
1601 let content_type = response
1602 .headers()
1603 .get(ureq::http::header::CONTENT_TYPE)
1604 .and_then(|value| value.to_str().ok())
1605 .map(|value| value.to_string());
1606 let body = response.into_body().into_reader();
1607 HttpResponse {
1608 status,
1609 content_type,
1610 body: Box::new(body),
1611 }
1612}
1613
1614#[cfg(feature = "http-client")]
1617fn http_transport_error(url: &str, err: &ureq::Error) -> GitError {
1618 GitError::Io(format!("HTTP request to {url} failed: {err}"))
1619}
1620
1621fn line(mut payload: Vec<u8>) -> Vec<u8> {
1625 payload.push(b'\n');
1626 payload
1627}
1628
1629fn line_from_str(payload: &str) -> Vec<u8> {
1630 line(payload.as_bytes().to_vec())
1631}
1632
1633fn trim_trailing_lf(input: &[u8]) -> &[u8] {
1634 input.strip_suffix(b"\n").unwrap_or(input)
1635}
1636
1637fn validate_protocol_v2_line(label: &str, value: &[u8]) -> Result<()> {
1638 if value.is_empty() {
1639 return Err(GitError::InvalidFormat(format!("{label} is empty")));
1640 }
1641 if value.iter().any(|byte| matches!(*byte, b'\r' | 0)) {
1642 return Err(GitError::InvalidFormat(format!(
1643 "{label} contains a delimiter byte"
1644 )));
1645 }
1646 Ok(())
1647}
1648
1649fn parse_protocol_v2_line_text<'a>(label: &str, value: &'a [u8]) -> Result<&'a str> {
1650 validate_protocol_v2_line(label, value)?;
1651 let value = trim_trailing_lf(value);
1652 if value.is_empty() {
1653 return Err(GitError::InvalidFormat(format!("{label} is empty")));
1654 }
1655 if value.iter().any(|byte| matches!(*byte, b'\n' | b'\r' | 0)) {
1656 return Err(GitError::InvalidFormat(format!(
1657 "{label} contains a delimiter byte"
1658 )));
1659 }
1660 std::str::from_utf8(value).map_err(|err| GitError::InvalidFormat(err.to_string()))
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665 use super::*;
1666 use sley_core::{Capability, ObjectId};
1667
1668 #[test]
1669 fn service_request_parses_and_encodes_host_and_protocol() {
1670 let payload = b"git-upload-pack /project.git\0host=example.com\0\0version=2\0agent=sley\0";
1671 let request = parse_service_request(payload).expect("test operation should succeed");
1672 assert_eq!(
1673 request,
1674 ServiceRequest {
1675 service: GitService::UploadPack,
1676 path: "/project.git".into(),
1677 host: Some("example.com".into()),
1678 parameters: Vec::new(),
1679 protocol: Some(ProtocolVersion::V2),
1680 extra_parameters: vec!["agent=sley".into()],
1681 }
1682 );
1683 assert_eq!(
1684 encode_service_request(&request).expect("test operation should succeed"),
1685 payload
1686 );
1687 }
1688
1689 #[test]
1690 fn service_request_preserves_regular_and_extra_parameters() {
1691 let payload =
1692 b"git-receive-pack repo with spaces\0host=example.com\0version-hint=1\0\0trace=1\0";
1693 let request = parse_service_request(payload).expect("test operation should succeed");
1694 assert_eq!(
1695 request,
1696 ServiceRequest {
1697 service: GitService::ReceivePack,
1698 path: "repo with spaces".into(),
1699 host: Some("example.com".into()),
1700 parameters: vec!["version-hint=1".into()],
1701 protocol: None,
1702 extra_parameters: vec!["trace=1".into()],
1703 }
1704 );
1705 assert_eq!(
1706 encode_service_request(&request).expect("test operation should succeed"),
1707 payload
1708 );
1709 }
1710
1711 #[test]
1712 fn service_request_streams_round_trip() {
1713 let request = ServiceRequest {
1714 service: GitService::UploadArchive,
1715 path: "/repo.git".into(),
1716 host: None,
1717 parameters: Vec::new(),
1718 protocol: Some(ProtocolVersion::V1),
1719 extra_parameters: Vec::new(),
1720 };
1721 let mut encoded = Vec::new();
1722 write_service_request(&mut encoded, &request).expect("test operation should succeed");
1723 encoded.extend_from_slice(b"tail");
1724
1725 let mut input = encoded.as_slice();
1726 assert_eq!(
1727 read_service_request(&mut input).expect("test operation should succeed"),
1728 request
1729 );
1730 assert_eq!(input, b"tail");
1731 }
1732
1733 #[test]
1734 fn service_request_rejects_malformed_payloads() {
1735 assert!(parse_service_request(b"git-upload-pack").is_err());
1736 assert!(parse_service_request(b"git-not-a-service /repo.git").is_err());
1737 assert!(parse_service_request(b"git-upload-pack /repo.git\n").is_err());
1738 assert!(parse_service_request(b"git-upload-pack /repo.git\0host=example.com").is_err());
1739 assert!(parse_service_request(b"git-upload-pack /repo.git\0host=one\0host=two\0").is_err());
1740 assert!(
1741 parse_service_request(b"git-upload-pack /repo.git\0\0version=2\0version=1\0").is_err()
1742 );
1743 assert!(parse_service_request(b"git-upload-pack /repo.git\0\0version=0\0").is_err());
1744 assert!(
1745 encode_service_request(&ServiceRequest {
1746 service: GitService::UploadPack,
1747 path: "/repo.git".into(),
1748 host: None,
1749 parameters: Vec::new(),
1750 protocol: Some(ProtocolVersion::V0),
1751 extra_parameters: Vec::new(),
1752 })
1753 .is_err()
1754 );
1755 assert!(read_service_request(&mut &b"0000"[..]).is_err());
1756 }
1757
1758 #[test]
1759 fn service_announcement_parses_and_encodes_stream() {
1760 let frames = vec![
1761 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1762 PktLineFrame::Flush,
1763 ];
1764 let announcement =
1765 parse_service_announcement_stream(&frames).expect("test operation should succeed");
1766 assert_eq!(
1767 announcement,
1768 ServiceAnnouncement {
1769 service: GitService::UploadPack,
1770 }
1771 );
1772 assert_eq!(
1773 encode_service_announcement(&announcement).expect("test operation should succeed"),
1774 b"# service=git-upload-pack\n"
1775 );
1776 assert_eq!(
1777 encode_service_announcement_stream(&announcement)
1778 .expect("test operation should succeed"),
1779 frames
1780 );
1781 }
1782
1783 #[test]
1784 fn service_announcement_streams_round_trip() {
1785 let announcement = ServiceAnnouncement {
1786 service: GitService::ReceivePack,
1787 };
1788 let mut encoded = Vec::new();
1789 write_service_announcement(&mut encoded, &announcement)
1790 .expect("test operation should succeed");
1791 encoded.extend_from_slice(b"tail");
1792
1793 let mut input = encoded.as_slice();
1794 assert_eq!(
1795 read_service_announcement(&mut input).expect("test operation should succeed"),
1796 announcement
1797 );
1798 assert_eq!(input, b"tail");
1799 }
1800
1801 #[test]
1802 fn service_announcement_rejects_malformed_streams() {
1803 assert!(parse_service_announcement(b"service=git-upload-pack\n").is_err());
1804 assert!(parse_service_announcement(b"# service=git-not-a-service\n").is_err());
1805 assert!(parse_service_announcement(b"# service=git-upload-pack\0\n").is_err());
1806 assert!(parse_service_announcement_stream(&[]).is_err());
1807 assert!(
1808 parse_service_announcement_stream(&[PktLineFrame::Data(
1809 b"# service=git-upload-pack\n".to_vec(),
1810 )])
1811 .is_err()
1812 );
1813 assert!(
1814 parse_service_announcement_stream(&[
1815 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1816 PktLineFrame::Data(b"extra\n".to_vec()),
1817 PktLineFrame::Flush,
1818 ])
1819 .is_err()
1820 );
1821 assert!(parse_service_announcement_stream(&[PktLineFrame::Flush]).is_err());
1822 assert!(read_service_announcement(&mut &b"0000"[..]).is_err());
1823 }
1824
1825 #[test]
1826 fn service_discovery_response_parses_v0_refs_after_announcement() {
1827 let oid = ObjectId::from_hex(
1828 ObjectFormat::Sha1,
1829 "1111111111111111111111111111111111111111",
1830 )
1831 .expect("test operation should succeed");
1832 let frames = vec![
1833 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1834 PktLineFrame::Flush,
1835 PktLineFrame::Data(
1836 b"1111111111111111111111111111111111111111 HEAD\0multi_ack\n".to_vec(),
1837 ),
1838 PktLineFrame::Flush,
1839 ];
1840 let response = parse_service_discovery_response(ObjectFormat::Sha1, &frames)
1841 .expect("test operation should succeed");
1842 assert_eq!(
1843 response,
1844 ServiceDiscoveryResponse {
1845 announcement: ServiceAnnouncement {
1846 service: GitService::UploadPack,
1847 },
1848 payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
1849 protocol: ProtocolVersion::V0,
1850 refs: vec![RefAdvertisement {
1851 oid,
1852 name: "HEAD".into(),
1853 capabilities: vec![Capability {
1854 name: "multi_ack".into(),
1855 value: None,
1856 }],
1857 }],
1858 shallow: Vec::new(),
1859 }),
1860 }
1861 );
1862 assert_eq!(
1863 encode_service_discovery_response(&response).expect("test operation should succeed"),
1864 frames
1865 );
1866 }
1867
1868 #[test]
1869 fn service_discovery_response_parses_protocol_v2_after_announcement() {
1870 let frames = vec![
1871 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1872 PktLineFrame::Flush,
1873 PktLineFrame::Data(b"version 2\n".to_vec()),
1874 PktLineFrame::Data(b"ls-refs=unborn\n".to_vec()),
1875 PktLineFrame::Data(b"fetch=shallow filter\n".to_vec()),
1876 PktLineFrame::Flush,
1877 ];
1878 let response = parse_service_discovery_response(ObjectFormat::Sha1, &frames)
1879 .expect("test operation should succeed");
1880 assert_eq!(
1881 response,
1882 ServiceDiscoveryResponse {
1883 announcement: ServiceAnnouncement {
1884 service: GitService::UploadPack,
1885 },
1886 payload: ServiceDiscoveryPayload::ProtocolV2(TransportHandshake {
1887 protocol: ProtocolVersion::V2,
1888 capabilities: vec![
1889 Capability {
1890 name: "ls-refs".into(),
1891 value: Some("unborn".into()),
1892 },
1893 Capability {
1894 name: "fetch".into(),
1895 value: Some("shallow filter".into()),
1896 },
1897 ],
1898 }),
1899 }
1900 );
1901 assert_eq!(
1902 encode_service_discovery_response(&response).expect("test operation should succeed"),
1903 frames
1904 );
1905 }
1906
1907 #[test]
1908 fn service_discovery_response_streams_round_trip() {
1909 let response = ServiceDiscoveryResponse {
1910 announcement: ServiceAnnouncement {
1911 service: GitService::ReceivePack,
1912 },
1913 payload: ServiceDiscoveryPayload::AdvertisedRefs(RefAdvertisementSet {
1914 protocol: ProtocolVersion::V1,
1915 refs: Vec::new(),
1916 shallow: Vec::new(),
1917 }),
1918 };
1919 let mut encoded = Vec::new();
1920 write_service_discovery_response(&mut encoded, &response)
1921 .expect("test operation should succeed");
1922 encoded.extend_from_slice(b"tail");
1923
1924 let mut input = encoded.as_slice();
1925 assert_eq!(
1926 read_service_discovery_response(ObjectFormat::Sha1, &mut input)
1927 .expect("test operation should succeed"),
1928 response
1929 );
1930 assert_eq!(input, b"tail");
1931 }
1932
1933 #[test]
1934 fn service_discovery_response_rejects_malformed_streams() {
1935 assert!(parse_service_discovery_response(ObjectFormat::Sha1, &[]).is_err());
1936 assert!(
1937 parse_service_discovery_response(
1938 ObjectFormat::Sha1,
1939 &[
1940 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1941 PktLineFrame::Flush,
1942 ],
1943 )
1944 .is_err()
1945 );
1946 assert!(
1947 parse_service_discovery_response(
1948 ObjectFormat::Sha1,
1949 &[
1950 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1951 PktLineFrame::Data(b"version 2\n".to_vec()),
1952 PktLineFrame::Flush,
1953 ],
1954 )
1955 .is_err()
1956 );
1957 assert!(
1958 parse_service_discovery_response(
1959 ObjectFormat::Sha1,
1960 &[
1961 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1962 PktLineFrame::Flush,
1963 PktLineFrame::Delimiter,
1964 PktLineFrame::Flush,
1965 ],
1966 )
1967 .is_err()
1968 );
1969 assert!(
1970 parse_service_discovery_response(
1971 ObjectFormat::Sha1,
1972 &[
1973 PktLineFrame::Data(b"# service=git-upload-pack\n".to_vec()),
1974 PktLineFrame::Flush,
1975 PktLineFrame::Data(b"version 2\n".to_vec()),
1976 ],
1977 )
1978 .is_err()
1979 );
1980 }
1981
1982 #[test]
1983 fn remote_url_parser_classifies_local_http_git_and_ssh_forms() {
1984 assert_eq!(
1985 parse_remote_url("../repo.git").expect("test operation should succeed"),
1986 RemoteUrl {
1987 transport: RemoteTransport::Local,
1988 user: None,
1989 password: None,
1990 host: None,
1991 port: None,
1992 path: "../repo.git".into(),
1993 }
1994 );
1995 assert_eq!(
1996 parse_remote_url("/srv/git/repo.git").expect("test operation should succeed"),
1997 RemoteUrl {
1998 transport: RemoteTransport::Local,
1999 user: None,
2000 password: None,
2001 host: None,
2002 port: None,
2003 path: "/srv/git/repo.git".into(),
2004 }
2005 );
2006 assert_eq!(
2007 parse_remote_url("C:/work/repo.git").expect("test operation should succeed"),
2008 RemoteUrl {
2009 transport: RemoteTransport::Local,
2010 user: None,
2011 password: None,
2012 host: None,
2013 port: None,
2014 path: "C:/work/repo.git".into(),
2015 }
2016 );
2017 assert_eq!(
2018 parse_remote_url("file:///tmp/repo.git").expect("test operation should succeed"),
2019 RemoteUrl {
2020 transport: RemoteTransport::File,
2021 user: None,
2022 password: None,
2023 host: None,
2024 port: None,
2025 path: "/tmp/repo.git".into(),
2026 }
2027 );
2028 assert_eq!(
2029 parse_remote_url("file:///Users/alice/repo.git")
2030 .expect("test operation should succeed"),
2031 RemoteUrl {
2032 transport: RemoteTransport::File,
2033 user: None,
2034 password: None,
2035 host: None,
2036 port: None,
2037 path: "/Users/alice/repo.git".into(),
2038 }
2039 );
2040 assert_eq!(
2041 parse_remote_url("https://example.com/org/repo.git")
2042 .expect("test operation should succeed"),
2043 RemoteUrl {
2044 transport: RemoteTransport::Https,
2045 user: None,
2046 password: None,
2047 host: Some("example.com".into()),
2048 port: None,
2049 path: "/org/repo.git".into(),
2050 }
2051 );
2052 assert_eq!(
2053 parse_remote_url("https://alice:s3cret@example.com/org/repo.git")
2054 .expect("test operation should succeed"),
2055 RemoteUrl {
2056 transport: RemoteTransport::Https,
2057 user: Some("alice".into()),
2058 password: Some("s3cret".into()),
2059 host: Some("example.com".into()),
2060 port: None,
2061 path: "/org/repo.git".into(),
2062 }
2063 );
2064 assert_eq!(
2065 parse_remote_url("https://token@example.com/org/repo.git")
2066 .expect("test operation should succeed"),
2067 RemoteUrl {
2068 transport: RemoteTransport::Https,
2069 user: Some("token".into()),
2070 password: None,
2071 host: Some("example.com".into()),
2072 port: None,
2073 path: "/org/repo.git".into(),
2074 }
2075 );
2076 assert_eq!(
2077 parse_remote_url("https://[2001:db8::2]:8443/repo.git")
2078 .expect("test operation should succeed"),
2079 RemoteUrl {
2080 transport: RemoteTransport::Https,
2081 user: None,
2082 password: None,
2083 host: Some("2001:db8::2".into()),
2084 port: Some(8443),
2085 path: "/repo.git".into(),
2086 }
2087 );
2088 assert_eq!(
2089 parse_remote_url("http://example.com:8080/repo.git")
2090 .expect("test operation should succeed"),
2091 RemoteUrl {
2092 transport: RemoteTransport::Http,
2093 user: None,
2094 password: None,
2095 host: Some("example.com".into()),
2096 port: Some(8080),
2097 path: "/repo.git".into(),
2098 }
2099 );
2100 assert_eq!(
2101 parse_remote_url("git://example.com/repo.git").expect("test operation should succeed"),
2102 RemoteUrl {
2103 transport: RemoteTransport::Git,
2104 user: None,
2105 password: None,
2106 host: Some("example.com".into()),
2107 port: None,
2108 path: "/repo.git".into(),
2109 }
2110 );
2111 assert_eq!(
2112 parse_remote_url("ssh://git@[2001:db8::1]:2222/org/repo.git")
2113 .expect("test operation should succeed"),
2114 RemoteUrl {
2115 transport: RemoteTransport::Ssh,
2116 user: Some("git".into()),
2117 password: None,
2118 host: Some("2001:db8::1".into()),
2119 port: Some(2222),
2120 path: "/org/repo.git".into(),
2121 }
2122 );
2123 assert_eq!(
2124 parse_remote_url("ssh://git@example.com/org/repo%20space/it%27s%2Fnested.git")
2125 .expect("test operation should succeed"),
2126 RemoteUrl {
2127 transport: RemoteTransport::Ssh,
2128 user: Some("git".into()),
2129 password: None,
2130 host: Some("example.com".into()),
2131 port: None,
2132 path: "/org/repo space/it's/nested.git".into(),
2133 }
2134 );
2135 assert_eq!(
2136 parse_remote_url("git@example.com:org/repo.git")
2137 .expect("test operation should succeed"),
2138 RemoteUrl {
2139 transport: RemoteTransport::Ssh,
2140 user: Some("git".into()),
2141 password: None,
2142 host: Some("example.com".into()),
2143 port: None,
2144 path: "org/repo.git".into(),
2145 }
2146 );
2147 assert_eq!(
2148 parse_remote_url("example.com:org/repo.git").expect("test operation should succeed"),
2149 RemoteUrl {
2150 transport: RemoteTransport::Ssh,
2151 user: None,
2152 password: None,
2153 host: Some("example.com".into()),
2154 port: None,
2155 path: "org/repo.git".into(),
2156 }
2157 );
2158 }
2159
2160 #[test]
2161 fn remote_url_parser_rejects_malformed_values() {
2162 assert!(parse_remote_url("").is_err());
2163 assert!(parse_remote_url("ftp://example.com/repo.git").is_err());
2164 assert!(parse_remote_url("https://example.com").is_err());
2165 assert!(parse_remote_url("https://exa mple/repo.git").is_err());
2166 assert!(parse_remote_url("ssh://host:abc/repo.git").is_err());
2167 assert!(parse_remote_url("ssh://[2001:db8::1/repo.git").is_err());
2168 assert!(parse_remote_url("ssh://host/repo%2").is_err());
2169 assert!(parse_remote_url("ssh://host/repo%0a.git").is_err());
2170 assert!(parse_remote_url("git@example.com:").is_err());
2171 assert!(parse_remote_url("repo.git\n").is_err());
2172 }
2173
2174 #[test]
2175 fn git_credentials_parse_encode_and_preserve_extension_fields() {
2176 let credential = parse_git_credential(
2177 b"protocol=https\nhost=example.com\npath=org/repo.git\nusername=alice\npassword=secret\npassword_expiry_utc=1700000000\noauth_refresh_token=refresh\nurl=https://example.com/org/repo.git\nwwwauth[]=Bearer realm=one\nwwwauth[]=Basic realm=two\nquit=true\nhelper-state=opaque\n\n",
2178 )
2179 .expect("test operation should succeed");
2180 assert_eq!(
2181 credential,
2182 GitCredential {
2183 protocol: Some("https".into()),
2184 host: Some("example.com".into()),
2185 path: Some("org/repo.git".into()),
2186 username: Some("alice".into()),
2187 password: Some("secret".into()),
2188 password_expiry_utc: 1_700_000_000,
2189 oauth_refresh_token: Some("refresh".into()),
2190 url: Some("https://example.com/org/repo.git".into()),
2191 wwwauth: vec!["Bearer realm=one".into(), "Basic realm=two".into()],
2192 quit: true,
2193 extra: vec![("helper-state".into(), "opaque".into())],
2194 ..GitCredential::default()
2195 }
2196 );
2197 assert_eq!(
2198 encode_git_credential(&credential).expect("test operation should succeed"),
2199 b"protocol=https\nhost=example.com\npath=org/repo.git\nusername=alice\npassword=secret\npassword_expiry_utc=1700000000\noauth_refresh_token=refresh\nurl=https://example.com/org/repo.git\nwwwauth[]=Bearer realm=one\nwwwauth[]=Basic realm=two\nquit=true\nhelper-state=opaque\n\n"
2200 );
2201 }
2202
2203 #[test]
2204 fn git_credentials_stream_round_trip() {
2205 let credential = GitCredential {
2206 protocol: Some("https".into()),
2207 host: Some("example.com".into()),
2208 username: Some("alice".into()),
2209 password: Some("secret".into()),
2210 ..GitCredential::default()
2211 };
2212 let mut encoded = Vec::new();
2213 write_git_credential(&mut encoded, &credential).expect("test operation should succeed");
2214
2215 let mut input = encoded.as_slice();
2216 assert_eq!(
2217 read_git_credential(&mut input).expect("test operation should succeed"),
2218 credential
2219 );
2220 }
2221
2222 #[test]
2223 fn git_credentials_reject_oversized_helper_responses() {
2224 let oversized = vec![b'x'; MAX_GIT_CREDENTIAL_RESPONSE_BYTES + 1];
2225 let mut input = oversized.as_slice();
2226 let err = read_git_credential(&mut input).expect_err("oversized response should fail");
2227 assert!(
2228 matches!(
2229 err,
2230 GitError::InvalidFormat(ref message)
2231 if message.contains("credential helper response exceeds maximum size")
2232 && message.contains("65536")
2233 && message.contains("64 KiB")
2234 ),
2235 "got {err:?}"
2236 );
2237 }
2238
2239 #[test]
2240 fn git_credentials_accept_responses_at_size_limit() {
2241 let prefix = b"protocol=https\npadding=";
2242 let suffix = b"\n\n";
2243 let padding_len =
2244 MAX_GIT_CREDENTIAL_RESPONSE_BYTES - prefix.len() - suffix.len();
2245 let mut input = Vec::with_capacity(MAX_GIT_CREDENTIAL_RESPONSE_BYTES);
2246 input.extend_from_slice(prefix);
2247 input.extend(std::iter::repeat_n(b'x', padding_len));
2248 input.extend_from_slice(suffix);
2249 assert_eq!(input.len(), MAX_GIT_CREDENTIAL_RESPONSE_BYTES);
2250
2251 let mut reader = input.as_slice();
2252 let credential = read_git_credential(&mut reader).expect("at-limit response should succeed");
2253 assert_eq!(credential.protocol.as_deref(), Some("https"));
2254 assert_eq!(
2255 credential.extra,
2256 vec![("padding".into(), "x".repeat(padding_len))]
2257 );
2258 }
2259
2260 #[test]
2261 fn git_credentials_build_http_authorization_values() {
2262 let credential = GitCredential {
2263 username: Some("alice".into()),
2264 password: Some("secret".into()),
2265 ..GitCredential::default()
2266 };
2267 assert_eq!(
2268 git_credential_basic_authorization(&credential).expect("test operation should succeed"),
2269 Some("Basic YWxpY2U6c2VjcmV0".into())
2270 );
2271 assert_eq!(
2272 git_credential_basic_authorization(&GitCredential {
2273 username: Some("alice".into()),
2274 ..GitCredential::default()
2275 })
2276 .expect("test operation should succeed"),
2277 None
2278 );
2279 assert_eq!(
2280 git_credential_bearer_authorization("token-123")
2281 .expect("test operation should succeed"),
2282 "Bearer token-123"
2283 );
2284 }
2285
2286 #[test]
2287 fn git_credentials_reject_malformed_records() {
2288 assert!(parse_git_credential(b"protocol\n\n").is_err());
2289 assert!(parse_git_credential(b"=https\n\n").is_err());
2290 assert!(parse_git_credential(b"pro\0tocol=https\n\n").is_err());
2291 assert!(parse_git_credential(b"protocol=ht\0tps\n\n").is_err());
2292 assert!(parse_git_credential(b"protocol=http\rlive\n\n").is_err());
2293 assert!(parse_git_credential(b"protocol=http\nhost=example.com\n\ntrailing").is_err());
2294 assert!(parse_git_credential(b"protocol=http\nprotocol=https\n\n").is_err());
2295 assert!(
2296 encode_git_credential(&GitCredential {
2297 protocol: Some("http\nhttps".into()),
2298 ..GitCredential::default()
2299 })
2300 .is_err()
2301 );
2302 assert!(
2303 encode_git_credential(&GitCredential {
2304 extra: vec![("protocol".into(), "https".into())],
2305 ..GitCredential::default()
2306 })
2307 .is_err()
2308 );
2309 assert!(
2310 git_credential_basic_authorization(&GitCredential {
2311 username: Some("ali:ce".into()),
2312 password: Some("secret".into()),
2313 ..GitCredential::default()
2314 })
2315 .is_err()
2316 );
2317 assert!(
2318 git_credential_basic_authorization(&GitCredential {
2319 username: Some("alice".into()),
2320 password: Some("sec\nret".into()),
2321 ..GitCredential::default()
2322 })
2323 .is_err()
2324 );
2325 assert!(git_credential_bearer_authorization("").is_err());
2326 assert!(git_credential_bearer_authorization("tok\nen").is_err());
2327 }
2328
2329 #[test]
2330 fn http_smart_urls_build_absolute_urls_from_remote() {
2331 let remote = parse_remote_url("https://example.com/org/repo.git")
2333 .expect("test operation should succeed");
2334 assert_eq!(
2335 http_smart_info_refs_url(&remote, GitService::UploadPack)
2336 .expect("test operation should succeed"),
2337 "https://example.com/org/repo.git/info/refs?service=git-upload-pack"
2338 );
2339 assert_eq!(
2340 http_smart_rpc_url(&remote, GitService::UploadPack)
2341 .expect("test operation should succeed"),
2342 "https://example.com/org/repo.git/git-upload-pack"
2343 );
2344
2345 let remote = parse_remote_url("https://example.com:8443/org/repo.git")
2347 .expect("test operation should succeed");
2348 assert_eq!(
2349 http_smart_info_refs_url(&remote, GitService::ReceivePack)
2350 .expect("test operation should succeed"),
2351 "https://example.com:8443/org/repo.git/info/refs?service=git-receive-pack"
2352 );
2353 assert_eq!(
2354 http_smart_rpc_url(&remote, GitService::ReceivePack)
2355 .expect("test operation should succeed"),
2356 "https://example.com:8443/org/repo.git/git-receive-pack"
2357 );
2358
2359 let remote =
2361 parse_remote_url("http://example.com/repo").expect("test operation should succeed");
2362 assert_eq!(
2363 http_smart_info_refs_url(&remote, GitService::UploadPack)
2364 .expect("test operation should succeed"),
2365 "http://example.com/repo/info/refs?service=git-upload-pack"
2366 );
2367 }
2368
2369 #[test]
2370 fn http_smart_urls_never_include_userinfo() {
2371 let remote = parse_remote_url("https://alice:s3cret@example.com/org/repo.git")
2372 .expect("test operation should succeed");
2373 assert_eq!(remote.user.as_deref(), Some("alice"));
2374 assert_eq!(remote.password.as_deref(), Some("s3cret"));
2375 let info = http_smart_info_refs_url(&remote, GitService::UploadPack)
2376 .expect("test operation should succeed");
2377 let rpc = http_smart_rpc_url(&remote, GitService::UploadPack)
2378 .expect("test operation should succeed");
2379 assert_eq!(
2380 info,
2381 "https://example.com/org/repo.git/info/refs?service=git-upload-pack"
2382 );
2383 assert_eq!(rpc, "https://example.com/org/repo.git/git-upload-pack");
2384 assert!(!info.contains("alice"));
2385 assert!(!info.contains("s3cret"));
2386 assert!(!rpc.contains('@'));
2387 }
2388
2389 #[test]
2390 fn http_smart_urls_bracket_ipv6_hosts() {
2391 let remote = parse_remote_url("https://[2001:db8::1]:8443/repo.git")
2392 .expect("test operation should succeed");
2393 assert_eq!(
2394 http_smart_rpc_url(&remote, GitService::UploadPack)
2395 .expect("test operation should succeed"),
2396 "https://[2001:db8::1]:8443/repo.git/git-upload-pack"
2397 );
2398 }
2399
2400 #[test]
2401 fn http_smart_urls_reject_non_http_transports() {
2402 let remote = parse_remote_url("ssh://git@example.com/repo.git")
2403 .expect("test operation should succeed");
2404 assert!(http_smart_info_refs_url(&remote, GitService::UploadPack).is_err());
2405 assert!(http_smart_rpc_url(&remote, GitService::UploadPack).is_err());
2406
2407 let remote =
2408 parse_remote_url("git://example.com/repo.git").expect("test operation should succeed");
2409 assert!(http_smart_info_refs_url(&remote, GitService::UploadPack).is_err());
2410 }
2411
2412 #[test]
2413 fn remote_url_parser_extracts_http_password_but_not_ssh() {
2414 let remote = parse_remote_url("https://alice:s3cret@example.com/org/repo.git")
2416 .expect("test operation should succeed");
2417 assert_eq!(remote.user.as_deref(), Some("alice"));
2418 assert_eq!(remote.password.as_deref(), Some("s3cret"));
2419
2420 let remote = parse_remote_url("http://bob:pw@example.com:8080/repo.git")
2421 .expect("test operation should succeed");
2422 assert_eq!(remote.user.as_deref(), Some("bob"));
2423 assert_eq!(remote.password.as_deref(), Some("pw"));
2424 assert_eq!(remote.port, Some(8080));
2425
2426 let remote = parse_remote_url("https://token@example.com/repo.git")
2428 .expect("test operation should succeed");
2429 assert_eq!(remote.user.as_deref(), Some("token"));
2430 assert_eq!(remote.password, None);
2431
2432 let remote = parse_remote_url("ssh://git@example.com/org/repo.git")
2435 .expect("test operation should succeed");
2436 assert_eq!(remote.user.as_deref(), Some("git"));
2437 assert_eq!(remote.password, None);
2438
2439 let remote = parse_remote_url("git@example.com:org/repo.git")
2440 .expect("test operation should succeed");
2441 assert_eq!(remote.user.as_deref(), Some("git"));
2442 assert_eq!(remote.password, None);
2443 }
2444
2445 #[test]
2446 fn ssh_service_command_builds_shell_quoted_commands() {
2447 assert_eq!(
2448 ssh_service_command(GitService::UploadPack, "/srv/repo.git")
2449 .expect("test operation should succeed"),
2450 "git-upload-pack '/srv/repo.git'"
2451 );
2452 assert_eq!(
2453 ssh_service_command(GitService::ReceivePack, "team/project.git")
2454 .expect("test operation should succeed"),
2455 "git-receive-pack 'team/project.git'"
2456 );
2457 assert_eq!(
2458 ssh_service_command(GitService::UploadArchive, "/srv/it's.git")
2459 .expect("test operation should succeed"),
2460 "git-upload-archive '/srv/it'\\''s.git'"
2461 );
2462 }
2463
2464 #[test]
2465 fn ssh_process_command_builds_openssh_arguments() {
2466 let remote = parse_remote_url("ssh://git@example.com:2222/srv/repo.git")
2467 .expect("test operation should succeed");
2468 assert_eq!(
2469 ssh_process_command(
2470 &remote,
2471 GitService::UploadPack,
2472 "ssh",
2473 SshCommandVariant::OpenSsh,
2474 )
2475 .expect("test operation should succeed"),
2476 SshProcessCommand {
2477 program: "ssh".into(),
2478 args: vec![
2479 "-p".into(),
2480 "2222".into(),
2481 "git@example.com".into(),
2482 "git-upload-pack '/srv/repo.git'".into(),
2483 ],
2484 }
2485 );
2486
2487 let remote = parse_remote_url("ssh://git@[2001:db8::1]:2222/org/it%27s.git")
2488 .expect("test operation should succeed");
2489 assert_eq!(
2490 ssh_process_args(&remote, GitService::UploadPack, SshCommandVariant::OpenSsh)
2491 .expect("test operation should succeed"),
2492 vec![
2493 "-p".to_string(),
2494 "2222".to_string(),
2495 "git@2001:db8::1".to_string(),
2496 "git-upload-pack '/org/it'\\''s.git'".to_string(),
2497 ]
2498 );
2499
2500 let remote = parse_remote_url("ssh://user:passw@rd@example.com:2222/repo.git")
2501 .expect("test operation should succeed");
2502 assert_eq!(remote.user.as_deref(), Some("user:passw@rd"));
2503 assert_eq!(remote.host.as_deref(), Some("example.com"));
2504 assert_eq!(
2505 ssh_process_args(&remote, GitService::UploadPack, SshCommandVariant::OpenSsh)
2506 .expect("test operation should succeed"),
2507 vec![
2508 "-p".to_string(),
2509 "2222".to_string(),
2510 "user:passw@rd@example.com".to_string(),
2511 "git-upload-pack '/repo.git'".to_string(),
2512 ]
2513 );
2514 }
2515
2516 #[test]
2517 fn ssh_process_command_builds_scp_like_and_plink_arguments() {
2518 let remote = parse_remote_url("git@example.com:team/it isn't.git")
2519 .expect("test operation should succeed");
2520 assert_eq!(
2521 ssh_process_args(&remote, GitService::ReceivePack, SshCommandVariant::OpenSsh)
2522 .expect("test operation should succeed"),
2523 vec![
2524 "git@example.com".to_string(),
2525 "git-receive-pack 'team/it isn'\\''t.git'".to_string(),
2526 ]
2527 );
2528
2529 let remote = parse_remote_url("example.com:team/project.git")
2530 .expect("test operation should succeed");
2531 assert_eq!(
2532 ssh_process_args(&remote, GitService::UploadPack, SshCommandVariant::OpenSsh)
2533 .expect("test operation should succeed"),
2534 vec![
2535 "example.com".to_string(),
2536 "git-upload-pack 'team/project.git'".to_string(),
2537 ]
2538 );
2539
2540 let remote = parse_remote_url("[myhost:123]:src").expect("test operation should succeed");
2541 assert_eq!(
2542 remote,
2543 RemoteUrl {
2544 transport: RemoteTransport::Ssh,
2545 user: None,
2546 password: None,
2547 host: Some("myhost".into()),
2548 port: Some(123),
2549 path: "src".into(),
2550 }
2551 );
2552 assert_eq!(
2553 ssh_process_args(&remote, GitService::UploadPack, SshCommandVariant::OpenSsh)
2554 .expect("test operation should succeed"),
2555 vec![
2556 "-p".to_string(),
2557 "123".to_string(),
2558 "myhost".to_string(),
2559 "git-upload-pack 'src'".to_string(),
2560 ]
2561 );
2562
2563 let remote = parse_remote_url("[::1]:rep").expect("test operation should succeed");
2564 assert_eq!(remote.host.as_deref(), Some("::1"));
2565 assert_eq!(remote.port, None);
2566 assert_eq!(remote.path, "rep");
2567
2568 let remote = parse_remote_url("[user@::1]:repo").expect("test operation should succeed");
2569 assert_eq!(remote.user.as_deref(), Some("user"));
2570 assert_eq!(remote.host.as_deref(), Some("::1"));
2571
2572 let remote = parse_remote_url("c:temp").expect("test operation should succeed");
2573 if cfg!(windows) {
2574 assert_eq!(remote.transport, RemoteTransport::Local);
2575 } else {
2576 assert_eq!(remote.transport, RemoteTransport::Ssh);
2577 assert_eq!(remote.host.as_deref(), Some("c"));
2578 assert_eq!(remote.path, "temp");
2579 }
2580
2581 let remote = parse_remote_url("ssh://example.com:29418/team/project.git")
2582 .expect("test operation should succeed");
2583 assert_eq!(
2584 ssh_process_args(&remote, GitService::UploadArchive, SshCommandVariant::Plink,)
2585 .expect("test operation should succeed"),
2586 vec![
2587 "-P".to_string(),
2588 "29418".to_string(),
2589 "example.com".to_string(),
2590 "git-upload-archive '/team/project.git'".to_string(),
2591 ]
2592 );
2593 assert_eq!(
2594 ssh_process_args(
2595 &remote,
2596 GitService::UploadArchive,
2597 SshCommandVariant::TortoisePlink,
2598 )
2599 .expect("test operation should succeed"),
2600 vec![
2601 "-batch".to_string(),
2602 "-P".to_string(),
2603 "29418".to_string(),
2604 "example.com".to_string(),
2605 "git-upload-archive '/team/project.git'".to_string(),
2606 ]
2607 );
2608 }
2609
2610 #[test]
2611 fn ssh_process_args_include_ip_family_by_variant() {
2612 let remote = parse_remote_url("[myhost:123]:src").expect("test operation should succeed");
2613 assert_eq!(
2614 ssh_process_args_with_ip(
2615 &remote,
2616 GitService::UploadPack,
2617 SshCommandVariant::OpenSsh,
2618 Some(SshIpVersion::V4),
2619 )
2620 .expect("test operation should succeed"),
2621 vec![
2622 "-4".to_string(),
2623 "-p".to_string(),
2624 "123".to_string(),
2625 "myhost".to_string(),
2626 "git-upload-pack 'src'".to_string(),
2627 ]
2628 );
2629 assert_eq!(
2630 ssh_process_args_with_ip(
2631 &remote,
2632 GitService::UploadPack,
2633 SshCommandVariant::Plink,
2634 Some(SshIpVersion::V6),
2635 )
2636 .expect("test operation should succeed"),
2637 vec![
2638 "-6".to_string(),
2639 "-P".to_string(),
2640 "123".to_string(),
2641 "myhost".to_string(),
2642 "git-upload-pack 'src'".to_string(),
2643 ]
2644 );
2645 assert!(
2646 ssh_process_args_with_ip(
2647 &remote,
2648 GitService::UploadPack,
2649 SshCommandVariant::Simple,
2650 Some(SshIpVersion::V4),
2651 )
2652 .is_err()
2653 );
2654 }
2655
2656 #[test]
2657 fn ssh_scheme_accepts_git_ipv6_authority_forms() {
2658 for (url, user, host, port, path) in [
2659 (
2660 "ssh://::1/home/user/repo",
2661 None,
2662 "::1",
2663 None,
2664 "/home/user/repo",
2665 ),
2666 ("ssh://user@::1/~repo", Some("user"), "::1", None, "/~repo"),
2667 (
2668 "ssh://[user@::1]:22/home/user/repo",
2669 Some("user"),
2670 "::1",
2671 Some(22),
2672 "/home/user/repo",
2673 ),
2674 (
2675 "ssh://user@[::1]:/~repo",
2676 Some("user"),
2677 "::1",
2678 None,
2679 "/~repo",
2680 ),
2681 ] {
2682 let remote = parse_remote_url(url).expect("test operation should succeed");
2683 assert_eq!(remote.transport, RemoteTransport::Ssh);
2684 assert_eq!(remote.user.as_deref(), user);
2685 assert_eq!(remote.host.as_deref(), Some(host));
2686 assert_eq!(remote.port, port);
2687 assert_eq!(remote.path, path);
2688 }
2689 }
2690
2691 #[test]
2692 fn ssh_process_command_rejects_invalid_inputs() {
2693 let local = parse_remote_url("../repo.git").expect("test operation should succeed");
2694 assert!(
2695 ssh_process_args(&local, GitService::UploadPack, SshCommandVariant::OpenSsh).is_err()
2696 );
2697
2698 let remote = parse_remote_url("ssh://example.com:2222/repo.git")
2699 .expect("test operation should succeed");
2700 assert!(
2701 ssh_process_args(&remote, GitService::UploadPack, SshCommandVariant::Simple).is_err()
2702 );
2703 assert!(
2704 ssh_process_command(
2705 &remote,
2706 GitService::UploadPack,
2707 "ssh\n",
2708 SshCommandVariant::OpenSsh,
2709 )
2710 .is_err()
2711 );
2712 }
2713
2714 #[test]
2715 fn ssh_service_command_rejects_delimited_paths() {
2716 assert!(ssh_service_command(GitService::UploadPack, "").is_err());
2717 assert!(ssh_service_command(GitService::UploadPack, "/repo.git\n").is_err());
2718 assert!(ssh_service_command(GitService::UploadPack, "/repo.git\r").is_err());
2719 assert!(ssh_service_command(GitService::UploadPack, "/repo.git\0").is_err());
2720 }
2721
2722 #[test]
2723 fn git_protocol_header_parses_and_encodes_extra_parameters() {
2724 let header = GitProtocolHeader {
2725 protocol: Some(ProtocolVersion::V2),
2726 extra_parameters: vec!["agent=sley".into(), "trace".into()],
2727 };
2728 assert_eq!(
2729 encode_git_protocol_header(&header)
2730 .expect("test operation should succeed")
2731 .as_deref(),
2732 Some("version=2:agent=sley:trace")
2733 );
2734 assert_eq!(
2735 parse_git_protocol_header("version=2:agent=sley:trace")
2736 .expect("test operation should succeed"),
2737 header
2738 );
2739 assert_eq!(
2740 encode_git_protocol_header(&GitProtocolHeader::default())
2741 .expect("test operation should succeed"),
2742 None
2743 );
2744 assert_eq!(
2745 parse_git_protocol_header("agent=sley").expect("test operation should succeed"),
2746 GitProtocolHeader {
2747 protocol: None,
2748 extra_parameters: vec!["agent=sley".into()],
2749 }
2750 );
2751 }
2752
2753 #[test]
2754 fn git_protocol_header_rejects_malformed_values() {
2755 assert!(parse_git_protocol_header("").is_err());
2756 assert!(parse_git_protocol_header("version=2:version=1").is_err());
2757 assert!(parse_git_protocol_header("version=0").is_err());
2758 assert!(parse_git_protocol_header("version=2:").is_err());
2759 assert!(parse_git_protocol_header("bad\nparameter").is_err());
2760 assert!(
2761 encode_git_protocol_header(&GitProtocolHeader {
2762 protocol: Some(ProtocolVersion::V0),
2763 extra_parameters: Vec::new(),
2764 })
2765 .is_err()
2766 );
2767 assert!(
2768 encode_git_protocol_header(&GitProtocolHeader {
2769 protocol: None,
2770 extra_parameters: vec!["bad:parameter".into()],
2771 })
2772 .is_err()
2773 );
2774 }
2775}