Skip to main content

sley_protocol/
pktline.rs

1use sley_core::{GitError, ObjectFormat, ObjectId, Result};
2use std::io::{ErrorKind, Read, Write};
3use std::sync::RwLock;
4
5static PACKET_TRACE_IDENTITY: RwLock<Option<String>> = RwLock::new(None);
6
7/// Set the program identity used in subsequent packet traces (the CLI sets this
8/// from the running subcommand). Mirrors `packet_trace_identity(prog)`.
9pub fn set_packet_trace_identity(prog: &str) {
10    if let Ok(mut guard) = PACKET_TRACE_IDENTITY.write() {
11        *guard = Some(prog.to_string());
12    }
13}
14
15fn packet_trace_prefix() -> String {
16    PACKET_TRACE_IDENTITY
17        .read()
18        .ok()
19        .and_then(|guard| guard.clone())
20        .unwrap_or_else(|| "git".to_string())
21}
22
23/// The destination for `GIT_TRACE_PACKET`: `1`/`2`/`true` → stderr, an absolute
24/// path is opened append+create, `0`/`false`/empty/unset disables. Mirrors
25/// git's `get_trace_fd` for the values the tests use.
26fn packet_trace_sink() -> Option<Box<dyn Write>> {
27    let value = std::env::var("GIT_TRACE_PACKET").ok()?;
28    let lower = value.to_ascii_lowercase();
29    match lower.as_str() {
30        "" | "0" | "false" => None,
31        "1" | "2" | "true" => Some(Box::new(std::io::stderr())),
32        _ => {
33            if std::path::Path::new(&value).is_absolute() {
34                std::fs::OpenOptions::new()
35                    .create(true)
36                    .append(true)
37                    .open(&value)
38                    .ok()
39                    .map(|f| Box::new(f) as Box<dyn Write>)
40            } else {
41                None
42            }
43        }
44    }
45}
46
47/// Whether packet tracing is enabled (cheap env probe for the hot-path guard).
48fn packet_trace_enabled() -> bool {
49    match std::env::var("GIT_TRACE_PACKET") {
50        Ok(value) => {
51            let lower = value.to_ascii_lowercase();
52            !matches!(lower.as_str(), "" | "0" | "false")
53        }
54        Err(_) => false,
55    }
56}
57
58/// Emit one packet-trace line for `data` (one pkt-line's framing token or
59/// payload). `is_write` selects the `>`/`<` direction. Octal-escapes
60/// non-printable bytes and suppresses newlines, exactly like git's
61/// `packet_trace`. The pack-data stream is collapsed to `PACK ...` on its first
62/// chunk (git does the same so the trace stays human-readable).
63fn packet_trace(data: &[u8], is_write: bool) {
64    if !packet_trace_enabled() {
65        return;
66    }
67    let Some(mut sink) = packet_trace_sink() else {
68        return;
69    };
70    // Collapse pack data: once a payload starts with "PACK" (raw) or "\1PACK"
71    // (sideband channel 1), git emits a single `PACK ...` marker for the human
72    // trace rather than the binary stream. We approximate per-call (stateless):
73    // any payload beginning with those magic bytes is rendered as `PACK ...`.
74    let rendered: Vec<u8> = if data.starts_with(b"PACK") || data.starts_with(b"\x01PACK") {
75        b"PACK ...".to_vec()
76    } else {
77        data.to_vec()
78    };
79
80    let mut out = format!(
81        "packet: {:>12}{} ",
82        packet_trace_prefix(),
83        if is_write { '>' } else { '<' }
84    );
85    for &byte in &rendered {
86        if byte == b'\n' {
87            continue;
88        }
89        if (0x20..=0x7e).contains(&byte) {
90            out.push(byte as char);
91        } else {
92            out.push_str(&format!("\\{byte:o}"));
93        }
94    }
95    out.push('\n');
96    let _ = sink.write_all(out.as_bytes());
97    let _ = sink.flush();
98}
99
100pub fn trace_packet_read_payload(payload: &[u8]) {
101    packet_trace(payload, false);
102}
103
104pub fn trace_packet_write_payload(payload: &[u8]) {
105    packet_trace(payload, true);
106}
107
108/// Trace a frame on the wire. Flush/delim/response-end map to their 4-byte
109/// tokens (`0000`/`0001`/`0002`) like git, data frames to their payload.
110fn packet_trace_frame(frame: &PktLineFrame, is_write: bool) {
111    if !packet_trace_enabled() {
112        return;
113    }
114    match frame {
115        PktLineFrame::Data(payload) => packet_trace(payload, is_write),
116        PktLineFrame::Flush => packet_trace(b"0000", is_write),
117        PktLineFrame::Delimiter => packet_trace(b"0001", is_write),
118        PktLineFrame::ResponseEnd => packet_trace(b"0002", is_write),
119    }
120}
121
122pub const PKT_LINE_MAX_LEN: usize = 65_520;
123
124pub const PKT_LINE_MAX_PAYLOAD_LEN: usize = PKT_LINE_MAX_LEN - 4;
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ProtocolVersion {
128    V0,
129    V1,
130    V2,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct PktLine(pub Vec<u8>);
135
136impl PktLine {
137    pub fn encode(&self) -> Vec<u8> {
138        encode_pkt_line_payload(&self.0)
139    }
140
141    pub fn try_encode(&self) -> Result<Vec<u8>> {
142        validate_pkt_line_payload(&self.0)?;
143        Ok(self.encode())
144    }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum PktLineFrame {
149    Data(Vec<u8>),
150    Flush,
151    Delimiter,
152    ResponseEnd,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ProtocolErrorLine {
157    pub message: String,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum GitService {
162    UploadPack,
163    ReceivePack,
164    UploadArchive,
165}
166
167impl GitService {
168    pub fn as_str(self) -> &'static str {
169        match self {
170            Self::UploadPack => "git-upload-pack",
171            Self::ReceivePack => "git-receive-pack",
172            Self::UploadArchive => "git-upload-archive",
173        }
174    }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct RefSpec {
179    pub force: bool,
180    pub negative: bool,
181    pub src: Option<String>,
182    pub dst: Option<String>,
183    pub pattern: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct FetchHeadRecord {
188    pub oid: ObjectId,
189    pub not_for_merge: bool,
190    pub description: String,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct FetchRefUpdate {
195    pub src: String,
196    pub dst: Option<String>,
197    pub oid: ObjectId,
198    pub not_for_merge: bool,
199    pub force: bool,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct PushSourceRef {
204    pub name: String,
205    pub oid: ObjectId,
206}
207
208impl PktLineFrame {
209    pub fn data(payload: impl Into<Vec<u8>>) -> Result<Self> {
210        let payload = payload.into();
211        validate_pkt_line_payload(&payload)?;
212        Ok(Self::Data(payload))
213    }
214
215    pub fn encode(&self) -> Vec<u8> {
216        match self {
217            Self::Data(payload) => encode_pkt_line_payload(payload),
218            Self::Flush => b"0000".to_vec(),
219            Self::Delimiter => b"0001".to_vec(),
220            Self::ResponseEnd => b"0002".to_vec(),
221        }
222    }
223
224    pub fn try_encode(&self) -> Result<Vec<u8>> {
225        match self {
226            Self::Data(payload) => try_encode_pkt_line_payload(payload),
227            Self::Flush | Self::Delimiter | Self::ResponseEnd => Ok(self.encode()),
228        }
229    }
230
231    pub fn parse(input: &[u8]) -> Result<(Self, usize)> {
232        if input.len() < 4 {
233            return Err(GitError::InvalidFormat("truncated pkt-line length".into()));
234        }
235        let len = parse_pkt_len(&input[..4])?;
236        match len {
237            0 => Ok((Self::Flush, 4)),
238            1 => Ok((Self::Delimiter, 4)),
239            2 => Ok((Self::ResponseEnd, 4)),
240            3 => Err(GitError::InvalidFormat(
241                "reserved pkt-line length 0003".into(),
242            )),
243            4..=PKT_LINE_MAX_LEN => {
244                if input.len() < len {
245                    return Err(GitError::InvalidFormat(format!(
246                        "truncated pkt-line payload: expected {} bytes, got {}",
247                        len - 4,
248                        input.len().saturating_sub(4)
249                    )));
250                }
251                Ok((Self::Data(input[4..len].to_vec()), len))
252            }
253            _ => Err(GitError::InvalidFormat(format!(
254                "pkt-line length exceeds {PKT_LINE_MAX_LEN}: {len}"
255            ))),
256        }
257    }
258}
259
260fn validate_pkt_line_payload(payload: &[u8]) -> Result<()> {
261    if payload.len() > PKT_LINE_MAX_PAYLOAD_LEN {
262        return Err(GitError::InvalidFormat(format!(
263            "pkt-line payload exceeds {PKT_LINE_MAX_PAYLOAD_LEN} bytes"
264        )));
265    }
266    Ok(())
267}
268
269pub(crate) fn pkt_line_header(len: usize) -> [u8; 4] {
270    const HEX: &[u8; 16] = b"0123456789abcdef";
271    [
272        HEX[(len >> 12) & 0xf],
273        HEX[(len >> 8) & 0xf],
274        HEX[(len >> 4) & 0xf],
275        HEX[len & 0xf],
276    ]
277}
278
279fn encode_pkt_line_payload(payload: &[u8]) -> Vec<u8> {
280    let len = payload.len() + 4;
281    let mut out = Vec::with_capacity(len);
282    out.extend_from_slice(&pkt_line_header(len));
283    out.extend_from_slice(payload);
284    out
285}
286
287fn try_encode_pkt_line_payload(payload: &[u8]) -> Result<Vec<u8>> {
288    validate_pkt_line_payload(payload)?;
289    Ok(encode_pkt_line_payload(payload))
290}
291
292pub fn parse_pkt_line_stream(mut input: &[u8]) -> Result<Vec<PktLineFrame>> {
293    let mut frames = Vec::new();
294    while !input.is_empty() {
295        let (frame, consumed) = PktLineFrame::parse(input)?;
296        frames.push(frame);
297        input = &input[consumed..];
298    }
299    Ok(frames)
300}
301
302pub(crate) fn parse_pkt_line_frames_until_flush_from(mut input: &[u8]) -> Result<(Vec<PktLineFrame>, usize)> {
303    let mut frames = Vec::new();
304    let mut total = 0usize;
305    loop {
306        if input.is_empty() {
307            return Err(GitError::InvalidFormat(
308                "pkt-line stream ended before flush".into(),
309            ));
310        }
311        let (frame, consumed) = PktLineFrame::parse(input)?;
312        total += consumed;
313        let done = matches!(frame, PktLineFrame::Flush);
314        frames.push(frame);
315        input = &input[consumed..];
316        if done {
317            return Ok((frames, total));
318        }
319    }
320}
321
322pub fn read_pkt_line_frame(reader: &mut impl Read) -> Result<Option<PktLineFrame>> {
323    let mut header = [0u8; 4];
324    let mut read = 0usize;
325    while read < header.len() {
326        match reader.read(&mut header[read..]) {
327            Ok(0) if read == 0 => return Ok(None),
328            Ok(0) => {
329                return Err(GitError::InvalidFormat("truncated pkt-line length".into()));
330            }
331            Ok(n) => read += n,
332            Err(err) if err.kind() == ErrorKind::Interrupted => {}
333            Err(err) => return Err(err.into()),
334        }
335    }
336
337    let len = parse_pkt_len(&header)?;
338    let frame = match len {
339        0 => PktLineFrame::Flush,
340        1 => PktLineFrame::Delimiter,
341        2 => PktLineFrame::ResponseEnd,
342        3 => {
343            return Err(GitError::InvalidFormat(
344                "reserved pkt-line length 0003".into(),
345            ));
346        }
347        4..=PKT_LINE_MAX_LEN => {
348            let mut payload = vec![0; len - 4];
349            reader.read_exact(&mut payload)?;
350            PktLineFrame::Data(payload)
351        }
352        _ => {
353            return Err(GitError::InvalidFormat(format!(
354                "pkt-line length exceeds {PKT_LINE_MAX_LEN}: {len}"
355            )));
356        }
357    };
358    packet_trace_frame(&frame, false);
359    Ok(Some(frame))
360}
361
362pub fn read_pkt_line_frames(reader: &mut impl Read) -> Result<Vec<PktLineFrame>> {
363    let mut frames = Vec::new();
364    while let Some(frame) = read_pkt_line_frame(reader)? {
365        frames.push(frame);
366    }
367    Ok(frames)
368}
369
370pub fn read_pkt_line_frames_until_flush(reader: &mut impl Read) -> Result<Vec<PktLineFrame>> {
371    read_pkt_line_frames_until_control(reader, |frame| matches!(frame, PktLineFrame::Flush))
372}
373
374pub fn read_pkt_line_frames_until_response_end(
375    reader: &mut impl Read,
376) -> Result<Vec<PktLineFrame>> {
377    read_pkt_line_frames_until_control(reader, |frame| matches!(frame, PktLineFrame::ResponseEnd))
378}
379
380fn read_pkt_line_frames_until_control(
381    reader: &mut impl Read,
382    stop: impl Fn(&PktLineFrame) -> bool,
383) -> Result<Vec<PktLineFrame>> {
384    let mut frames = Vec::new();
385    loop {
386        let Some(frame) = read_pkt_line_frame(reader)? else {
387            return Err(GitError::InvalidFormat(
388                "pkt-line stream ended before control packet".into(),
389            ));
390        };
391        let done = stop(&frame);
392        frames.push(frame);
393        if done {
394            return Ok(frames);
395        }
396    }
397}
398
399pub fn write_pkt_line_frame(writer: &mut impl Write, frame: &PktLineFrame) -> Result<()> {
400    match frame {
401        // `write_pkt_line_payload` already traces the data line.
402        PktLineFrame::Data(payload) => write_pkt_line_payload(writer, payload)?,
403        PktLineFrame::Flush => {
404            packet_trace(b"0000", true);
405            writer.write_all(b"0000")?;
406        }
407        PktLineFrame::Delimiter => {
408            packet_trace(b"0001", true);
409            writer.write_all(b"0001")?;
410        }
411        PktLineFrame::ResponseEnd => {
412            packet_trace(b"0002", true);
413            writer.write_all(b"0002")?;
414        }
415    }
416    Ok(())
417}
418
419pub fn write_pkt_line_payload(writer: &mut impl Write, payload: &[u8]) -> Result<()> {
420    validate_pkt_line_payload(payload)?;
421    packet_trace(payload, true);
422    let len = payload.len() + 4;
423    writer.write_all(&pkt_line_header(len))?;
424    writer.write_all(payload)?;
425    Ok(())
426}
427
428pub fn write_pkt_line_frames(writer: &mut impl Write, frames: &[PktLineFrame]) -> Result<()> {
429    for frame in frames {
430        write_pkt_line_frame(writer, frame)?;
431    }
432    Ok(())
433}
434
435pub fn parse_error_line(payload: &[u8]) -> Result<ProtocolErrorLine> {
436    let text = parse_protocol_v2_line_text("protocol error line", payload)?;
437    let Some(message) = text.strip_prefix("ERR ") else {
438        return Err(GitError::InvalidFormat(
439            "protocol error line must start with ERR".into(),
440        ));
441    };
442    validate_protocol_error_message(message)?;
443    Ok(ProtocolErrorLine {
444        message: message.to_string(),
445    })
446}
447
448pub fn encode_error_line(error: &ProtocolErrorLine) -> Result<Vec<u8>> {
449    validate_protocol_error_message(&error.message)?;
450    Ok(line_from_str(&format!("ERR {}", error.message)))
451}
452
453pub fn parse_error_frame(frame: &PktLineFrame) -> Result<Option<ProtocolErrorLine>> {
454    match frame {
455        PktLineFrame::Data(payload) if trim_trailing_lf(payload).starts_with(b"ERR ") => {
456            parse_error_line(payload).map(Some)
457        }
458        PktLineFrame::Data(_)
459        | PktLineFrame::Flush
460        | PktLineFrame::Delimiter
461        | PktLineFrame::ResponseEnd => Ok(None),
462    }
463}
464
465pub fn read_error_line(reader: &mut impl Read) -> Result<ProtocolErrorLine> {
466    let Some(frame) = read_pkt_line_frame(reader)? else {
467        return Err(GitError::InvalidFormat(
468            "pkt-line stream ended before protocol error line".into(),
469        ));
470    };
471    match frame {
472        PktLineFrame::Data(payload) => parse_error_line(&payload),
473        _ => Err(GitError::InvalidFormat(
474            "protocol error line must be a data packet".into(),
475        )),
476    }
477}
478
479pub fn write_error_line(writer: &mut impl Write, error: &ProtocolErrorLine) -> Result<()> {
480    write_pkt_line_frame(writer, &PktLineFrame::data(encode_error_line(error)?)?)
481}
482
483pub fn parse_git_service(value: &str) -> Result<GitService> {
484    match value {
485        "git-upload-pack" => Ok(GitService::UploadPack),
486        "git-receive-pack" => Ok(GitService::ReceivePack),
487        "git-upload-archive" => Ok(GitService::UploadArchive),
488        other => Err(GitError::InvalidFormat(format!(
489            "unsupported git service {other}"
490        ))),
491    }
492}
493
494pub fn parse_refspec(value: &str) -> Result<RefSpec> {
495    validate_refspec_value(value)?;
496    let (force, value) = value
497        .strip_prefix('+')
498        .map_or((false, value), |value| (true, value));
499    let (negative, value) = value
500        .strip_prefix('^')
501        .map_or((false, value), |value| (true, value));
502    if force && negative {
503        return Err(GitError::InvalidFormat(
504            "negative refspec must not be forced".into(),
505        ));
506    }
507    let (src, dst) = if negative {
508        if value.contains(':') {
509            return Err(GitError::InvalidFormat(
510                "negative refspec must not have a destination".into(),
511            ));
512        }
513        (Some(value), None)
514    } else if let Some((src, dst)) = value.split_once(':') {
515        (non_empty(src), non_empty(dst))
516    } else {
517        (Some(value), None)
518    };
519    if src.is_none() && dst.is_none() && value != ":" {
520        return Err(GitError::InvalidFormat(
521            "refspec must include a source or destination".into(),
522        ));
523    }
524    if negative && src.is_none() {
525        return Err(GitError::InvalidFormat(
526            "negative refspec is missing a source".into(),
527        ));
528    }
529    if let Some(src) = src {
530        validate_refspec_endpoint("refspec source", src)?;
531    }
532    if let Some(dst) = dst {
533        validate_refspec_endpoint("refspec destination", dst)?;
534    }
535    let src_pattern_count = src.map(count_refspec_wildcards).unwrap_or(0);
536    let dst_pattern_count = dst.map(count_refspec_wildcards).unwrap_or(0);
537    if src_pattern_count > 1 || dst_pattern_count > 1 {
538        return Err(GitError::InvalidFormat(
539            "refspec endpoint has too many wildcards".into(),
540        ));
541    }
542    if dst.is_some() && (src_pattern_count == 1) != (dst_pattern_count == 1) {
543        return Err(GitError::InvalidFormat(
544            "refspec wildcard must appear in both source and destination".into(),
545        ));
546    }
547    Ok(RefSpec {
548        force,
549        negative,
550        src: src.map(str::to_string),
551        dst: dst.map(str::to_string),
552        pattern: src_pattern_count == 1 || dst_pattern_count == 1,
553    })
554}
555
556pub fn encode_refspec(refspec: &RefSpec) -> Result<String> {
557    validate_refspec_shape(refspec)?;
558    let mut out = String::new();
559    if refspec.force {
560        out.push('+');
561    }
562    if refspec.negative {
563        out.push('^');
564    }
565    if let Some(src) = &refspec.src {
566        out.push_str(src);
567    }
568    if !refspec.negative && refspec.src.is_none() && refspec.dst.is_none() {
569        out.push(':');
570    } else if !refspec.negative && refspec.dst.is_some() {
571        out.push(':');
572        if let Some(dst) = &refspec.dst {
573            out.push_str(dst);
574        }
575    }
576    Ok(out)
577}
578
579pub fn refspec_matches_source(refspec: &RefSpec, source: &str) -> Result<bool> {
580    Ok(refspec_map_source(refspec, source)?.is_some())
581}
582
583pub fn refspec_map_source(refspec: &RefSpec, source: &str) -> Result<Option<String>> {
584    validate_refspec_shape(refspec)?;
585    validate_refspec_endpoint("refspec match source", source)?;
586    let Some(src) = refspec.src.as_deref() else {
587        return Ok(None);
588    };
589    if refspec.pattern {
590        let Some((src_prefix, src_suffix)) = src.split_once('*') else {
591            return Ok(None);
592        };
593        let Some(middle) = source
594            .strip_prefix(src_prefix)
595            .and_then(|value| value.strip_suffix(src_suffix))
596        else {
597            return Ok(None);
598        };
599        if let Some(dst) = refspec.dst.as_deref() {
600            let (dst_prefix, dst_suffix) = dst.split_once('*').ok_or_else(|| {
601                GitError::InvalidFormat("pattern refspec destination is missing wildcard".into())
602            })?;
603            return Ok(Some(format!("{dst_prefix}{middle}{dst_suffix}")));
604        }
605        return Ok(Some(source.to_string()));
606    }
607    if src == source {
608        return Ok(Some(
609            refspec.dst.clone().unwrap_or_else(|| source.to_string()),
610        ));
611    }
612    Ok(None)
613}
614
615pub fn smart_http_info_refs_path(repository_path: &str, service: GitService) -> Result<String> {
616    validate_smart_http_service(service)?;
617    let repository_path = normalize_http_repository_path(repository_path)?;
618    Ok(format!(
619        "{repository_path}/info/refs?service={}",
620        service.as_str()
621    ))
622}
623
624pub fn smart_http_rpc_path(repository_path: &str, service: GitService) -> Result<String> {
625    validate_smart_http_service(service)?;
626    let repository_path = normalize_http_repository_path(repository_path)?;
627    Ok(format!("{repository_path}/{}", service.as_str()))
628}
629
630pub fn dumb_http_info_refs_path(repository_path: &str) -> Result<String> {
631    let repository_path = normalize_http_repository_path(repository_path)?;
632    Ok(format!("{repository_path}/info/refs"))
633}
634
635pub fn dumb_http_alternates_path(repository_path: &str) -> Result<String> {
636    let repository_path = normalize_http_repository_path(repository_path)?;
637    Ok(format!("{repository_path}/objects/info/http-alternates"))
638}
639
640pub fn dumb_http_packs_path(repository_path: &str) -> Result<String> {
641    let repository_path = normalize_http_repository_path(repository_path)?;
642    Ok(format!("{repository_path}/objects/info/packs"))
643}
644
645pub fn dumb_http_loose_object_path(repository_path: &str, oid: &ObjectId) -> Result<String> {
646    let repository_path = normalize_http_repository_path(repository_path)?;
647    let oid = oid.to_string();
648    let (directory, file) = oid.split_at(2);
649    Ok(format!("{repository_path}/objects/{directory}/{file}"))
650}
651
652pub fn dumb_http_pack_file_path(repository_path: &str, hash: &ObjectId) -> Result<String> {
653    dumb_http_pack_resource_path(repository_path, hash, "pack")
654}
655
656pub fn dumb_http_pack_index_path(repository_path: &str, hash: &ObjectId) -> Result<String> {
657    dumb_http_pack_resource_path(repository_path, hash, "idx")
658}
659
660pub fn smart_http_advertisement_content_type(service: GitService) -> Result<String> {
661    validate_smart_http_service(service)?;
662    Ok(format!("application/x-{}-advertisement", service.as_str()))
663}
664
665pub fn smart_http_rpc_request_content_type(service: GitService) -> Result<String> {
666    validate_smart_http_service(service)?;
667    Ok(format!("application/x-{}-request", service.as_str()))
668}
669
670pub fn smart_http_rpc_result_content_type(service: GitService) -> Result<String> {
671    validate_smart_http_service(service)?;
672    Ok(format!("application/x-{}-result", service.as_str()))
673}
674
675pub fn parse_smart_http_advertisement_content_type(value: &str) -> Result<GitService> {
676    parse_smart_http_content_type(value, "-advertisement")
677}
678
679pub fn parse_smart_http_rpc_request_content_type(value: &str) -> Result<GitService> {
680    parse_smart_http_content_type(value, "-request")
681}
682
683pub fn parse_smart_http_rpc_result_content_type(value: &str) -> Result<GitService> {
684    parse_smart_http_content_type(value, "-result")
685}
686pub(crate) fn parse_pkt_len(bytes: &[u8]) -> Result<usize> {
687    let mut len = 0usize;
688    for byte in bytes {
689        len = (len << 4) | hex_nibble(*byte)? as usize;
690    }
691    Ok(len)
692}
693
694fn hex_nibble(byte: u8) -> Result<u8> {
695    match byte {
696        b'0'..=b'9' => Ok(byte - b'0'),
697        b'a'..=b'f' => Ok(byte - b'a' + 10),
698        b'A'..=b'F' => Ok(byte - b'A' + 10),
699        _ => Err(GitError::InvalidFormat(format!(
700            "invalid pkt-line length byte {byte:#04x}"
701        ))),
702    }
703}
704fn validate_protocol_error_message(message: &str) -> Result<()> {
705    if message.is_empty() {
706        return Err(GitError::InvalidFormat(
707            "protocol error message is empty".into(),
708        ));
709    }
710    if message
711        .bytes()
712        .any(|byte| matches!(byte, b'\n' | b'\r' | 0))
713    {
714        return Err(GitError::InvalidFormat(
715            "protocol error message contains a delimiter byte".into(),
716        ));
717    }
718    Ok(())
719}
720
721pub(crate) fn validate_capability_field(label: &str, value: &str) -> Result<()> {
722    if value.is_empty() {
723        return Err(GitError::InvalidFormat(format!("{label} is empty")));
724    }
725    if value
726        .bytes()
727        .any(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0))
728    {
729        return Err(GitError::InvalidFormat(format!(
730            "{label} contains a delimiter byte"
731        )));
732    }
733    Ok(())
734}
735
736pub(crate) fn validate_capability_name(value: &str) -> Result<()> {
737    validate_capability_field("capability name", value)?;
738    if value.bytes().any(|byte| byte == b'=') {
739        return Err(GitError::InvalidFormat(
740            "capability name contains a delimiter byte".into(),
741        ));
742    }
743    Ok(())
744}
745
746pub(crate) fn non_empty(value: &str) -> Option<&str> {
747    (!value.is_empty()).then_some(value)
748}
749
750fn validate_refspec_value(value: &str) -> Result<()> {
751    if value.is_empty() {
752        return Err(GitError::InvalidFormat("refspec is empty".into()));
753    }
754    if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
755        return Err(GitError::InvalidFormat(
756            "refspec contains a delimiter byte".into(),
757        ));
758    }
759    Ok(())
760}
761
762pub(crate) fn validate_refspec_endpoint(label: &str, value: &str) -> Result<()> {
763    if value.is_empty() {
764        return Err(GitError::InvalidFormat(format!("{label} is empty")));
765    }
766    if value
767        .bytes()
768        .any(|byte| matches!(byte, b':' | b' ' | b'\t' | b'\n' | b'\r' | 0))
769    {
770        return Err(GitError::InvalidFormat(format!(
771            "{label} contains a delimiter byte"
772        )));
773    }
774    Ok(())
775}
776
777fn count_refspec_wildcards(value: &str) -> usize {
778    value.bytes().filter(|byte| *byte == b'*').count()
779}
780
781pub(crate) fn validate_refspec_shape(refspec: &RefSpec) -> Result<()> {
782    if refspec.force && refspec.negative {
783        return Err(GitError::InvalidFormat(
784            "negative refspec must not be forced".into(),
785        ));
786    }
787    if refspec.negative && refspec.dst.is_some() {
788        return Err(GitError::InvalidFormat(
789            "negative refspec must not have a destination".into(),
790        ));
791    }
792    if refspec.negative && refspec.src.is_none() {
793        return Err(GitError::InvalidFormat(
794            "negative refspec is missing a source".into(),
795        ));
796    }
797    if refspec.src.is_none() && refspec.dst.is_none() && refspec.negative {
798        return Err(GitError::InvalidFormat(
799            "refspec must include a source or destination".into(),
800        ));
801    }
802    if let Some(src) = &refspec.src {
803        validate_refspec_endpoint("refspec source", src)?;
804    }
805    if let Some(dst) = &refspec.dst {
806        validate_refspec_endpoint("refspec destination", dst)?;
807    }
808    let src_pattern_count = refspec
809        .src
810        .as_deref()
811        .map(count_refspec_wildcards)
812        .unwrap_or(0);
813    let dst_pattern_count = refspec
814        .dst
815        .as_deref()
816        .map(count_refspec_wildcards)
817        .unwrap_or(0);
818    if src_pattern_count > 1 || dst_pattern_count > 1 {
819        return Err(GitError::InvalidFormat(
820            "refspec endpoint has too many wildcards".into(),
821        ));
822    }
823    if refspec.dst.is_some() && (src_pattern_count == 1) != (dst_pattern_count == 1) {
824        return Err(GitError::InvalidFormat(
825            "refspec wildcard must appear in both source and destination".into(),
826        ));
827    }
828    if refspec.pattern != (src_pattern_count == 1 || dst_pattern_count == 1) {
829        return Err(GitError::InvalidFormat(
830            "refspec pattern flag does not match endpoints".into(),
831        ));
832    }
833    Ok(())
834}
835
836pub(crate) fn validate_fetch_head_line(value: &[u8]) -> Result<()> {
837    if value.is_empty() {
838        return Err(GitError::InvalidFormat("FETCH_HEAD record is empty".into()));
839    }
840    if !value.ends_with(b"\n") {
841        return Err(GitError::InvalidFormat(
842            "FETCH_HEAD record missing LF".into(),
843        ));
844    }
845    if value.iter().any(|byte| matches!(*byte, b'\r' | 0)) {
846        return Err(GitError::InvalidFormat(
847            "FETCH_HEAD record contains a delimiter byte".into(),
848        ));
849    }
850    Ok(())
851}
852
853pub(crate) fn validate_fetch_head_description_field(value: &str) -> Result<()> {
854    if value.is_empty() {
855        return Err(GitError::InvalidFormat(
856            "FETCH_HEAD description is empty".into(),
857        ));
858    }
859    if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
860        return Err(GitError::InvalidFormat(
861            "FETCH_HEAD description contains a delimiter byte".into(),
862        ));
863    }
864    Ok(())
865}
866fn validate_smart_http_service(service: GitService) -> Result<()> {
867    match service {
868        GitService::UploadPack | GitService::ReceivePack => Ok(()),
869        GitService::UploadArchive => Err(GitError::InvalidFormat(
870            "smart HTTP only supports upload-pack and receive-pack services".into(),
871        )),
872    }
873}
874
875fn normalize_http_repository_path(path: &str) -> Result<String> {
876    if path.is_empty() {
877        return Err(GitError::InvalidFormat(
878            "smart HTTP repository path is empty".into(),
879        ));
880    }
881    if !path.starts_with('/') {
882        return Err(GitError::InvalidFormat(
883            "smart HTTP repository path must start with /".into(),
884        ));
885    }
886    if path
887        .bytes()
888        .any(|byte| matches!(byte, b'\n' | b'\r' | 0 | b'?' | b'#'))
889    {
890        return Err(GitError::InvalidFormat(
891            "smart HTTP repository path contains a delimiter byte".into(),
892        ));
893    }
894    let normalized = path.trim_end_matches('/');
895    Ok(if normalized.is_empty() {
896        "/".into()
897    } else {
898        normalized.to_string()
899    })
900}
901
902fn dumb_http_pack_resource_path(
903    repository_path: &str,
904    hash: &ObjectId,
905    suffix: &str,
906) -> Result<String> {
907    let repository_path = normalize_http_repository_path(repository_path)?;
908    Ok(format!(
909        "{repository_path}/objects/pack/pack-{hash}.{suffix}"
910    ))
911}
912
913fn parse_smart_http_content_type(value: &str, suffix: &str) -> Result<GitService> {
914    let value = value.trim();
915    if value.is_empty() {
916        return Err(GitError::InvalidFormat(
917            "smart HTTP content type is empty".into(),
918        ));
919    }
920    if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
921        return Err(GitError::InvalidFormat(
922            "smart HTTP content type contains a delimiter byte".into(),
923        ));
924    }
925    let value = value.to_ascii_lowercase();
926    let service = value
927        .strip_prefix("application/x-")
928        .and_then(|value| value.strip_suffix(suffix))
929        .ok_or_else(|| GitError::InvalidFormat("invalid smart HTTP content type".into()))?;
930    let service = parse_git_service(service)?;
931    validate_smart_http_service(service)?;
932    Ok(service)
933}
934
935pub(crate) fn validate_protocol_v2_token(label: &str, value: &str) -> Result<()> {
936    if value.is_empty() {
937        return Err(GitError::InvalidFormat(format!("{label} is empty")));
938    }
939    if value
940        .bytes()
941        .any(|byte| matches!(byte, b' ' | b'\n' | b'\r' | 0))
942    {
943        return Err(GitError::InvalidFormat(format!(
944            "{label} contains a delimiter byte"
945        )));
946    }
947    Ok(())
948}
949
950pub(crate) fn validate_protocol_v2_line(label: &str, value: &[u8]) -> Result<()> {
951    if value.is_empty() {
952        return Err(GitError::InvalidFormat(format!("{label} is empty")));
953    }
954    if value.iter().any(|byte| matches!(*byte, b'\r' | 0)) {
955        return Err(GitError::InvalidFormat(format!(
956            "{label} contains a delimiter byte"
957        )));
958    }
959    Ok(())
960}
961
962pub(crate) fn parse_protocol_v2_line_text<'a>(label: &str, value: &'a [u8]) -> Result<&'a str> {
963    validate_protocol_v2_line(label, value)?;
964    let value = trim_trailing_lf(value);
965    if value.is_empty() {
966        return Err(GitError::InvalidFormat(format!("{label} is empty")));
967    }
968    if value.iter().any(|byte| matches!(*byte, b'\n' | b'\r' | 0)) {
969        return Err(GitError::InvalidFormat(format!(
970            "{label} contains a delimiter byte"
971        )));
972    }
973    std::str::from_utf8(value).map_err(|err| GitError::InvalidFormat(err.to_string()))
974}
975
976pub(crate) fn parse_oid_argument(
977    format: ObjectFormat,
978    label: &str,
979    value: &str,
980    prefix: &str,
981) -> Result<ObjectId> {
982    let oid = value
983        .strip_prefix(prefix)
984        .ok_or_else(|| GitError::InvalidFormat(format!("invalid {label}")))?;
985    validate_protocol_v2_token(label, oid)?;
986    ObjectId::from_hex(format, oid)
987}
988
989pub(crate) fn line(mut payload: Vec<u8>) -> Vec<u8> {
990    payload.push(b'\n');
991    payload
992}
993
994pub(crate) fn line_from_str(payload: &str) -> Vec<u8> {
995    line(payload.as_bytes().to_vec())
996}
997
998pub(crate) fn trim_trailing_lf(input: &[u8]) -> &[u8] {
999    input.strip_suffix(b"\n").unwrap_or(input)
1000}