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