Skip to main content

git_policy_cli/
candidate_batch.rs

1//! What:
2//!  Read one `git cat-file --batch` reply from a byte stream,
3//!  with no process involved.
4//! Why:
5//!  Object bytes are untrusted input that may contain anything,
6//!  including text that
7//!      looks like a reply header.
8//!  The reply is therefore framed only by the header's
9//!      declared size,
10//!  never by searching content,
11//!  and every malformed shape is a
12//!      typed failure instead of a guess.
13//!
14//! Git 2.56.0 (`Documentation/git-cat-file.adoc`,
15//!  "BATCH OUTPUT") prints,
16//!  per request line:
17//! `<oid> SP <type> SP <size> LF <contents> LF`,
18//!  or `<request> SP missing LF`.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! // const header = await readLine(stream); const body = await readExactly(stream, size);
23//! ```
24
25/// Import the layer's failure type and its closed list of causes.
26use super::candidate_error::{CandidateError, CandidateFailure};
27/// Import the validated object name and its parser.
28use super::candidate_object::{ObjectId, parse_object_id};
29/// What:
30///  `BufRead` is the trait (interface) of buffered byte sources:
31///  it adds
32///       `.read_until(..)`.
33///  `Read` adds `.take(..)`,
34///  `.read_to_end(..)` and `.read_exact(..)`.
35/// Why:
36///   The same function reads a real child's pipe and an in-memory test or fuzz buffer.
37///
38/// In TS you'd write (pseudocode):
39/// ```ts
40/// import type { Readable } from 'node:stream';
41/// ```
42use std::io::{BufRead, Read};
43
44/// What:
45///  The longest reply header accepted,
46///  in bytes,
47///  excluding its line feed.
48///       `usize` is the unsigned integer every length uses (siblings `u32`,
49///  `u64`).
50/// Why:
51///   A real header is at most 64 name digits,
52///  a type word and a decimal size
53///       (under 100 bytes).
54///  Without a bound,
55///  a stream that never sends a line feed
56///       would be buffered without limit.
57///  `usize` matches `.len()`.
58///
59/// In TS you'd write (pseudocode):
60/// ```ts
61/// const MAX_BATCH_HEADER_BYTES = 128;
62/// ```
63pub const MAX_BATCH_HEADER_BYTES: usize = 128;
64
65/// What:
66///  The four object types Git stores.
67/// Why:
68///   A candidate's bytes must come from a blob,
69///  and `HEAD` must resolve to a commit;
70///       the caller checks the type instead of assuming it.
71///
72/// In TS you'd write (pseudocode):
73/// ```ts
74/// type ObjectKind = 'blob' | 'tree' | 'commit' | 'tag';
75/// ```
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub enum ObjectKind {
78    /// File content,
79    ///  or a symbolic link's target.
80    Blob,
81    /// A directory listing.
82    Tree,
83    /// A commit.
84    Commit,
85    /// An annotated tag.
86    Tag,
87}
88
89/// What:
90///  What Git answered for one request line.
91///       `Vec<u8>` is an owned byte list (sibling `String` would require UTF-8).
92/// Why:
93///   File content is arbitrary bytes and must be kept exactly;
94///  a missing object is
95///       an ordinary answer the caller decides about.
96///
97/// In TS you'd write (pseudocode):
98/// ```ts
99/// type BatchReply = { kind: 'found'; object: ObjectId; type: ObjectKind; bytes: Buffer } | { kind: 'missing' };
100/// ```
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub enum BatchReply {
103    /// The object exists;
104    ///  these are its exact bytes.
105    Found {
106        /// The full object name Git resolved the request to.
107        object: ObjectId,
108        /// The stored object type.
109        kind: ObjectKind,
110        /// Exactly the declared number of content bytes.
111        bytes: Vec<u8>,
112    },
113    /// Git could not resolve the request to an object.
114    Missing,
115}
116
117/// What:
118///  Build a failure whose message names the reply defect.
119///       `format!` builds owned text;
120///  `.as_str()` lends it to the constructor.
121/// Why:
122///   Every defect means the stream can no longer be trusted,
123///  and the message says
124///       so without quoting reply bytes,
125///  which may be file content.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// function replyFailure(failure: CandidateFailure, detail: string): CandidateError;
130/// ```
131fn reply_failure(failure: CandidateFailure, detail: &str) -> CandidateError {
132    return CandidateError::new(
133        failure,
134        format!(
135            "cli-git could not read a Git object: git cat-file --batch {detail}. \
136             No candidate content was accepted from this reply."
137        )
138        .as_str(),
139    );
140}
141
142/// What:
143///  Read the header line,
144///  without its line feed,
145///  refusing an overlong one.
146///       `&mut dyn BufRead` lends any buffered source for reading;
147///  `dyn` means the
148///       concrete type is decided at run time,
149///  like a TS interface parameter.
150///       `Result<T, E>` is "a value or a failure".
151/// Why:
152///   The header is the only delimiter-searched part of a reply,
153///  so it is the only
154///       part that needs a length bound.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// async function readHeaderLine(stream: Readable): Promise<Buffer>;
159/// ```
160fn read_header_line(stream: &mut dyn BufRead) -> Result<Vec<u8>, CandidateError> {
161    // `Vec::new()` is an empty growable byte list; `mut` lets the read append to it.
162    let mut line: Vec<u8> = Vec::new();
163    // What: `.take(n)` stops after `n` bytes; `as u64` widens the length losslessly.
164    //       `.read_until(b'\n', &mut line)` appends bytes up to and including a line feed.
165    // Why:  One byte past the bound is enough to tell "too long" from "ended early".
166    //
167    // In TS you'd write (pseudocode):
168    // ```ts
169    // const line = await readUntil(stream, '\n', MAX_BATCH_HEADER_BYTES + 1);
170    // ```
171    let limit: u64 = MAX_BATCH_HEADER_BYTES as u64 + 1;
172    // What: `match` on the read's `Result`: `Ok(_)` ignores the byte count, `Err(error)`
173    //       binds the input/output failure.
174    // Why:  A failed read means the process or pipe is gone.
175    //
176    // In TS you'd write (pseudocode):
177    // ```ts
178    // try { await read(); } catch (error) { throw readerEnded(error); }
179    // ```
180    match stream.take(limit).read_until(b'\n', &mut line) {
181        Ok(_) => {}
182        Err(error) => {
183            // `Err(...)` is the failure variant.
184            return Err(reply_failure(
185                CandidateFailure::ReaderEnded,
186                format!("could not be read ({error})").as_str(),
187            ));
188        }
189    }
190    if line.is_empty() {
191        return Err(reply_failure(
192            CandidateFailure::ReaderEnded,
193            "ended before answering a request",
194        ));
195    }
196    // What: `.last()` is `Option<&u8>`, the final byte if any; `Some(&b'\n')` matches
197    //       only a line feed.
198    // Why:  A complete header ends with exactly one line feed. Without one, either the
199    //       bound was reached (more bytes than any header) or the stream ended early.
200    //
201    // In TS you'd write (pseudocode):
202    // ```ts
203    // if (line.at(-1) !== 0x0a) throw ...;
204    // ```
205    if line.last() != Some(&b'\n') {
206        if line.len() > MAX_BATCH_HEADER_BYTES {
207            return Err(reply_failure(
208                CandidateFailure::ReplyMalformed,
209                "sent a header longer than any object header",
210            ));
211        }
212        return Err(reply_failure(
213            CandidateFailure::ReplyTruncated,
214            "ended in the middle of a header",
215        ));
216    }
217    // `.pop()` removes the line feed, which is not part of the header.
218    line.pop();
219    // `Ok(...)` is the success variant.
220    return Ok(line);
221}
222
223/// What:
224///  Parse a decimal object size.
225///  `Option<usize>` is "a size or nothing".
226/// Why:
227///   The size decides how many content bytes are consumed,
228///  so only Git's canonical
229///       form is accepted:
230///  digits only,
231///  no sign,
232///  no leading zero,
233///  and no overflow.
234///       The standard parser does the arithmetic,
235///  so no hand-written digit loop can
236///       be off by one;
237///  it alone would also accept a leading `+`,
238///  which the digit
239///       check refuses first.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// function parseSize(digits: string): number | undefined;
244/// ```
245fn parse_size(digits: &[u8]) -> Option<usize> {
246    if digits.len() > 1 && digits[0] == b'0' {
247        // `None` is the "absent" variant.
248        return None;
249    }
250    // `.iter().all(u8::is_ascii_digit)` asks the standard digit test about every byte.
251    if !digits.iter().all(u8::is_ascii_digit) {
252        return None;
253    }
254    // What: `std::str::from_utf8(..)` views the bytes as text; `.ok()?` returns `None`
255    //       when that fails. `.parse::<usize>()` reads a number; `.ok()` turns a failure
256    //       (empty text, or a value too large for `usize`) into `None`.
257    // Why:  An empty field and an overflowing one are refused by the same parser that
258    //       accepts every canonical size.
259    //
260    // In TS you'd write (pseudocode):
261    // ```ts
262    // const size = Number(text); return Number.isSafeInteger(size) ? size : undefined;
263    // ```
264    let text: &str = std::str::from_utf8(digits).ok()?;
265    return text.parse::<usize>().ok();
266}
267
268/// Map Git's object type word to a kind;
269///  any other word is not a header this layer accepts.
270fn object_kind(word: &[u8]) -> Option<ObjectKind> {
271    if word == b"blob" {
272        return Some(ObjectKind::Blob);
273    }
274    if word == b"tree" {
275        return Some(ObjectKind::Tree);
276    }
277    if word == b"commit" {
278        return Some(ObjectKind::Commit);
279    }
280    if word == b"tag" {
281        return Some(ObjectKind::Tag);
282    }
283    return None;
284}
285
286/// Named predicate for splitting a header into fields;
287///  `&u8` borrows one byte.
288fn is_space(byte: &u8) -> bool {
289    return *byte == b' ';
290}
291
292/// What:
293///  Parse `<oid> SP <type> SP <size>`.
294///  The result is a tuple,
295///  three values returned together.
296/// Why:
297///   Exactly three fields in Git's canonical spelling is the only shape whose size
298///       this layer will trust to frame the content.
299///
300/// In TS you'd write (pseudocode):
301/// ```ts
302/// function parseFoundHeader(header: Buffer): [ObjectId, ObjectKind, number] | undefined;
303/// ```
304fn parse_found_header(header: &[u8]) -> Option<(ObjectId, ObjectKind, usize)> {
305    // What: `.split(is_space)` cuts at every space; `.collect()` gathers the borrowed pieces.
306    // Why:  Two consecutive spaces produce an empty field, which no parser below accepts.
307    //
308    // In TS you'd write (pseudocode):
309    // ```ts
310    // const fields = header.toString('latin1').split(' ');
311    // ```
312    let fields: Vec<&[u8]> = header.split(is_space).collect();
313    if fields.len() != 3 {
314        return None;
315    }
316    let object: ObjectId = parse_object_id(fields[0])?;
317    let kind: ObjectKind = object_kind(fields[1])?;
318    let size: usize = parse_size(fields[2])?;
319    return Some((object, kind, size));
320}
321
322/// What:
323///  Read exactly `size` content bytes and the line feed that follows them.
324/// Why:
325///   `.take(size).read_to_end(..)` grows the buffer only as bytes really arrive,
326///  so
327///       a header that lies about a huge size produces a truncated-reply failure
328///       instead of one huge allocation.
329///
330/// In TS you'd write (pseudocode):
331/// ```ts
332/// async function readContent(stream: Readable, size: number): Promise<Buffer>;
333/// ```
334fn read_content(stream: &mut dyn BufRead, size: usize) -> Result<Vec<u8>, CandidateError> {
335    let mut bytes: Vec<u8> = Vec::new();
336    // `&mut *stream` re-lends the source, so `stream` stays usable for the terminator below.
337    if let Err(error) = (&mut *stream).take(size as u64).read_to_end(&mut bytes) {
338        return Err(reply_failure(
339            CandidateFailure::ReaderEnded,
340            format!("could not be read ({error})").as_str(),
341        ));
342    }
343    if bytes.len() != size {
344        return Err(reply_failure(
345            CandidateFailure::ReplyTruncated,
346            "ended before the declared object size",
347        ));
348    }
349    // What: `[0_u8; 1]` is a one-byte buffer; `.read_exact(..)` fills it or fails.
350    // Why:  Git ends every object with one line feed that is not content. Its absence
351    //       means the declared size was wrong and the stream is out of step.
352    //
353    // In TS you'd write (pseudocode):
354    // ```ts
355    // const terminator = await readExactly(stream, 1);
356    // ```
357    let mut terminator: [u8; 1] = [0_u8; 1];
358    if stream.read_exact(&mut terminator).is_err() {
359        return Err(reply_failure(
360            CandidateFailure::ReplyTruncated,
361            "ended before the line feed that closes an object",
362        ));
363    }
364    if terminator[0] != b'\n' {
365        return Err(reply_failure(
366            CandidateFailure::ReplyMalformed,
367            "sent content longer than the declared object size",
368        ));
369    }
370    return Ok(bytes);
371}
372
373/// What:
374///  Read one complete reply to `request` from `stream`.
375///       `request` is the exact request line that was sent,
376///  without its line feed.
377/// Why:
378///   Git echoes the request in a `missing` reply and prints the resolved name in a
379///       found reply.
380///  When the request is itself a complete object name,
381///  a found reply
382///       naming any other object means requests and replies are out of step,
383///  and its
384///       bytes are refused rather than attributed to the wrong object.
385///
386/// In TS you'd write (pseudocode):
387/// ```ts
388/// async function readBatchReply(stream: Readable, request: Buffer): Promise<BatchReply>;
389/// ```
390pub fn read_batch_reply(
391    stream: &mut dyn BufRead,
392    request: &[u8],
393) -> Result<BatchReply, CandidateError> {
394    // A trailing `?` returns the failure to our caller, or unwraps the header.
395    let header: Vec<u8> = read_header_line(stream)?;
396    // What: `.strip_suffix(..)` is `Some(prefix)` when the header ends with ` missing`.
397    // Why:  Only the echoed request followed by that word is the missing form; a found
398    //       header can never end with it because its last field is a number.
399    //
400    // In TS you'd write (pseudocode):
401    // ```ts
402    // if (header.equals(Buffer.concat([request, Buffer.from(' missing')]))) return { kind: 'missing' };
403    // ```
404    if header.strip_suffix(b" missing") == Some(request) {
405        return Ok(BatchReply::Missing);
406    }
407    // `let Some(..) = .. else { .. }` unwraps the parsed fields or leaves with a failure.
408    let Some((object, kind, size)) = parse_found_header(header.as_slice()) else {
409        return Err(reply_failure(
410            CandidateFailure::ReplyMalformed,
411            "sent a header that is neither an object header nor a missing notice",
412        ));
413    };
414    // What: `requested` is the request as an object name, or nothing when it was a word
415    //       such as `HEAD`; `.as_ref()` views it as `Option<&ObjectId>` for comparing.
416    // Why:  Only a request that is itself a complete name pins which object must answer.
417    //
418    // In TS you'd write (pseudocode):
419    // ```ts
420    // if (requested !== undefined && requested !== object) throw mismatched();
421    // ```
422    let requested: Option<ObjectId> = parse_object_id(request);
423    if requested.is_some() && requested.as_ref() != Some(&object) {
424        return Err(reply_failure(
425            CandidateFailure::ReplyMismatched,
426            "answered with a different object than the one requested",
427        ));
428    }
429    let bytes: Vec<u8> = read_content(stream, size)?;
430    return Ok(BatchReply::Found {
431        object,
432        kind,
433        bytes,
434    });
435}
436
437/// Framing controls stay out of the release executable.
438#[cfg(test)]
439#[path = "candidate_batch_tests.rs"]
440mod tests;
441
442/// A byte source that reports its end and later yields more bytes.
443#[cfg(test)]
444#[path = "candidate_batch_resume_tests.rs"]
445mod resume_tests;