Skip to main content

git_zero_client/
lib.rs

1//! Bounded HTTP fetch primitives. The host supplies transport and storage.
2//! Smart fetch returns a validated raw pack; dumb fetch returns a zlib loose
3//! object. Bounded loose-object decoding validates headers, lengths and IDs;
4//! `git_zero_core::zlib` also provides standalone inflation, and `git_zero_core::pack` provides
5//! entry/delta decoding and bounded materialized indexes. The host owns storage
6//! and checkout policy.
7#![no_std]
8#![forbid(unsafe_code)]
9
10use git_zero_core::{
11    Error, Object, ObjectFormat, ObjectId, Result,
12    http::Request,
13    io::{SliceWriter, Write},
14    zlib::{InflateLimits, InflateStatus, Inflater, WINDOW_SIZE},
15};
16
17mod reference;
18mod smart;
19pub use reference::{reference_name, resolve_reference};
20pub use smart::{
21    FetchOptions, SmartAdvertisement, read_pack_response, read_pack_response_with_format,
22    read_smart_reference, write_fetch_request, write_fetch_request_iter,
23};
24
25pub trait Transport {
26    /// Paths are URL paths relative to a fixed repository URL, with reserved
27    /// bytes percent-encoded. Return status and body length.
28    /// Decode HTTP transfer framing before returning. Do not enable protocol v2.
29    fn request(&mut self, request: Request<'_>, response: &mut [u8]) -> Result<(u16, usize)>;
30}
31
32/// Default-zlib wire capacity, including up to 32 bytes of canonical Git header.
33/// Non-default encoders can need a separate budget; output limits are independent.
34pub fn loose_wire_capacity(body_limit: usize) -> Result<usize> {
35    let source = body_limit.checked_add(32).ok_or(Error::LimitExceeded)?;
36    usize::try_from(git_zero_core::zlib::compress_bound(source as u64)?)
37        .map_err(|_| Error::LimitExceeded)
38}
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum Protocol {
42    Smart,
43    Dumb,
44}
45
46#[derive(Clone, Copy, Debug)]
47pub struct Reference<'a> {
48    pub id: ObjectId,
49    pub name: &'a str,
50}
51
52pub struct Discovery<'a> {
53    pub protocol: Protocol,
54    bytes: &'a [u8],
55    format: Option<ObjectFormat>,
56}
57
58impl<'a> Discovery<'a> {
59    /// Parse a complete downloaded response without transport I/O. Reference
60    /// IDs must share the advertised or inferred repository object format.
61    pub fn parse(data: &'a [u8]) -> Result<Self> {
62        let (protocol, bytes, format) = if data
63            .get(4..)
64            .is_some_and(|tail| tail.starts_with(b"# service="))
65        {
66            let advertisement = SmartAdvertisement::parse(data)?;
67            (
68                Protocol::Smart,
69                smart::advertisement_body(data)?,
70                advertisement.object_format(),
71            )
72        } else {
73            (Protocol::Dumb, data, None)
74        };
75        let mut discovery = Self {
76            protocol,
77            bytes,
78            format,
79        };
80        let mut references = discovery.references();
81        for reference in references.by_ref() {
82            reference?;
83        }
84        discovery.format = references.format;
85        Ok(discovery)
86    }
87    pub fn references(&self) -> References<'a> {
88        References {
89            protocol: self.protocol,
90            bytes: self.bytes,
91            failed: false,
92            format: self.format,
93        }
94    }
95
96    /// Smart HEAD when present; otherwise the first advertised branch.
97    /// Dumb callers can use `read_head` to resolve the actual default branch.
98    pub fn head(&self) -> Result<ObjectId> {
99        let mut first = None;
100        for reference in self.references() {
101            let reference = reference?;
102            if reference.name == "HEAD" {
103                return Ok(reference.id);
104            }
105            first.get_or_insert(reference.id);
106        }
107        first.ok_or(Error::ObjectNotFound)
108    }
109}
110
111pub struct References<'a> {
112    protocol: Protocol,
113    bytes: &'a [u8],
114    failed: bool,
115    format: Option<ObjectFormat>,
116}
117
118impl<'a> References<'a> {
119    fn parse(&mut self, line: &'a [u8]) -> Result<Reference<'a>> {
120        let mut parts = line.splitn(2, |&byte| byte == 0);
121        let reference = parts.next().ok_or(Error::InvalidRequest)?;
122        let reference = reference.strip_suffix(b"\n").unwrap_or(reference);
123        let separator = reference
124            .iter()
125            .position(|&byte| matches!(byte, b' ' | b'\t'))
126            .ok_or(Error::InvalidRequest)?;
127        if reference.len() < separator + 2 {
128            return Err(Error::InvalidRequest);
129        }
130        let id = ObjectId::from_hex(&reference[..separator])?;
131        let advertised = if self.protocol == Protocol::Smart {
132            parts.next().and_then(|capabilities| {
133                capabilities
134                    .strip_suffix(b"\n")
135                    .unwrap_or(capabilities)
136                    .split(|&byte| byte == b' ')
137                    .find_map(|capability| capability.strip_prefix(b"object-format="))
138            })
139        } else {
140            None
141        };
142        let format = match advertised {
143            Some(b"sha1") => ObjectFormat::Sha1,
144            Some(b"sha256") => ObjectFormat::Sha256,
145            Some(_) => return Err(Error::Unsupported),
146            None => id.format(),
147        };
148        // The first advertised algorithm encodes the refs. Without a capability,
149        // infer it from the first ID (including the empty capabilities marker).
150        let expected = *self.format.get_or_insert(format);
151        if format != expected || id.format() != expected {
152            return Err(Error::InvalidRequest);
153        }
154        Ok(Reference {
155            id,
156            name: core::str::from_utf8(&reference[separator + 1..])
157                .map_err(|_| Error::InvalidRequest)?,
158        })
159    }
160}
161
162impl<'a> Iterator for References<'a> {
163    type Item = Result<Reference<'a>>;
164    fn next(&mut self) -> Option<Self::Item> {
165        if self.failed {
166            return None;
167        }
168        if self.protocol == Protocol::Smart {
169            let result = smart::next_reference(&mut self.bytes, &mut self.format);
170            if !matches!(result, Ok(Some(_))) {
171                self.failed = true;
172            }
173            return result.transpose();
174        }
175        loop {
176            let line = match self.protocol {
177                Protocol::Smart => unreachable!("handled above"),
178                Protocol::Dumb => {
179                    if self.bytes.is_empty() {
180                        return None;
181                    }
182                    let end = self
183                        .bytes
184                        .iter()
185                        .position(|&byte| byte == b'\n')
186                        .unwrap_or(self.bytes.len());
187                    let line = &self.bytes[..end];
188                    self.bytes = self.bytes.get(end + 1..).unwrap_or_default();
189                    line
190                }
191            };
192            let result = self.parse(line);
193            if let Ok(reference) = &result {
194                if self.protocol == Protocol::Smart
195                    && reference.id.is_zero()
196                    && reference.name == "capabilities^{}"
197                {
198                    continue;
199                }
200            }
201            if result.is_err() {
202                self.failed = true;
203            }
204            return Some(result);
205        }
206    }
207}
208
209pub fn discover<'a>(
210    transport: &mut impl Transport,
211    storage: &'a mut [u8],
212) -> Result<Discovery<'a>> {
213    let (status, length) = transport.request(
214        Request {
215            content_type: None,
216            git_protocol: None,
217            method: "GET",
218            path: "info/refs",
219            query: Some("service=git-upload-pack"),
220            body: &[],
221        },
222        storage,
223    )?;
224    if status != 200 {
225        return Err(Error::Backend);
226    }
227    Discovery::parse(storage.get(..length).ok_or(Error::BufferTooSmall)?)
228}
229
230pub fn read_head(
231    transport: &mut impl Transport,
232    discovery: &Discovery<'_>,
233    storage: &mut [u8],
234) -> Result<ObjectId> {
235    if discovery.protocol == Protocol::Smart {
236        return discovery.head();
237    }
238    let (status, length) = transport.request(
239        Request {
240            content_type: None,
241            git_protocol: None,
242            method: "GET",
243            path: "HEAD",
244            query: None,
245            body: &[],
246        },
247        storage,
248    )?;
249    if status != 200 {
250        return Err(Error::Backend);
251    }
252    let bytes = storage.get(..length).ok_or(Error::BufferTooSmall)?;
253    let line = bytes.strip_suffix(b"\n").unwrap_or(bytes);
254    if let Some(name) = line.strip_prefix(b"ref: ") {
255        for reference in discovery.references() {
256            let reference = reference?;
257            if reference.name.as_bytes() == name {
258                return Ok(reference.id);
259            }
260        }
261        Err(Error::ObjectNotFound)
262    } else {
263        let id = ObjectId::from_hex(line)?;
264        if discovery.format.is_some_and(|format| format != id.format()) {
265            return Err(Error::InvalidRequest);
266        }
267        Ok(id)
268    }
269}
270
271/// Ask for a full reachable pack, without thin packs or sideband capabilities.
272pub fn fetch_pack<'a>(
273    transport: &mut impl Transport,
274    want: ObjectId,
275    request_storage: &mut [u8],
276    response_storage: &'a mut [u8],
277) -> Result<&'a [u8]> {
278    fetch_pack_with_options(
279        transport,
280        &[want],
281        FetchOptions::default(),
282        request_storage,
283        response_storage,
284    )
285}
286
287/// Fetch distinct tips in one request. Options must match the advertisement;
288/// all wants and the checksummed response use the same object format.
289pub fn fetch_pack_with_options<'a>(
290    transport: &mut impl Transport,
291    wants: &[ObjectId],
292    options: FetchOptions,
293    request_storage: &mut [u8],
294    response_storage: &'a mut [u8],
295) -> Result<&'a [u8]> {
296    let mut request = SliceWriter::new(request_storage);
297    write_fetch_request(wants, options, &mut request)?;
298    let (status, length) = transport.request(
299        Request {
300            content_type: Some("application/x-git-upload-pack-request"),
301            git_protocol: None,
302            method: "POST",
303            path: "git-upload-pack",
304            query: None,
305            body: request.as_bytes(),
306        },
307        response_storage,
308    )?;
309    if status != 200 {
310        return Err(Error::Backend);
311    }
312    let bytes = response_storage
313        .get(..length)
314        .ok_or(Error::BufferTooSmall)?;
315    read_pack_response_with_format(bytes, wants[0].format())
316}
317
318/// Fetch a loose object's zlib bytes from an ordinary HTTP repository.
319pub fn fetch_loose<'a>(
320    transport: &mut impl Transport,
321    id: ObjectId,
322    response_storage: &'a mut [u8],
323) -> Result<&'a [u8]> {
324    let hex = id.to_hex();
325    let mut path_storage = [0; 73];
326    let mut path = SliceWriter::new(&mut path_storage);
327    path.write_all(b"objects/")?;
328    path.write_all(&hex[..2])?;
329    path.write_all(b"/")?;
330    path.write_all(&hex[2..])?;
331    let path = core::str::from_utf8(path.as_bytes()).map_err(|_| Error::InvalidRequest)?;
332    let (status, length) = transport.request(
333        Request {
334            content_type: None,
335            git_protocol: None,
336            method: "GET",
337            path,
338            query: None,
339            body: &[],
340        },
341        response_storage,
342    )?;
343    if status == 404 {
344        return Err(Error::ObjectNotFound);
345    }
346    if status != 200 {
347        return Err(Error::Backend);
348    }
349    response_storage.get(..length).ok_or(Error::BufferTooSmall)
350}
351
352/// Decode exactly one loose response into caller-owned staging storage.
353/// Storage/limits include the Git header (at most 28 bytes) and body. A borrowed
354/// body is returned only after zlib, canonical header, length and ID validation.
355/// Discard staging bytes on error; concatenated streams/trailing bytes fail.
356pub fn decode_loose<'a>(
357    compressed: &[u8],
358    expected: ObjectId,
359    storage: &'a mut [u8],
360    window: &mut [u8; WINDOW_SIZE],
361    limits: InflateLimits,
362) -> Result<Object<'a>> {
363    let mut decoder = Inflater::new(window, limits);
364    let progress = decoder.update(compressed, storage, true)?;
365    if progress.status == InflateStatus::NeedOutput {
366        return Err(Error::BufferTooSmall);
367    }
368    if progress.status != InflateStatus::Finished || progress.consumed != compressed.len() {
369        return Err(Error::InvalidObject);
370    }
371    Object::from_loose(&storage[..progress.written], expected)
372}
373
374/// Fetch and validate a dumb-HTTP loose object using independent wire and
375/// inflated buffers. The transport remains responsible for HTTP framing.
376pub fn fetch_loose_object<'a>(
377    transport: &mut impl Transport,
378    expected: ObjectId,
379    response_storage: &mut [u8],
380    object_storage: &'a mut [u8],
381    window: &mut [u8; WINDOW_SIZE],
382    limits: InflateLimits,
383) -> Result<Object<'a>> {
384    let compressed = fetch_loose(transport, expected, response_storage)?;
385    decode_loose(compressed, expected, object_storage, window, limits)
386}