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;