Skip to main content

sley_protocol/
v0.rs

1use sley_core::{Capability, GitError, ObjectFormat, ObjectId, Result};
2use std::io::{Read, Write};
3
4use crate::pktline::{
5    FetchHeadRecord, FetchRefUpdate, PktLineFrame, ProtocolVersion, RefSpec,
6    line_from_str, parse_oid_argument, parse_protocol_v2_line_text,
7    read_pkt_line_frames_until_flush, refspec_map_source, refspec_matches_source, trim_trailing_lf,
8    validate_capability_field, validate_fetch_head_description_field,
9    validate_fetch_head_line, validate_protocol_v2_token, validate_refspec_shape,
10    write_pkt_line_payload,
11};
12
13use crate::v1::{encode_v1_version_frame, is_v1_version_payload, write_v1_version_line};
14
15pub fn fetch_head_ref_description(refname: &str) -> Result<String> {
16    validate_fetch_head_description_field(refname)?;
17    // Mirror git's `kind`/`what` split in builtin/fetch.c: `HEAD` yields an empty
18    // note (no `'…' of` prefix at all), the standard ref namespaces get their
19    // kind word, and any other ref name is quoted bare.
20    if refname == "HEAD" {
21        Ok(String::new())
22    } else if let Some(branch) = refname.strip_prefix("refs/heads/") {
23        Ok(format!("branch '{branch}'"))
24    } else if let Some(tag) = refname.strip_prefix("refs/tags/") {
25        Ok(format!("tag '{tag}'"))
26    } else if let Some(rest) = refname.strip_prefix("refs/remotes/") {
27        Ok(format!("remote-tracking branch '{rest}'"))
28    } else {
29        Ok(format!("'{refname}'"))
30    }
31}
32
33pub fn fetch_head_remote_description(refname: &str, remote: &str) -> Result<String> {
34    validate_fetch_head_description_field(remote)?;
35    // git only appends `of <url>` when the note (`what`) is non-empty; a bare
36    // `HEAD` fetch records just the URL with an empty description.
37    let what = fetch_head_ref_description(refname)?;
38    if what.is_empty() {
39        Ok(remote.to_string())
40    } else {
41        Ok(format!("{what} of {remote}"))
42    }
43}
44
45pub fn parse_fetch_head(format: ObjectFormat, input: &[u8]) -> Result<Vec<FetchHeadRecord>> {
46    if input.is_empty() {
47        return Ok(Vec::new());
48    }
49    input
50        .split_inclusive(|byte| *byte == b'\n')
51        .map(|line| parse_fetch_head_record(format, line))
52        .collect()
53}
54
55pub fn encode_fetch_head(records: &[FetchHeadRecord]) -> Result<Vec<u8>> {
56    let mut out = Vec::new();
57    for record in records {
58        validate_fetch_head_description_field(&record.description)?;
59        out.extend_from_slice(record.oid.to_string().as_bytes());
60        out.push(b'\t');
61        if record.not_for_merge {
62            out.extend_from_slice(b"not-for-merge");
63        }
64        out.push(b'\t');
65        out.extend_from_slice(record.description.as_bytes());
66        out.push(b'\n');
67    }
68    Ok(out)
69}
70
71pub fn read_fetch_head(
72    format: ObjectFormat,
73    reader: &mut impl Read,
74) -> Result<Vec<FetchHeadRecord>> {
75    let mut input = Vec::new();
76    reader.read_to_end(&mut input)?;
77    parse_fetch_head(format, &input)
78}
79
80pub fn write_fetch_head(writer: &mut impl Write, records: &[FetchHeadRecord]) -> Result<()> {
81    for record in records {
82        validate_fetch_head_description_field(&record.description)?;
83        writer.write_all(record.oid.to_string().as_bytes())?;
84        writer.write_all(b"\t")?;
85        if record.not_for_merge {
86            writer.write_all(b"not-for-merge")?;
87        }
88        writer.write_all(b"\t")?;
89        writer.write_all(record.description.as_bytes())?;
90        writer.write_all(b"\n")?;
91    }
92    Ok(())
93}
94
95/// Match an abbreviated refspec source against the advertised refs the way
96/// upstream's `find_ref_by_name_abbrev` (remote.c) does: score each
97/// advertisement with `refname_match`'s `ref_rev_parse_rules` (exact name
98/// first, then `refs/<name>`, `refs/tags/<name>`, `refs/heads/<name>`,
99/// `refs/remotes/<name>`, `refs/remotes/<name>/HEAD`) and keep the best.
100fn find_advertised_ref_by_name_abbrev<'a>(
101    refs: &'a [RefAdvertisement],
102    name: &str,
103) -> Option<&'a RefAdvertisement> {
104    let mut best: Option<(&RefAdvertisement, usize)> = None;
105    for reference in refs {
106        let score = fetch_refname_match_score(name, &reference.name);
107        if score > best.map(|(_, score)| score).unwrap_or(0) {
108            best = Some((reference, score));
109        }
110    }
111    best.map(|(reference, _)| reference)
112}
113
114/// `refname_match` (refs.c): non-zero when `abbrev` can mean `full`, with the
115/// magnitude giving disambiguation precedence (earlier rules win).
116fn fetch_refname_match_score(abbrev: &str, full: &str) -> usize {
117    let expansions = [
118        abbrev.to_string(),
119        format!("refs/{abbrev}"),
120        format!("refs/tags/{abbrev}"),
121        format!("refs/heads/{abbrev}"),
122        format!("refs/remotes/{abbrev}"),
123        format!("refs/remotes/{abbrev}/HEAD"),
124    ];
125    for (index, candidate) in expansions.iter().enumerate() {
126        if candidate == full {
127            return expansions.len() - index;
128        }
129    }
130    0
131}
132
133/// Whether `abbrev` (a possibly-abbreviated ref like `three` or `refs/heads/main`)
134/// matches the full ref `full` under git's `ref_rev_parse_rules` expansion, the
135/// way `refname_match`/`branch_merge_matches` (remote.c) compare a configured
136/// `branch.<name>.merge` value against an advertised ref name.
137pub fn refname_matches(abbrev: &str, full: &str) -> bool {
138    fetch_refname_match_score(abbrev, full) > 0
139}
140
141/// Qualify a fetch refspec destination the way upstream's `get_local_ref`
142/// (remote.c) does: `refs/...` stays as-is, `heads/`, `tags/` and `remotes/`
143/// gain a `refs/` prefix, and anything else lands under `refs/heads/`.
144fn fetch_local_ref_name(name: &str) -> String {
145    if name.starts_with("refs/") {
146        name.to_string()
147    } else if name.starts_with("heads/")
148        || name.starts_with("tags/")
149        || name.starts_with("remotes/")
150    {
151        format!("refs/{name}")
152    } else {
153        format!("refs/heads/{name}")
154    }
155}
156
157pub fn plan_fetch_ref_updates(
158    refs: &[RefAdvertisement],
159    refspecs: &[RefSpec],
160    auto_follow_tags: bool,
161) -> Result<Vec<FetchRefUpdate>> {
162    let negative = refspecs
163        .iter()
164        .filter(|refspec| refspec.negative)
165        .collect::<Vec<_>>();
166    let mut updates = Vec::new();
167    for refspec in refspecs.iter().filter(|refspec| !refspec.negative) {
168        validate_refspec_shape(refspec)?;
169        let Some(src) = refspec.src.as_deref() else {
170            return Err(GitError::InvalidFormat(
171                "fetch refspec is missing a source".into(),
172            ));
173        };
174        if refspec.pattern {
175            for reference in refs {
176                if refspec_is_excluded(&negative, &reference.name)? {
177                    continue;
178                }
179                if let Some(dst) = refspec_map_source(refspec, &reference.name)? {
180                    updates.push(FetchRefUpdate {
181                        src: reference.name.clone(),
182                        dst: Some(dst),
183                        oid: reference.oid,
184                        not_for_merge: false,
185                        force: refspec.force,
186                    });
187                }
188            }
189            continue;
190        }
191        if refspec_is_excluded(&negative, src)? {
192            continue;
193        }
194        let Some(reference) = find_advertised_ref_by_name_abbrev(refs, src) else {
195            return Err(GitError::reference_not_found(format!("remote ref {src}")));
196        };
197        updates.push(FetchRefUpdate {
198            src: reference.name.clone(),
199            dst: refspec.dst.as_deref().map(fetch_local_ref_name),
200            oid: reference.oid,
201            not_for_merge: false,
202            force: refspec.force,
203        });
204    }
205    if auto_follow_tags && updates.iter().any(|update| update.dst.is_some()) {
206        let fetched_oids = updates.iter().map(|update| update.oid).collect::<Vec<_>>();
207        let fetched_srcs = updates
208            .iter()
209            .map(|update| update.src.clone())
210            .collect::<Vec<_>>();
211        for reference in refs {
212            if reference.name.starts_with("refs/tags/")
213                && fetched_oids.iter().any(|oid| oid == &reference.oid)
214                && !fetched_srcs.contains(&reference.name)
215                && !refspec_is_excluded(&negative, &reference.name)?
216            {
217                updates.push(FetchRefUpdate {
218                    src: reference.name.clone(),
219                    dst: Some(reference.name.clone()),
220                    oid: reference.oid,
221                    not_for_merge: true,
222                    force: false,
223                });
224            }
225        }
226    }
227    Ok(updates)
228}
229
230pub fn fetch_ref_updates_to_fetch_head(
231    updates: &[FetchRefUpdate],
232    remote: &str,
233) -> Result<Vec<FetchHeadRecord>> {
234    updates
235        .iter()
236        .map(|update| {
237            Ok(FetchHeadRecord {
238                oid: update.oid,
239                not_for_merge: update.not_for_merge,
240                description: fetch_head_remote_description(&update.src, remote)?,
241            })
242        })
243        .collect()
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct TransportHandshake {
248    pub protocol: ProtocolVersion,
249    pub capabilities: Vec<Capability>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct RefAdvertisement {
254    pub oid: ObjectId,
255    pub name: String,
256    pub capabilities: Vec<Capability>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct DumbHttpRefRecord {
261    pub oid: ObjectId,
262    pub name: String,
263    pub peeled: bool,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct DumbHttpPackRecord {
268    pub hash: ObjectId,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct RefAdvertisementSet {
273    pub protocol: ProtocolVersion,
274    pub refs: Vec<RefAdvertisement>,
275    pub shallow: Vec<ObjectId>,
276}
277pub fn parse_capabilities(input: &[u8]) -> Result<Vec<Capability>> {
278    let input = trim_trailing_lf(input);
279    if input.is_empty() {
280        return Ok(Vec::new());
281    }
282    let text =
283        std::str::from_utf8(input).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
284    text.split(' ')
285        .map(parse_capability_token)
286        .collect::<Result<Vec<_>>>()
287}
288
289pub fn encode_capabilities(capabilities: &[Capability]) -> Result<Vec<u8>> {
290    let mut out = Vec::new();
291    for (idx, capability) in capabilities.iter().enumerate() {
292        validate_capability_field("capability name", &capability.name)?;
293        if idx != 0 {
294            out.push(b' ');
295        }
296        out.extend_from_slice(capability.name.as_bytes());
297        if let Some(value) = &capability.value {
298            validate_capability_field("capability value", value)?;
299            out.push(b'=');
300            out.extend_from_slice(value.as_bytes());
301        }
302    }
303    Ok(out)
304}
305
306pub fn parse_ref_advertisement(format: ObjectFormat, payload: &[u8]) -> Result<RefAdvertisement> {
307    let payload = trim_trailing_lf(payload);
308    let (reference, capabilities) = match payload.iter().position(|byte| *byte == 0) {
309        Some(idx) => (&payload[..idx], parse_capabilities(&payload[idx + 1..])?),
310        None => (payload, Vec::new()),
311    };
312    let text =
313        std::str::from_utf8(reference).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
314    let (oid, name) = text
315        .split_once(' ')
316        .ok_or_else(|| GitError::InvalidFormat("advertised ref is missing name".into()))?;
317    if name.is_empty() {
318        return Err(GitError::InvalidFormat(
319            "advertised ref name is empty".into(),
320        ));
321    }
322    Ok(RefAdvertisement {
323        oid: ObjectId::from_hex(format, oid)?,
324        name: name.to_string(),
325        capabilities,
326    })
327}
328
329pub fn encode_ref_advertisement(advertisement: &RefAdvertisement) -> Result<Vec<u8>> {
330    validate_protocol_v2_token("advertised ref name", &advertisement.name)?;
331    let mut out = advertisement.oid.to_string().into_bytes();
332    out.push(b' ');
333    out.extend_from_slice(advertisement.name.as_bytes());
334    if !advertisement.capabilities.is_empty() {
335        out.push(0);
336        out.extend_from_slice(&encode_capabilities(&advertisement.capabilities)?);
337    }
338    out.push(b'\n');
339    Ok(out)
340}
341
342pub fn parse_ref_advertisements(
343    format: ObjectFormat,
344    frames: &[PktLineFrame],
345) -> Result<Vec<RefAdvertisement>> {
346    Ok(parse_ref_advertisement_set(format, frames)?.refs)
347}
348
349pub fn parse_ref_advertisement_set(
350    format: ObjectFormat,
351    frames: &[PktLineFrame],
352) -> Result<RefAdvertisementSet> {
353    let mut set = RefAdvertisementSet {
354        protocol: ProtocolVersion::V0,
355        refs: Vec::new(),
356        shallow: Vec::new(),
357    };
358    let mut saw_flush = false;
359    let mut in_shallow = false;
360    for (idx, frame) in frames.iter().enumerate() {
361        match frame {
362            PktLineFrame::Data(payload) if !saw_flush => {
363                let trimmed = trim_trailing_lf(payload);
364                if is_v1_version_payload(payload) {
365                    if idx != 0 {
366                        return Err(GitError::InvalidFormat(
367                            "advertised ref protocol version must be the first line".into(),
368                        ));
369                    }
370                    set.protocol = ProtocolVersion::V1;
371                    continue;
372                }
373                if trimmed.starts_with(b"version ") {
374                    return Err(GitError::InvalidFormat(
375                        "unsupported advertised ref protocol version".into(),
376                    ));
377                }
378                if trimmed.starts_with(b"shallow ") {
379                    if set.refs.is_empty() {
380                        return Err(GitError::InvalidFormat(
381                            "advertised shallow refs must follow advertised refs".into(),
382                        ));
383                    }
384                    let text = parse_protocol_v2_line_text("advertised shallow ref", payload)?;
385                    set.shallow.push(parse_oid_argument(
386                        format,
387                        "advertised shallow ref",
388                        text,
389                        "shallow ",
390                    )?);
391                    in_shallow = true;
392                    continue;
393                }
394                if in_shallow {
395                    return Err(GitError::InvalidFormat(
396                        "advertised refs must not follow shallow refs".into(),
397                    ));
398                }
399                let advertisement = parse_ref_advertisement(format, payload)?;
400                if !set.refs.is_empty() && !advertisement.capabilities.is_empty() {
401                    return Err(GitError::InvalidFormat(
402                        "advertised ref capabilities must appear on the first ref".into(),
403                    ));
404                }
405                set.refs.push(advertisement);
406            }
407            PktLineFrame::Data(_) => {
408                return Err(GitError::InvalidFormat(
409                    "advertised ref stream has data after flush".into(),
410                ));
411            }
412            PktLineFrame::Flush => {
413                saw_flush = true;
414                if idx + 1 != frames.len() {
415                    return Err(GitError::InvalidFormat(
416                        "advertised ref stream has frames after flush".into(),
417                    ));
418                }
419            }
420            PktLineFrame::Delimiter | PktLineFrame::ResponseEnd => {
421                return Err(GitError::InvalidFormat(
422                    "advertised ref stream contains a non-flush control packet".into(),
423                ));
424            }
425        }
426    }
427    if !saw_flush {
428        return Err(GitError::InvalidFormat(
429            "advertised ref stream missing flush".into(),
430        ));
431    }
432    Ok(set)
433}
434
435pub fn encode_ref_advertisements(advertisements: &[RefAdvertisement]) -> Result<Vec<PktLineFrame>> {
436    encode_ref_advertisement_set(&RefAdvertisementSet {
437        protocol: ProtocolVersion::V0,
438        refs: advertisements.to_vec(),
439        shallow: Vec::new(),
440    })
441}
442
443pub fn encode_ref_advertisement_set(set: &RefAdvertisementSet) -> Result<Vec<PktLineFrame>> {
444    let mut frames = Vec::new();
445    match set.protocol {
446        ProtocolVersion::V0 => {}
447        ProtocolVersion::V1 => frames.push(encode_v1_version_frame()?),
448        ProtocolVersion::V2 => {
449            return Err(GitError::InvalidFormat(
450                "protocol v2 does not use v0/v1 advertised-ref streams".into(),
451            ));
452        }
453    }
454    if set.refs.is_empty() && !set.shallow.is_empty() {
455        return Err(GitError::InvalidFormat(
456            "advertised shallow refs require advertised refs".into(),
457        ));
458    }
459    for (idx, advertisement) in set.refs.iter().enumerate() {
460        if idx != 0 && !advertisement.capabilities.is_empty() {
461            return Err(GitError::InvalidFormat(
462                "advertised ref capabilities must appear on the first ref".into(),
463            ));
464        }
465        frames.push(PktLineFrame::data(encode_ref_advertisement(
466            advertisement,
467        )?)?);
468    }
469    for oid in &set.shallow {
470        frames.push(PktLineFrame::data(line_from_str(&format!(
471            "shallow {oid}"
472        )))?);
473    }
474    frames.push(PktLineFrame::Flush);
475    Ok(frames)
476}
477
478pub fn read_ref_advertisements(
479    format: ObjectFormat,
480    reader: &mut impl Read,
481) -> Result<Vec<RefAdvertisement>> {
482    let frames = read_pkt_line_frames_until_flush(reader)?;
483    parse_ref_advertisements(format, &frames)
484}
485
486pub fn read_ref_advertisement_set(
487    format: ObjectFormat,
488    reader: &mut impl Read,
489) -> Result<RefAdvertisementSet> {
490    let frames = read_pkt_line_frames_until_flush(reader)?;
491    parse_ref_advertisement_set(format, &frames)
492}
493
494pub fn write_ref_advertisements(
495    writer: &mut impl Write,
496    advertisements: &[RefAdvertisement],
497) -> Result<()> {
498    write_ref_advertisement_stream(writer, ProtocolVersion::V0, advertisements, &[])
499}
500
501pub fn write_ref_advertisement_set(
502    writer: &mut impl Write,
503    set: &RefAdvertisementSet,
504) -> Result<()> {
505    write_ref_advertisement_stream(writer, set.protocol, &set.refs, &set.shallow)
506}
507
508fn write_ref_advertisement_stream(
509    writer: &mut impl Write,
510    protocol: ProtocolVersion,
511    refs: &[RefAdvertisement],
512    shallow: &[ObjectId],
513) -> Result<()> {
514    match protocol {
515        ProtocolVersion::V0 => {}
516        ProtocolVersion::V1 => write_v1_version_line(writer)?,
517        ProtocolVersion::V2 => {
518            return Err(GitError::InvalidFormat(
519                "protocol v2 does not use v0/v1 advertised-ref streams".into(),
520            ));
521        }
522    }
523    if refs.is_empty() && !shallow.is_empty() {
524        return Err(GitError::InvalidFormat(
525            "advertised shallow refs require advertised refs".into(),
526        ));
527    }
528    for (idx, advertisement) in refs.iter().enumerate() {
529        if idx != 0 && !advertisement.capabilities.is_empty() {
530            return Err(GitError::InvalidFormat(
531                "advertised ref capabilities must appear on the first ref".into(),
532            ));
533        }
534        write_pkt_line_payload(writer, &encode_ref_advertisement(advertisement)?)?;
535    }
536    for oid in shallow {
537        write_pkt_line_payload(writer, &line_from_str(&format!("shallow {oid}")))?;
538    }
539    writer.write_all(b"0000")?;
540    Ok(())
541}
542
543pub fn parse_dumb_http_info_refs(
544    format: ObjectFormat,
545    input: &[u8],
546) -> Result<Vec<DumbHttpRefRecord>> {
547    if input.is_empty() {
548        return Ok(Vec::new());
549    }
550    input
551        .split_inclusive(|byte| *byte == b'\n')
552        .map(|line| parse_dumb_http_info_ref_record(format, line))
553        .collect()
554}
555
556pub fn encode_dumb_http_info_refs(records: &[DumbHttpRefRecord]) -> Result<Vec<u8>> {
557    let mut out = Vec::new();
558    for record in records {
559        validate_dumb_http_ref_name(&record.name)?;
560        out.extend_from_slice(record.oid.to_string().as_bytes());
561        out.push(b'\t');
562        out.extend_from_slice(record.name.as_bytes());
563        if record.peeled {
564            out.extend_from_slice(b"^{}");
565        }
566        out.push(b'\n');
567    }
568    Ok(out)
569}
570
571pub fn read_dumb_http_info_refs(
572    format: ObjectFormat,
573    reader: &mut impl Read,
574) -> Result<Vec<DumbHttpRefRecord>> {
575    let mut input = Vec::new();
576    reader.read_to_end(&mut input)?;
577    parse_dumb_http_info_refs(format, &input)
578}
579
580pub fn write_dumb_http_info_refs(
581    writer: &mut impl Write,
582    records: &[DumbHttpRefRecord],
583) -> Result<()> {
584    for record in records {
585        validate_dumb_http_ref_name(&record.name)?;
586        writer.write_all(record.oid.to_string().as_bytes())?;
587        writer.write_all(b"\t")?;
588        writer.write_all(record.name.as_bytes())?;
589        if record.peeled {
590            writer.write_all(b"^{}")?;
591        }
592        writer.write_all(b"\n")?;
593    }
594    Ok(())
595}
596
597pub fn parse_dumb_http_alternates(input: &[u8]) -> Result<Vec<String>> {
598    if input.is_empty() {
599        return Ok(Vec::new());
600    }
601    input
602        .split_inclusive(|byte| *byte == b'\n')
603        .map(parse_dumb_http_alternate)
604        .collect()
605}
606
607pub fn encode_dumb_http_alternates(alternates: &[String]) -> Result<Vec<u8>> {
608    let mut out = Vec::new();
609    for alternate in alternates {
610        validate_dumb_http_alternate(alternate)?;
611        out.extend_from_slice(alternate.as_bytes());
612        out.push(b'\n');
613    }
614    Ok(out)
615}
616
617pub fn read_dumb_http_alternates(reader: &mut impl Read) -> Result<Vec<String>> {
618    let mut input = Vec::new();
619    reader.read_to_end(&mut input)?;
620    parse_dumb_http_alternates(&input)
621}
622
623pub fn write_dumb_http_alternates(writer: &mut impl Write, alternates: &[String]) -> Result<()> {
624    for alternate in alternates {
625        validate_dumb_http_alternate(alternate)?;
626        writer.write_all(alternate.as_bytes())?;
627        writer.write_all(b"\n")?;
628    }
629    Ok(())
630}
631
632pub fn parse_dumb_http_packs(
633    format: ObjectFormat,
634    input: &[u8],
635) -> Result<Vec<DumbHttpPackRecord>> {
636    if input.is_empty() {
637        return Ok(Vec::new());
638    }
639    input
640        .split_inclusive(|byte| *byte == b'\n')
641        .map(|line| parse_dumb_http_pack_record(format, line))
642        .collect()
643}
644
645pub fn encode_dumb_http_packs(records: &[DumbHttpPackRecord]) -> Result<Vec<u8>> {
646    let mut out = Vec::new();
647    for record in records {
648        out.extend_from_slice(format!("P pack-{}.pack\n", record.hash).as_bytes());
649    }
650    Ok(out)
651}
652
653pub fn read_dumb_http_packs(
654    format: ObjectFormat,
655    reader: &mut impl Read,
656) -> Result<Vec<DumbHttpPackRecord>> {
657    let mut input = Vec::new();
658    reader.read_to_end(&mut input)?;
659    parse_dumb_http_packs(format, &input)
660}
661
662pub fn write_dumb_http_packs(
663    writer: &mut impl Write,
664    records: &[DumbHttpPackRecord],
665) -> Result<()> {
666    for record in records {
667        writer.write_all(format!("P pack-{}.pack\n", record.hash).as_bytes())?;
668    }
669    Ok(())
670}
671fn parse_capability_token(token: &str) -> Result<Capability> {
672    if token.is_empty() {
673        return Err(GitError::InvalidFormat("empty capability token".into()));
674    }
675    let (name, value) = token
676        .split_once('=')
677        .map_or((token, None), |(name, value)| (name, Some(value)));
678    validate_capability_field("capability name", name)?;
679    if let Some(value) = value {
680        validate_capability_field("capability value", value)?;
681    }
682    Ok(Capability {
683        name: name.to_string(),
684        value: value.map(str::to_string),
685    })
686}
687
688fn parse_fetch_head_record(format: ObjectFormat, line: &[u8]) -> Result<FetchHeadRecord> {
689    validate_fetch_head_line(line)?;
690    let line = trim_trailing_lf(line);
691    let mut fields = line.splitn(3, |byte| *byte == b'\t');
692    let oid = fields
693        .next()
694        .ok_or_else(|| GitError::InvalidFormat("FETCH_HEAD record is missing oid".into()))?;
695    let merge_marker = fields.next().ok_or_else(|| {
696        GitError::InvalidFormat("FETCH_HEAD record is missing merge marker".into())
697    })?;
698    let description = fields.next().ok_or_else(|| {
699        GitError::InvalidFormat("FETCH_HEAD record is missing description".into())
700    })?;
701    let oid = std::str::from_utf8(oid).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
702    validate_protocol_v2_token("FETCH_HEAD oid", oid)?;
703    let not_for_merge = match merge_marker {
704        b"" => false,
705        b"not-for-merge" => true,
706        _ => {
707            return Err(GitError::InvalidFormat(
708                "FETCH_HEAD record has invalid merge marker".into(),
709            ));
710        }
711    };
712    let description =
713        std::str::from_utf8(description).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
714    validate_fetch_head_description_field(description)?;
715    Ok(FetchHeadRecord {
716        oid: ObjectId::from_hex(format, oid)?,
717        not_for_merge,
718        description: description.to_string(),
719    })
720}
721
722fn refspec_is_excluded(negative: &[&RefSpec], source: &str) -> Result<bool> {
723    for refspec in negative {
724        if refspec_matches_source(refspec, source)? {
725            return Ok(true);
726        }
727    }
728    Ok(false)
729}
730
731fn parse_dumb_http_info_ref_record(format: ObjectFormat, line: &[u8]) -> Result<DumbHttpRefRecord> {
732    validate_dumb_http_info_ref_line(line)?;
733    let line = trim_trailing_lf(line);
734    let tab = line
735        .iter()
736        .position(|byte| *byte == b'\t')
737        .ok_or_else(|| GitError::InvalidFormat("dumb HTTP ref record is missing name".into()))?;
738    let (oid, name) = (&line[..tab], &line[tab + 1..]);
739    let oid = std::str::from_utf8(oid).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
740    validate_protocol_v2_token("dumb HTTP ref oid", oid)?;
741    let name = std::str::from_utf8(name).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
742    let (name, peeled) = name
743        .strip_suffix("^{}")
744        .map_or((name, false), |name| (name, true));
745    validate_dumb_http_ref_name(name)?;
746    Ok(DumbHttpRefRecord {
747        oid: ObjectId::from_hex(format, oid)?,
748        name: name.to_string(),
749        peeled,
750    })
751}
752
753fn parse_dumb_http_alternate(line: &[u8]) -> Result<String> {
754    validate_dumb_http_alternate_line(line)?;
755    let line = trim_trailing_lf(line);
756    let alternate =
757        std::str::from_utf8(line).map_err(|err| GitError::InvalidFormat(err.to_string()))?;
758    validate_dumb_http_alternate(alternate)?;
759    Ok(alternate.to_string())
760}
761
762fn parse_dumb_http_pack_record(format: ObjectFormat, line: &[u8]) -> Result<DumbHttpPackRecord> {
763    validate_dumb_http_info_ref_line(line)?;
764    let line = parse_protocol_v2_line_text("dumb HTTP pack record", line)?;
765    let pack_name = line
766        .strip_prefix("P ")
767        .ok_or_else(|| GitError::InvalidFormat("dumb HTTP pack record must start with P".into()))?;
768    let hash = pack_name
769        .strip_prefix("pack-")
770        .and_then(|value| value.strip_suffix(".pack"))
771        .ok_or_else(|| GitError::InvalidFormat("invalid dumb HTTP pack name".into()))?;
772    validate_protocol_v2_token("dumb HTTP pack hash", hash)?;
773    Ok(DumbHttpPackRecord {
774        hash: ObjectId::from_hex(format, hash)?,
775    })
776}
777
778fn validate_dumb_http_info_ref_line(value: &[u8]) -> Result<()> {
779    if value.is_empty() {
780        return Err(GitError::InvalidFormat(
781            "dumb HTTP ref record is empty".into(),
782        ));
783    }
784    if !value.ends_with(b"\n") {
785        return Err(GitError::InvalidFormat(
786            "dumb HTTP ref record missing LF".into(),
787        ));
788    }
789    if value.iter().any(|byte| matches!(*byte, b'\r' | 0)) {
790        return Err(GitError::InvalidFormat(
791            "dumb HTTP ref record contains a delimiter byte".into(),
792        ));
793    }
794    Ok(())
795}
796
797fn validate_dumb_http_ref_name(value: &str) -> Result<()> {
798    validate_protocol_v2_token("dumb HTTP ref name", value)?;
799    if value.ends_with("^{}") {
800        return Err(GitError::InvalidFormat(
801            "dumb HTTP ref name must not include peeled suffix".into(),
802        ));
803    }
804    Ok(())
805}
806
807fn validate_dumb_http_alternate_line(value: &[u8]) -> Result<()> {
808    if value.is_empty() {
809        return Err(GitError::InvalidFormat(
810            "dumb HTTP alternate is empty".into(),
811        ));
812    }
813    if !value.ends_with(b"\n") {
814        return Err(GitError::InvalidFormat(
815            "dumb HTTP alternate missing LF".into(),
816        ));
817    }
818    if value.iter().any(|byte| matches!(*byte, b'\r' | 0)) {
819        return Err(GitError::InvalidFormat(
820            "dumb HTTP alternate contains a delimiter byte".into(),
821        ));
822    }
823    Ok(())
824}
825
826fn validate_dumb_http_alternate(value: &str) -> Result<()> {
827    if value.is_empty() {
828        return Err(GitError::InvalidFormat(
829            "dumb HTTP alternate is empty".into(),
830        ));
831    }
832    if value.bytes().any(|byte| matches!(byte, b'\n' | b'\r' | 0)) {
833        return Err(GitError::InvalidFormat(
834            "dumb HTTP alternate contains a delimiter byte".into(),
835        ));
836    }
837    Ok(())
838}
839