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