Skip to main content

netget/server/git/
pktline.rs

1//! Git pkt-line framing and `git-upload-pack` request parsing.
2//!
3//! A pkt-line is a 4-digit hex length (counting the 4 bytes themselves) followed by that many
4//! bytes minus four. `0000` is a flush packet and `0001` a delimiter packet, neither carrying
5//! a payload.
6
7/// Frame `payload` as a single pkt-line.
8pub fn encode(payload: &[u8]) -> Vec<u8> {
9    let len = payload.len() + 4;
10    debug_assert!(len <= 65520, "pkt-line payload too large: {len}");
11    let mut out = format!("{:04x}", len).into_bytes();
12    out.extend_from_slice(payload);
13    out
14}
15
16/// The flush packet that terminates a section.
17pub const FLUSH: &[u8] = b"0000";
18
19/// What a client asked for in a `POST /git-upload-pack` body.
20#[derive(Debug, Default, Clone)]
21pub struct UploadPackRequest {
22    /// Object IDs the client wants, lowercased hex.
23    pub wants: Vec<String>,
24    /// Object IDs the client already has (empty for a clone).
25    pub haves: Vec<String>,
26    /// Capabilities the client selected, taken from the first `want` line.
27    pub capabilities: Vec<String>,
28    /// Whether the client sent `done`.
29    pub done: bool,
30}
31
32// No side-band helpers here: `BASE_CAPABILITIES` deliberately does not advertise
33// `side-band`/`side-band-64k`, so nothing can select it and nothing multiplexes the
34// response. `wants_side_band()` and `side_band_chunk_size()` used to sit here with no
35// caller at all, which read as support the server does not have.
36
37/// Parse a `git-upload-pack` request body.
38///
39/// Unknown or malformed lines are skipped rather than rejected: the body comes off the
40/// network, and a honeypot that dies on a malformed want line is worse than one that answers
41/// what it understood. A truncated length header ends parsing.
42pub fn parse_upload_pack_request(body: &[u8]) -> UploadPackRequest {
43    let mut request = UploadPackRequest::default();
44    let mut offset = 0usize;
45
46    while offset + 4 <= body.len() {
47        let header = match std::str::from_utf8(&body[offset..offset + 4]) {
48            Ok(h) => h,
49            Err(_) => break,
50        };
51        let len = match usize::from_str_radix(header, 16) {
52            Ok(l) => l,
53            Err(_) => break,
54        };
55        offset += 4;
56
57        // 0000 (flush) and 0001 (delimiter) carry no payload.
58        if len == 0 || len == 1 {
59            continue;
60        }
61        if len < 4 {
62            break;
63        }
64        let payload_len = len - 4;
65        if offset + payload_len > body.len() {
66            break;
67        }
68        let payload = &body[offset..offset + payload_len];
69        offset += payload_len;
70
71        let line = String::from_utf8_lossy(payload);
72        let line = line.trim_end_matches(['\n', '\r']);
73
74        if let Some(rest) = line.strip_prefix("want ") {
75            let mut parts = rest.split(' ');
76            if let Some(oid) = parts.next() {
77                if is_object_id(oid) {
78                    request.wants.push(oid.to_ascii_lowercase());
79                }
80            }
81            // Capabilities ride on the first want line only.
82            if request.capabilities.is_empty() {
83                request.capabilities = parts.map(|c| c.to_string()).collect();
84            }
85        } else if let Some(oid) = line.strip_prefix("have ") {
86            let oid = oid.split(' ').next().unwrap_or("");
87            if is_object_id(oid) {
88                request.haves.push(oid.to_ascii_lowercase());
89            }
90        } else if line == "done" {
91            request.done = true;
92        }
93    }
94
95    request
96}
97
98fn is_object_id(value: &str) -> bool {
99    value.len() == 40 && value.bytes().all(|b| b.is_ascii_hexdigit())
100}