Skip to main content

sley_protocol/
pktline.rs

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