Skip to main content

sley_transport/
lib.rs

1// sley#7: untrusted-input parsing crate — fallible ops propagate errors;
2// the only retained `expect`s would be documented compile-time invariants.
3#![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    // Smart HTTP with `Git-Protocol: version=2` omits the `# service=` preamble and
340    // flush that precede the v2 capability advertisement (upstream http-backend.c).
341    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
575/// Build the absolute smart-HTTP service-discovery URL for `remote`, e.g.
576/// `https://host:443/org/repo.git/info/refs?service=git-upload-pack`.
577///
578/// The scheme is derived from `remote.transport` (only `Http`/`Https` are
579/// accepted). Any userinfo/password embedded in `remote` is intentionally
580/// excluded; credentials are supplied separately as request headers.
581pub 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
587/// Build the absolute smart-HTTP RPC URL for `remote`, e.g.
588/// `https://host:443/org/repo.git/git-upload-pack`.
589///
590/// The scheme is derived from `remote.transport` (only `Http`/`Https` are
591/// accepted). Any userinfo/password embedded in `remote` is intentionally
592/// excluded; credentials are supplied separately as request headers.
593pub 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
599/// Render the `scheme://host[:port]` origin for an http(s) remote, never
600/// including userinfo. IPv6 hosts are bracketed.
601fn 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            // Only http(s) userinfo may carry an embedded password; SSH/git keep
924            // their authority verbatim so existing behavior does not regress.
925            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
1010/// Parsed remote authority: `(user, password, host, port)`. Password is only
1011/// ever populated for http(s) authorities (see `parse_remote_authority`).
1012type 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                // For http(s) URLs the userinfo may embed a password as
1032                // "user:pass"; split on the first ':' so the password can be
1033                // surfaced separately and kept out of derived URLs.
1034                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                // SSH/scp-like authorities keep the userinfo verbatim, matching
1044                // existing behavior (no embedded-password concept).
1045                (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/// User-Agent advertised by the built-in HTTP transport client.
1419#[cfg(feature = "http-client")]
1420pub const HTTP_USER_AGENT: &str = "git/2.54.0 (sley)";
1421
1422/// A buffered HTTP response whose body streams directly from the network.
1423///
1424/// Note that *any* HTTP status (including 4xx/5xx) is reported here with a
1425/// populated [`status`](HttpResponse::status); transport-level failures are
1426/// reported as [`Err`] from the [`HttpClient`] methods instead.
1427#[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/// Minimal byte-transport over HTTP(S) used to drive smart-HTTP git transport.
1435///
1436/// This is the injectable seam through which a host enforces network policy: an
1437/// implementation owns the entire dial (DNS resolution, connect, TLS) for each
1438/// `url`, so a host mirroring attacker-controlled public URLs can supply a client
1439/// that validates the resolved IP and pins the connection to it, guarding against
1440/// SSRF. The default fetch/clone path uses [`UreqHttpClient`]; see
1441/// `sley_remote::fetch_with_http_client` / `clone_with_http_client` to inject one.
1442///
1443/// Implementations must surface HTTP error statuses (401/403/404/5xx) as
1444/// `Ok(HttpResponse { status, .. })` so callers can react to them (for example,
1445/// retrying a 401 with credentials). Only genuine transport failures
1446/// (DNS/connect/TLS/timeout/protocol) are reported as `Err`.
1447#[cfg(feature = "http-client")]
1448pub trait HttpClient {
1449    /// Issue a `GET` for `url`, sending the additional `headers`.
1450    fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<HttpResponse>;
1451
1452    /// Issue a `POST` for `url` with `body`, sending `content_type` and the
1453    /// additional `headers`.
1454    fn post(
1455        &self,
1456        url: &str,
1457        content_type: &str,
1458        headers: &[(&str, &str)],
1459        body: &[u8],
1460    ) -> Result<HttpResponse>;
1461
1462    /// Issue a `POST` whose body is streamed from `body` with chunked
1463    /// transfer-encoding (no `Content-Length`), so large request bodies never
1464    /// have to be held in memory. The default implementation buffers `body` and
1465    /// delegates to [`HttpClient::post`]; transports that can stream the request
1466    /// (e.g. [`UreqHttpClient`]) override this. Callers that need retry-on-auth
1467    /// must be able to regenerate `body` per attempt, since a reader is consumed
1468    /// once.
1469    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/// [`HttpClient`] backed by [`ureq`] with rustls + bundled Mozilla roots.
1484#[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        // `http_status_as_error(false)` makes ureq deliver 4xx/5xx as a normal
1493        // response (carrying status + body) rather than an error, which is what
1494        // smart-HTTP callers need (e.g. inspecting 401 to prompt for creds).
1495        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/// Build explicit TLS settings when a non-default backend is selected.
1508#[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        // `tls-rustls` (and the default ureq rustls stack) need no explicit config.
1535        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        // `SendBody::from_reader` carries no known length, so ureq sends the
1588        // request with `Transfer-Encoding: chunked` and pulls bytes on demand.
1589        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/// Convert a successful ureq response into an [`HttpResponse`] that streams its
1597/// body from the connection (the body is never buffered into memory here).
1598#[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/// Map a genuine ureq transport/protocol failure to a [`GitError`], always
1615/// including the offending `url` in the message.
1616#[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
1621// Small private framing helpers, duplicated verbatim from git-protocol so the
1622// staying service-negotiation/credential code keeps working without exposing
1623// git-protocol's internal helpers as public API.
1624fn 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        // https without an explicit port.
2332        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        // https with an explicit port and a .git suffix path.
2346        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        // http scheme is honored too.
2360        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        // http(s) userinfo is split into user + password.
2415        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        // http(s) user without a password leaves the password unset.
2427        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        // SSH userinfo is preserved verbatim (no embedded-password concept), so
2433        // behavior does not regress for scp-like or ssh:// forms.
2434        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}