Skip to main content

gitserver_core/
protocol_v2.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) 2026 WJQSERVER
6
7use std::collections::BTreeSet;
8use std::collections::HashSet;
9use std::io::Cursor;
10use std::path::Path;
11
12use crate::error::{Error, Result};
13use crate::pack::UploadPackRequest;
14use crate::pktline;
15
16const CAPABILITIES: &[&str] = &[
17    "ls-refs=unborn",
18    "fetch=shallow wait-for-done",
19    "object-format=sha1",
20];
21
22pub enum Command {
23    LsRefs(LsRefsRequest),
24    Fetch(FetchRequest),
25}
26
27#[derive(Default)]
28pub struct LsRefsRequest {
29    pub peel: bool,
30    pub symrefs: bool,
31    pub unborn: bool,
32    pub ref_prefixes: Vec<String>,
33}
34
35pub struct FetchRequest {
36    pub upload_request: UploadPackRequest,
37}
38
39pub struct ShallowUpdate {
40    pub shallow: Vec<gix::ObjectId>,
41    pub unshallow: Vec<gix::ObjectId>,
42}
43
44pub fn advertise_capabilities() -> Vec<u8> {
45    let mut out = Vec::new();
46    out.extend_from_slice(&pktline::encode_comment("service=git-upload-pack"));
47    out.extend_from_slice(pktline::flush());
48    out.extend_from_slice(&pktline::encode(b"version 2\n"));
49    for capability in CAPABILITIES {
50        out.extend_from_slice(&pktline::encode(format!("{capability}\n").as_bytes()));
51    }
52    out.extend_from_slice(pktline::flush());
53    out
54}
55
56pub fn parse_command_request(body: &[u8]) -> Result<Command> {
57    let lines = decode_pkt_lines(body)?;
58    let mut iter = lines.into_iter();
59
60    let command = iter
61        .next()
62        .ok_or_else(|| Error::Protocol("missing protocol v2 command".into()))?;
63    let command = command
64        .strip_prefix("command=")
65        .ok_or_else(|| Error::Protocol("invalid protocol v2 command line".into()))?;
66
67    let mut args = Vec::new();
68    let mut saw_delim = false;
69    for line in iter {
70        if line.is_empty() {
71            continue;
72        }
73        if line == "0001" {
74            saw_delim = true;
75            continue;
76        }
77        if saw_delim {
78            args.push(line);
79        }
80    }
81
82    match command {
83        "ls-refs" => parse_ls_refs(args),
84        "fetch" => parse_fetch(args),
85        _ => Err(Error::Protocol(format!(
86            "unsupported protocol v2 command: {command}"
87        ))),
88    }
89}
90
91pub fn ls_refs(repo_path: &Path, request: &LsRefsRequest) -> Result<Vec<u8>> {
92    let repo = gix::open(repo_path)?;
93    let mut refs = BTreeSet::new();
94
95    if let Ok(mut head) = repo.head() {
96        if let Some(id) = head
97            .try_peel_to_id()
98            .map_err(|e| Error::Protocol(e.to_string()))?
99        {
100            let mut line = format!("{} HEAD", id.detach());
101            if request.symrefs
102                && let Some(target) = head.referent_name()
103            {
104                line.push_str(&format!(" symref-target:{}", target.as_bstr()));
105            }
106            refs.insert(line);
107        } else if request.unborn
108            && let Some(target) = head.referent_name()
109        {
110            refs.insert(format!("unborn HEAD symref-target:{}", target.as_bstr()));
111        }
112    }
113
114    if let Ok(platform) = repo.references()
115        && let Ok(iter) = platform.all()
116    {
117        for mut reference in iter.flatten() {
118            let name = reference.name().as_bstr().to_string();
119            if !request.ref_prefixes.is_empty()
120                && !request
121                    .ref_prefixes
122                    .iter()
123                    .any(|prefix| name.starts_with(prefix))
124            {
125                continue;
126            }
127
128            let mut line = match reference.try_id() {
129                Some(id) => format!("{} {name}", id.detach()),
130                None => match reference.peel_to_id() {
131                    Ok(id) => format!("{} {name}", id.detach()),
132                    Err(_) => continue,
133                },
134            };
135
136            if request.symrefs
137                && let Some(target) = reference.target().try_name()
138            {
139                line.push_str(&format!(" symref-target:{}", target.as_bstr()));
140            }
141
142            if request.peel
143                && let Ok(peeled) = reference.peel_to_id()
144            {
145                line.push_str(&format!(" peeled:{}", peeled.detach()));
146            }
147
148            refs.insert(line);
149        }
150    }
151
152    let mut out = Vec::new();
153    for line in refs {
154        out.extend_from_slice(&pktline::encode(format!("{line}\n").as_bytes()));
155    }
156    out.extend_from_slice(pktline::flush());
157    Ok(out)
158}
159
160pub fn encode_fetch_pack_response(pack_bytes: &[u8]) -> Vec<u8> {
161    let mut out = Vec::new();
162    out.extend_from_slice(&pktline::encode(b"packfile\n"));
163
164    let mut pos = 0;
165    while pos + 4 <= pack_bytes.len() {
166        let len_str = match std::str::from_utf8(&pack_bytes[pos..pos + 4]) {
167            Ok(v) => v,
168            Err(_) => break,
169        };
170        pos += 4;
171
172        if len_str == "0000" {
173            out.extend_from_slice(b"0000");
174            break;
175        }
176
177        let len = match usize::from_str_radix(len_str, 16) {
178            Ok(v) if v >= 4 && pos + (v - 4) <= pack_bytes.len() => v,
179            _ => break,
180        };
181
182        let frame = &pack_bytes[pos - 4..pos + (len - 4)];
183        let payload = &pack_bytes[pos..pos + (len - 4)];
184        pos += len - 4;
185
186        if payload.starts_with(&[0x01])
187            || payload.starts_with(&[0x02])
188            || payload.starts_with(&[0x03])
189        {
190            out.extend_from_slice(frame);
191        }
192    }
193
194    out
195}
196
197pub struct PrefixThenReader<R> {
198    prefix: Cursor<Vec<u8>>,
199    reader: R,
200}
201
202impl<R> PrefixThenReader<R> {
203    pub fn new(prefix: Vec<u8>, reader: R) -> Self {
204        Self {
205            prefix: Cursor::new(prefix),
206            reader,
207        }
208    }
209}
210
211pub struct PackSectionReader<R> {
212    reader: R,
213    buf: Vec<u8>,
214    out: Cursor<Vec<u8>>,
215    finished: bool,
216}
217
218impl<R> PackSectionReader<R> {
219    pub fn new(reader: R) -> Self {
220        Self {
221            reader,
222            buf: Vec::new(),
223            out: Cursor::new(Vec::new()),
224            finished: false,
225        }
226    }
227}
228
229impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PackSectionReader<R> {
230    fn poll_read(
231        mut self: std::pin::Pin<&mut Self>,
232        cx: &mut std::task::Context<'_>,
233        buf: &mut tokio::io::ReadBuf<'_>,
234    ) -> std::task::Poll<std::io::Result<()>> {
235        loop {
236            if (self.out.position() as usize) < self.out.get_ref().len() {
237                let remaining = &self.out.get_ref()[self.out.position() as usize..];
238                let to_copy = remaining.len().min(buf.remaining());
239                buf.put_slice(&remaining[..to_copy]);
240                let next = self.out.position() + to_copy as u64;
241                self.out.set_position(next);
242                return std::task::Poll::Ready(Ok(()));
243            }
244
245            if self.finished {
246                return std::task::Poll::Ready(Ok(()));
247            }
248
249            let mut frame_buf = [0u8; 8192];
250            let mut read_buf = tokio::io::ReadBuf::new(&mut frame_buf);
251            match std::pin::Pin::new(&mut self.reader).poll_read(cx, &mut read_buf) {
252                std::task::Poll::Pending => return std::task::Poll::Pending,
253                std::task::Poll::Ready(Err(err)) => return std::task::Poll::Ready(Err(err)),
254                std::task::Poll::Ready(Ok(())) => {
255                    let filled = read_buf.filled();
256                    if filled.is_empty() {
257                        self.finished = true;
258                        return std::task::Poll::Ready(Ok(()));
259                    }
260                    self.buf.extend_from_slice(filled);
261                }
262            }
263
264            let mut emitted = Vec::new();
265            loop {
266                if self.buf.len() < 4 {
267                    break;
268                }
269                let len_str = match std::str::from_utf8(&self.buf[..4]) {
270                    Ok(v) => v,
271                    Err(_) => {
272                        self.finished = true;
273                        return std::task::Poll::Ready(Err(std::io::Error::other(
274                            "invalid pkt-line prefix in pack response",
275                        )));
276                    }
277                };
278
279                if len_str == "0000" {
280                    emitted.extend_from_slice(b"0000");
281                    self.buf.drain(..4);
282                    self.finished = true;
283                    break;
284                }
285
286                let len = match usize::from_str_radix(len_str, 16) {
287                    Ok(v) if v >= 4 => v,
288                    _ => {
289                        self.finished = true;
290                        return std::task::Poll::Ready(Err(std::io::Error::other(
291                            "invalid pkt-line length in pack response",
292                        )));
293                    }
294                };
295
296                if self.buf.len() < len {
297                    break;
298                }
299
300                let frame = self.buf[..len].to_vec();
301                let payload = &frame[4..];
302                if payload.starts_with(&[0x01])
303                    || payload.starts_with(&[0x02])
304                    || payload.starts_with(&[0x03])
305                {
306                    emitted.extend_from_slice(&frame);
307                }
308                self.buf.drain(..len);
309            }
310
311            if !emitted.is_empty() {
312                self.out = Cursor::new(emitted);
313                self.out.set_position(0);
314            }
315        }
316    }
317}
318
319impl<R: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for PrefixThenReader<R> {
320    fn poll_read(
321        mut self: std::pin::Pin<&mut Self>,
322        cx: &mut std::task::Context<'_>,
323        buf: &mut tokio::io::ReadBuf<'_>,
324    ) -> std::task::Poll<std::io::Result<()>> {
325        if (self.prefix.position() as usize) < self.prefix.get_ref().len() {
326            let remaining = &self.prefix.get_ref()[self.prefix.position() as usize..];
327            let to_copy = remaining.len().min(buf.remaining());
328            buf.put_slice(&remaining[..to_copy]);
329            let next = self.prefix.position() + to_copy as u64;
330            self.prefix.set_position(next);
331            return std::task::Poll::Ready(Ok(()));
332        }
333
334        std::pin::Pin::new(&mut self.reader).poll_read(cx, buf)
335    }
336}
337
338pub fn encode_fetch_ready_and_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
339    let mut out = encode_fetch_acknowledgments(common);
340    if !common.is_empty() {
341        out.truncate(out.len() - 4);
342        out.extend_from_slice(&pktline::encode(b"ready\n"));
343        out.extend_from_slice(b"0001");
344    }
345    out
346}
347
348pub fn encode_fetch_acknowledgments(common: &[gix::ObjectId]) -> Vec<u8> {
349    let mut out = Vec::new();
350    out.extend_from_slice(&pktline::encode(b"acknowledgments\n"));
351
352    if common.is_empty() {
353        out.extend_from_slice(&pktline::encode(b"NAK\n"));
354    } else {
355        for oid in common {
356            out.extend_from_slice(&pktline::encode(format!("ACK {oid}\n").as_bytes()));
357        }
358    }
359
360    out.extend_from_slice(pktline::flush());
361    out
362}
363
364pub fn encode_shallow_info(update: &ShallowUpdate) -> Vec<u8> {
365    let mut out = Vec::new();
366    if update.shallow.is_empty() && update.unshallow.is_empty() {
367        return out;
368    }
369
370    out.extend_from_slice(&pktline::encode(b"shallow-info\n"));
371    for oid in &update.shallow {
372        out.extend_from_slice(&pktline::encode(format!("shallow {oid}\n").as_bytes()));
373    }
374    for oid in &update.unshallow {
375        out.extend_from_slice(&pktline::encode(format!("unshallow {oid}\n").as_bytes()));
376    }
377    out.extend_from_slice(b"0001");
378    out
379}
380
381pub fn common_haves(repo_path: &Path, request: &FetchRequest) -> Result<Vec<gix::ObjectId>> {
382    let repo = gix::open(repo_path)?;
383    let want_set: HashSet<gix::ObjectId> =
384        collect_want_closure(&repo, &request.upload_request.wants)?
385            .into_iter()
386            .collect();
387
388    Ok(request
389        .upload_request
390        .haves
391        .iter()
392        .copied()
393        .filter(|oid| want_set.contains(oid))
394        .collect())
395}
396
397pub fn apply_shallow_boundaries(
398    repo_path: &Path,
399    request: &mut FetchRequest,
400) -> Result<ShallowUpdate> {
401    let Some(depth) = request.upload_request.shallow.depth else {
402        return Ok(ShallowUpdate {
403            shallow: Vec::new(),
404            unshallow: Vec::new(),
405        });
406    };
407
408    let repo = gix::open(repo_path)?;
409    let previous_shallows = request.upload_request.shallow.client_shallows.clone();
410    let state = collect_depth_limited_commits(&repo, &request.upload_request, depth)?;
411
412    request.upload_request.object_ids = Some(state.included_objects.clone());
413    request
414        .upload_request
415        .haves
416        .extend(previous_shallows.iter().copied());
417
418    let next_shallows: HashSet<_> = state.shallow_boundary.iter().copied().collect();
419    let prev_shallows: HashSet<_> = previous_shallows.iter().copied().collect();
420
421    Ok(ShallowUpdate {
422        shallow: state
423            .shallow_boundary
424            .iter()
425            .copied()
426            .filter(|oid| !prev_shallows.contains(oid))
427            .collect(),
428        unshallow: previous_shallows
429            .into_iter()
430            .filter(|oid| !next_shallows.contains(oid))
431            .collect(),
432    })
433}
434
435struct DepthState {
436    included_objects: Vec<gix::ObjectId>,
437    shallow_boundary: Vec<gix::ObjectId>,
438}
439
440fn parse_ls_refs(args: Vec<String>) -> Result<Command> {
441    let mut request = LsRefsRequest::default();
442
443    for arg in args {
444        match arg.as_str() {
445            "peel" => request.peel = true,
446            "symrefs" => request.symrefs = true,
447            "unborn" => request.unborn = true,
448            _ => {
449                if let Some(prefix) = arg.strip_prefix("ref-prefix ") {
450                    request.ref_prefixes.push(prefix.to_owned());
451                } else {
452                    return Err(Error::Protocol(format!(
453                        "unsupported ls-refs argument: {arg}"
454                    )));
455                }
456            }
457        }
458    }
459
460    Ok(Command::LsRefs(request))
461}
462
463fn parse_fetch(args: Vec<String>) -> Result<Command> {
464    let mut wants = Vec::new();
465    let mut haves = Vec::new();
466    let mut done = false;
467    let mut capabilities = crate::pack::UploadPackCapabilities::default();
468    let mut shallow = crate::pack::ShallowRequest::default();
469
470    for arg in args {
471        if arg == "done" {
472            done = true;
473        } else if arg == "ofs-delta" {
474            capabilities.ofs_delta = true;
475        } else if arg == "deepen-relative" {
476            shallow.deepen_relative = true;
477        } else if arg == "thin-pack"
478            || arg == "no-progress"
479            || arg == "include-tag"
480            || arg == "wait-for-done"
481        {
482            continue;
483        } else if let Some(depth) = arg.strip_prefix("deepen ") {
484            shallow.depth = Some(
485                depth
486                    .parse::<usize>()
487                    .map_err(|_| Error::Protocol(format!("invalid deepen value: {depth}")))?,
488            );
489        } else if let Some(oid_hex) = arg.strip_prefix("shallow ") {
490            let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
491                .map_err(|_| Error::Protocol(format!("invalid OID in shallow: {oid_hex}")))?;
492            shallow.client_shallows.push(oid);
493        } else if let Some(oid_hex) = arg.strip_prefix("want ") {
494            let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
495                .map_err(|_| Error::Protocol(format!("invalid OID in want: {oid_hex}")))?;
496            wants.push(oid);
497        } else if let Some(oid_hex) = arg.strip_prefix("have ") {
498            let oid = gix::ObjectId::from_hex(oid_hex.as_bytes())
499                .map_err(|_| Error::Protocol(format!("invalid OID in have: {oid_hex}")))?;
500            haves.push(oid);
501        } else {
502            return Err(Error::Protocol(format!(
503                "unsupported fetch argument: {arg}"
504            )));
505        }
506    }
507
508    Ok(Command::Fetch(FetchRequest {
509        upload_request: UploadPackRequest {
510            wants,
511            haves,
512            done,
513            capabilities,
514            shallow,
515            object_ids: None,
516        },
517    }))
518}
519
520fn decode_pkt_lines(body: &[u8]) -> Result<Vec<String>> {
521    let mut pos = 0;
522    let mut out = Vec::new();
523
524    while pos < body.len() {
525        if pos + 4 > body.len() {
526            return Err(Error::Protocol("truncated pkt-line prefix".into()));
527        }
528
529        let len_str = std::str::from_utf8(&body[pos..pos + 4])
530            .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
531        pos += 4;
532
533        if len_str == "0000" {
534            break;
535        }
536        if len_str == "0001" {
537            out.push("0001".to_string());
538            continue;
539        }
540
541        let len = usize::from_str_radix(len_str, 16)
542            .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
543        if len < 4 || pos + (len - 4) > body.len() {
544            return Err(Error::Protocol("invalid pkt-line frame length".into()));
545        }
546
547        let payload = &body[pos..pos + (len - 4)];
548        pos += len - 4;
549        let line = std::str::from_utf8(payload)
550            .map_err(|_| Error::Protocol("invalid UTF-8 in pkt-line".into()))?;
551        out.push(line.trim_end_matches('\n').to_owned());
552    }
553
554    Ok(out)
555}
556
557fn collect_want_closure(
558    repo: &gix::Repository,
559    wants: &[gix::ObjectId],
560) -> Result<Vec<gix::ObjectId>> {
561    let mut seen = HashSet::new();
562    let mut out = Vec::new();
563
564    let walk = repo
565        .rev_walk(wants.iter().copied())
566        .all()
567        .map_err(|e| Error::Protocol(e.to_string()))?;
568    for info_result in walk {
569        let info = info_result.map_err(|e| Error::Protocol(e.to_string()))?;
570        let commit_oid = info.id;
571        if !seen.insert(commit_oid) {
572            continue;
573        }
574        out.push(commit_oid);
575    }
576
577    Ok(out)
578}
579
580fn collect_depth_limited_commits(
581    repo: &gix::Repository,
582    request: &crate::pack::UploadPackRequest,
583    depth: usize,
584) -> Result<DepthState> {
585    use std::collections::{HashSet, VecDeque};
586
587    let mut queue = VecDeque::new();
588    let mut seen = HashSet::new();
589    let mut included_commits = Vec::new();
590    let mut included_objects = Vec::new();
591    let mut shallow_boundary = Vec::new();
592
593    let base_depth = if request.shallow.deepen_relative {
594        1usize
595    } else {
596        0
597    };
598    let limit = base_depth + depth;
599
600    for want in &request.wants {
601        queue.push_back((*want, 1usize));
602    }
603
604    while let Some((commit_oid, current_depth)) = queue.pop_front() {
605        if !seen.insert(commit_oid) {
606            continue;
607        }
608        included_commits.push(commit_oid);
609        included_objects.push(commit_oid);
610
611        let commit_obj = repo
612            .find_object(commit_oid)
613            .map_err(|e| Error::Protocol(e.to_string()))?;
614        let tree_oid = gix::objs::CommitRefIter::from_bytes(&commit_obj.data)
615            .tree_id()
616            .map_err(|e| Error::Protocol(e.to_string()))?;
617        collect_tree_oids(repo, tree_oid, &mut seen, &mut included_objects)?;
618        let parents: Vec<_> = gix::objs::CommitRefIter::from_bytes(&commit_obj.data)
619            .parent_ids()
620            .collect();
621
622        if current_depth >= limit || parents.is_empty() {
623            shallow_boundary.push(commit_oid);
624            continue;
625        }
626
627        for parent in parents {
628            queue.push_back((parent, current_depth + 1));
629        }
630    }
631
632    Ok(DepthState {
633        included_objects,
634        shallow_boundary,
635    })
636}
637
638fn collect_tree_oids(
639    repo: &gix::Repository,
640    root_tree_oid: gix::ObjectId,
641    seen: &mut HashSet<gix::ObjectId>,
642    oids: &mut Vec<gix::ObjectId>,
643) -> Result<()> {
644    let mut stack = vec![root_tree_oid];
645
646    while let Some(tree_oid) = stack.pop() {
647        if !seen.insert(tree_oid) {
648            continue;
649        }
650
651        let tree_obj = repo
652            .find_object(tree_oid)
653            .map_err(|e| Error::Protocol(e.to_string()))?;
654        oids.push(tree_oid);
655
656        for entry_result in gix::objs::TreeRefIter::from_bytes(&tree_obj.data) {
657            let entry = entry_result.map_err(|e| Error::Protocol(e.to_string()))?;
658            let entry_oid = entry.oid.to_owned();
659            let entry_mode = entry.mode;
660
661            if entry_mode.is_tree() {
662                stack.push(entry_oid);
663            } else if seen.insert(entry_oid) && !entry_mode.is_commit() {
664                oids.push(entry_oid);
665            }
666        }
667    }
668
669    Ok(())
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn pkt(data: &str) -> Vec<u8> {
677        pktline::encode(data.as_bytes())
678    }
679
680    #[test]
681    fn parse_ls_refs_command() {
682        let mut body = Vec::new();
683        body.extend_from_slice(&pkt("command=ls-refs\n"));
684        body.extend_from_slice(b"0001");
685        body.extend_from_slice(&pkt("peel\n"));
686        body.extend_from_slice(&pkt("symrefs\n"));
687        body.extend_from_slice(&pkt("ref-prefix refs/heads/\n"));
688        body.extend_from_slice(b"0000");
689
690        let Command::LsRefs(req) = parse_command_request(&body).unwrap() else {
691            panic!("expected ls-refs command");
692        };
693        assert!(req.peel);
694        assert!(req.symrefs);
695        assert_eq!(req.ref_prefixes, vec!["refs/heads/"]);
696    }
697
698    #[test]
699    fn parse_fetch_command() {
700        let mut body = Vec::new();
701        body.extend_from_slice(&pkt("command=fetch\n"));
702        body.extend_from_slice(b"0001");
703        body.extend_from_slice(&pkt("ofs-delta\n"));
704        body.extend_from_slice(&pkt("want 0000000000000000000000000000000000000001\n"));
705        body.extend_from_slice(&pkt("done\n"));
706        body.extend_from_slice(b"0000");
707
708        let Command::Fetch(req) = parse_command_request(&body).unwrap() else {
709            panic!("expected fetch command");
710        };
711        assert_eq!(req.upload_request.wants.len(), 1);
712        assert!(req.upload_request.done);
713        assert!(req.upload_request.capabilities.ofs_delta);
714    }
715
716    #[test]
717    fn ls_refs_returns_unborn_head() {
718        let root = tempfile::TempDir::new().unwrap();
719        let repo_path = root.path().join("repo.git");
720        std::process::Command::new("git")
721            .args(["init", "--bare", repo_path.to_str().unwrap()])
722            .output()
723            .unwrap();
724        std::process::Command::new("git")
725            .args(["symbolic-ref", "HEAD", "refs/heads/main"])
726            .current_dir(&repo_path)
727            .output()
728            .unwrap();
729
730        let out = ls_refs(
731            &repo_path,
732            &LsRefsRequest {
733                unborn: true,
734                symrefs: true,
735                ..Default::default()
736            },
737        )
738        .unwrap();
739        let text = String::from_utf8(out).unwrap();
740        assert!(text.contains("unborn HEAD symref-target:refs/heads/main"));
741    }
742}