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