git_policy_cli/candidate_reader.rs
1//! What:
2//! One long-lived `git cat-file --batch` process that answers every object read of an invocation.
3//! Why:
4//! Starting Git costs far more than reading one object,
5//! so candidate bytes are read
6//! through a single process whose count does not grow with the number of files.
7//! Blob bytes are remembered by object name:
8//! a name always denotes the same bytes,
9//! so a remembered blob can never become stale.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! // const reader = startObjectReader(git); const bytes = await reader.blob(oid);
14//! ```
15
16/// Import the pure reply reader and the values it produces.
17use super::candidate_batch::{BatchReply, ObjectKind, read_batch_reply};
18/// Import the layer's failure type and its closed list of causes.
19use super::candidate_error::{CandidateError, CandidateFailure};
20/// Starts that ride out a short shortage of processes.
21use super::start_retry::RetryStart;
22/// Import the validated object name.
23use super::candidate_object::ObjectId;
24/// Import the shared child-command builder.
25use super::forwarding::git_command;
26/// The watch that announces a request that takes long and stops the process at the bound.
27use super::child_watch::{ChildWatch, WatchTerms, watched_timeout_text};
28/// What:
29/// `HashMap<K, V>` is a key-to-value table (siblings:
30/// `BTreeMap`,
31/// which keeps keys
32/// sorted,
33/// and `Vec` of pairs).
34/// Why:
35/// Remembered blobs are looked up by object name and never listed in order.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// const blobs = new Map<ObjectId, Uint8Array>();
40/// ```
41use std::collections::HashMap;
42/// `OsString` is owned operating-system text of raw OS bytes (sibling `String` must be UTF-8).
43use std::ffi::OsString;
44/// `BufReader` adds a read buffer to a pipe;
45/// `Write` is the trait that gives pipes `.write_all(..)`.
46use std::io::{BufReader, Write};
47/// `Path` is a borrowed filesystem path of raw OS bytes.
48use std::path::Path;
49/// The child process record,
50/// its two pipes,
51/// and the stream selector.
52use std::process::{Child, ChildStdin, ChildStdout, Stdio};
53/// What:
54/// `Rc<T>` is a shared,
55/// read-only handle to one heap value,
56/// counted so the value
57/// lives until its last handle is gone (siblings:
58/// `Box<T>`,
59/// a single owner,
60/// and
61/// `Arc<T>`,
62/// the thread-safe form).
63/// Why:
64/// The reader keeps a blob while callers also hold it;
65/// copying large files for
66/// each holder would double their memory.
67/// Nothing here crosses threads,
68/// so `Arc`
69/// would add cost for no benefit.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// // Every object reference in TS already behaves like this.
74/// ```
75use std::rc::Rc;
76
77/// What:
78/// The request and reply pipes of a running reader.
79/// Why:
80/// Both are closed together:
81/// after any reply failure the stream can no longer be
82/// trusted,
83/// and closing both ends lets the process stop whatever it was doing.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// type ReaderPipes = { input: Writable; output: Readable };
88/// ```
89struct ReaderPipes {
90 /// Where request lines are written.
91 input: ChildStdin,
92 /// Where replies are read,
93 /// through a buffer so header lines do not cost one read per byte.
94 output: BufReader<ChildStdout>,
95}
96
97/// What:
98/// A running object reader and the blobs it has already read.
99/// `Option<ReaderPipes>` is "open pipes or nothing";
100/// nothing means closed.
101/// `Rc<[u8]>` is a shared read-only byte list.
102/// Why:
103/// One value owns the process for its whole life,
104/// so the process is always
105/// stopped and collected when the value is dropped.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// class ObjectReader { #child: ChildProcess; #pipes?: ReaderPipes; #blobs: Map<ObjectId, Uint8Array> }
110/// ```
111pub struct ObjectReader {
112 /// The `git cat-file --batch` process.
113 child: Child,
114 /// Open pipes,
115 /// or nothing once the reader was closed after a failure.
116 pipes: Option<ReaderPipes>,
117 /// Blob bytes already read,
118 /// by object name.
119 blobs: HashMap<ObjectId, Rc<[u8]>>,
120 /// Announces a request that takes long and stops the process when one crosses the bound.
121 watch: ChildWatch,
122}
123
124/// What:
125/// Build the failure for a reader that can no longer answer.
126/// Why:
127/// The message tells the person how to see Git's own explanation,
128/// because the
129/// reader's error stream is discarded (an unread pipe could stall Git).
130///
131/// In TS you'd write (pseudocode):
132/// ```ts
133/// function readerEnded(detail: string): CandidateError;
134/// ```
135fn reader_ended(detail: &str) -> CandidateError {
136 return CandidateError::new(
137 CandidateFailure::ReaderEnded,
138 format!(
139 "cli-git could not read a Git object: the git cat-file --batch process {detail}. \
140 Run `git cat-file --batch-check` in this repository to see Git's own diagnostic."
141 )
142 .as_str(),
143 );
144}
145
146/// What:
147/// Start the reader for the repository the caller's global options select.
148/// `&[OsString]` borrows the arguments before the subcommand,
149/// unchanged;
150/// `&[(OsString, OsString)]` borrows added environment pairs.
151/// `Result<T, E>` is "a value or a failure".
152/// Why:
153/// Replaying the global prefix makes the reader open the same repository as the
154/// forwarded command.
155/// The overlay may point it at a private object directory,
156/// so
157/// one reader is bound to one overlay for its whole life.
158///
159/// In TS you'd write (pseudocode):
160/// ```ts
161/// function startObjectReader(realGit, globalPrefix, overlay): ObjectReader;
162/// ```
163pub fn start_object_reader(
164 real_git: &Path,
165 global_prefix: &[OsString],
166 overlay: &[(OsString, OsString)],
167) -> Result<ObjectReader, CandidateError> {
168 return start_object_reader_with(real_git, global_prefix, overlay, WatchTerms::from_environment());
169}
170
171/// What:
172/// `start_object_reader` with the notice cadence, the bound and the notice sink of its requests chosen by the caller.
173/// Why:
174/// The production entry reads them from the environment;
175/// tests choose short ones and read the notices from a buffer.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// function startObjectReader(realGit, globalPrefix, overlay, terms): ObjectReader;
180/// ```
181pub fn start_object_reader_with(
182 real_git: &Path,
183 global_prefix: &[OsString],
184 overlay: &[(OsString, OsString)],
185 terms: WatchTerms,
186) -> Result<ObjectReader, CandidateError> {
187 // `.to_vec()` copies the borrowed prefix into an owned, growable argument list.
188 let mut arguments: Vec<OsString> = global_prefix.to_vec();
189 arguments.push(OsString::from("cat-file"));
190 arguments.push(OsString::from("--batch"));
191 // What: `Stdio::piped()` connects a stream to this process; `Stdio::null()` discards it.
192 // `.spawn()` starts the child without waiting; `match` reads its `Result`.
193 // Why: Requests and replies travel over the two pipes. Standard error is discarded
194 // because nothing reads it while replies are awaited, and a full unread pipe
195 // would block Git forever.
196 //
197 // In TS you'd write (pseudocode):
198 // ```ts
199 // const child = spawn(realGit, args, { stdio: ['pipe', 'pipe', 'ignore'] });
200 // ```
201 let started: std::io::Result<Child> = git_command(real_git, arguments.as_slice(), overlay)
202 .stdin(Stdio::piped())
203 .stdout(Stdio::piped())
204 .stderr(Stdio::null())
205 .spawn_retrying();
206 let mut child: Child = match started {
207 Ok(spawned) => spawned,
208 Err(error) => {
209 // `Err(...)` is the failure variant.
210 return Err(CandidateError::new(
211 CandidateFailure::GitNotStarted,
212 format!(
213 "cli-git could not start git cat-file --batch to read Git objects: {error}."
214 )
215 .as_str(),
216 ));
217 }
218 };
219 // What: `.take()` moves each pipe out of the child record, leaving nothing behind.
220 // `let (Some(a), Some(b)) = (x, y) else { ... };` unwraps both or exits.
221 // Why: The reader owns the pipes so it can close them independently of the process record.
222 //
223 // In TS you'd write (pseudocode):
224 // ```ts
225 // const { stdin, stdout } = child;
226 // ```
227 let (Some(input), Some(output)) = (child.stdin.take(), child.stdout.take()) else {
228 // Both streams were requested as pipes, so this is not expected; the child is still collected.
229 let _ = child.kill();
230 let _ = child.wait();
231 return Err(reader_ended("started without its request and reply pipes"));
232 };
233 // `Ok(...)` is the success variant; `Some(...)` is the "present" variant.
234 let watch: ChildWatch = ChildWatch::start("git cat-file --batch", child.id(), terms);
235 return Ok(ObjectReader {
236 child,
237 pipes: Some(ReaderPipes {
238 input,
239 output: BufReader::new(output),
240 }),
241 blobs: HashMap::new(),
242 watch,
243 });
244}
245
246/// What:
247/// `impl ObjectReader { ... }` attaches the read operations,
248/// like class methods.
249/// Why:
250/// Only complete object names and the fixed word `HEAD` are ever written as
251/// requests,
252/// so no caller text can inject a second request line.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// class ObjectReader { blob(oid) {} headCommit() {} }
257/// ```
258impl ObjectReader {
259 /// What:
260 /// Send one request line and read its reply.
261 /// `&mut self` borrows the reader
262 /// for changing:
263 /// its pipes advance and may be closed.
264 /// Why:
265 /// Strict alternation (one request,
266 /// then its whole reply) can never fill a
267 /// pipe in both directions at once.
268 /// Any failure closes the pipes,
269 /// because the
270 /// rest of the stream could be object content that looks like a reply.
271 ///
272 /// In TS you'd write (pseudocode):
273 /// ```ts
274 /// async #request(name: Buffer): Promise<BatchReply>
275 /// ```
276 fn request(&mut self, name: &[u8]) -> Result<BatchReply, CandidateError> {
277 // The watch announces a request that takes long and stops the process when one crosses the bound.
278 self.watch.begin();
279 let reply: Result<BatchReply, CandidateError> = self.exchange(name);
280 self.watch.end();
281 if reply.is_err() && self.watch.expired() {
282 self.pipes = None;
283 return Err(CandidateError::new(
284 CandidateFailure::ReaderEnded,
285 format!(
286 "cli-git could not read a Git object: {}",
287 watched_timeout_text("git cat-file --batch", self.watch.limit())
288 )
289 .as_str(),
290 ));
291 }
292 return reply;
293 }
294
295 /// What:
296 /// One request line and its reply, without the watch.
297 /// Why:
298 /// `request` marks the wait around it.
299 ///
300 /// In TS you'd write (pseudocode):
301 /// ```ts
302 /// async #exchange(name: Buffer): Promise<BatchReply>
303 /// ```
304 fn exchange(&mut self, name: &[u8]) -> Result<BatchReply, CandidateError> {
305 // `.as_mut()` lends the open pipes for changing without taking them out.
306 let Some(pipes) = self.pipes.as_mut() else {
307 return Err(reader_ended("was closed after an earlier failure"));
308 };
309 let mut line: Vec<u8> = name.to_vec();
310 line.push(b'\n');
311 // `if let Err(error) = ...` runs the block only when the write failed.
312 if let Err(error) = pipes.input.write_all(line.as_slice()) {
313 // `None` is the "absent" variant: assigning it drops, and so closes, both pipes.
314 self.pipes = None;
315 return Err(reader_ended(
316 format!("stopped accepting requests ({error})").as_str(),
317 ));
318 }
319 match read_batch_reply(&mut pipes.output, name) {
320 Ok(reply) => return Ok(reply),
321 Err(error) => {
322 self.pipes = None;
323 return Err(error);
324 }
325 }
326 }
327
328 /// What:
329 /// The exact bytes of one blob,
330 /// read once and shared afterwards.
331 /// `Rc::clone(..)` makes another handle to the same bytes without copying them.
332 /// Why:
333 /// The same blob often backs several paths and several policies;
334 /// each further
335 /// use costs neither a request nor a copy.
336 ///
337 /// In TS you'd write (pseudocode):
338 /// ```ts
339 /// async blob(oid: ObjectId): Promise<Uint8Array>
340 /// ```
341 pub fn blob(&mut self, object: &ObjectId) -> Result<Rc<[u8]>, CandidateError> {
342 // `.get(object)` is `Option<&Rc<[u8]>>`: the remembered bytes, if any.
343 if let Some(bytes) = self.blobs.get(object) {
344 return Ok(Rc::clone(bytes));
345 }
346 // A trailing `?` returns the failure to our caller, or unwraps the reply.
347 let reply: BatchReply = self.request(object.as_str().as_bytes())?;
348 // What: `match` on the reply; `{ kind, bytes, .. }` binds two fields and ignores the rest.
349 // Why: Only a blob is file content; a missing or differently typed object is a
350 // failure the caller must not read as empty content.
351 //
352 // In TS you'd write (pseudocode):
353 // ```ts
354 // if (reply.kind === 'missing') throw ...; if (reply.type !== 'blob') throw ...;
355 // ```
356 match reply {
357 BatchReply::Missing => {
358 return Err(CandidateError::new(
359 CandidateFailure::ObjectMissing,
360 format!(
361 "cli-git could not read candidate content: Git has no object {}. \
362 Run `git fsck` to check the object store.",
363 object.as_str()
364 )
365 .as_str(),
366 ));
367 }
368 BatchReply::Found { kind, bytes, .. } => {
369 if kind != ObjectKind::Blob {
370 return Err(CandidateError::new(
371 CandidateFailure::ObjectKindUnexpected,
372 format!(
373 "cli-git could not read candidate content: object {} is not a blob.",
374 object.as_str()
375 )
376 .as_str(),
377 ));
378 }
379 // `Rc::from(bytes)` moves the owned bytes into shared storage.
380 let shared: Rc<[u8]> = Rc::from(bytes);
381 // `.clone()` copies the name so the table owns its key.
382 self.blobs.insert(object.clone(), Rc::clone(&shared));
383 return Ok(shared);
384 }
385 }
386 }
387
388 /// What:
389 /// The commit `HEAD` names,
390 /// or nothing when the branch has no commit yet.
391 /// `Option<ObjectId>` is "a commit name or nothing".
392 /// Why:
393 /// The staged listing compares against this commit.
394 /// Asking the running
395 /// reader costs no further process.
396 ///
397 /// In TS you'd write (pseudocode):
398 /// ```ts
399 /// async headCommit(): Promise<ObjectId | undefined>
400 /// ```
401 pub fn head_commit(&mut self) -> Result<Option<ObjectId>, CandidateError> {
402 match self.request(b"HEAD")? {
403 BatchReply::Missing => return Ok(None),
404 BatchReply::Found { object, kind, .. } => {
405 if kind != ObjectKind::Commit {
406 return Err(CandidateError::new(
407 CandidateFailure::ObjectKindUnexpected,
408 "cli-git could not list candidate files: HEAD does not name a commit.",
409 ));
410 }
411 return Ok(Some(object));
412 }
413 }
414 }
415}
416
417/// What:
418/// `impl Drop` runs when the value goes out of scope,
419/// like a `finally` block
420/// attached to the value itself.
421/// Why:
422/// A child that is never waited for stays in the process table as a zombie,
423/// which counts against the process limit of long sessions and containers.
424///
425/// In TS you'd write (pseudocode):
426/// ```ts
427/// [Symbol.dispose]() { this.#pipes = undefined; this.#child.kill(); }
428/// ```
429impl Drop for ObjectReader {
430 /// Close both pipes,
431 /// which ends the process,
432 /// then collect it.
433 fn drop(&mut self) {
434 self.pipes = None;
435 // `let _ =` discards the exit status: every reply was already checked on its own.
436 let _ = self.child.wait();
437 }
438}
439
440/// Process controls stay out of the release executable.
441#[cfg(test)]
442#[path = "candidate_reader_tests.rs"]
443mod tests;
444
445/// Stand-in programs that cut replies short,
446/// mismatch them,
447/// or never answer.
448#[cfg(test)]
449#[path = "candidate_reader_failure_tests.rs"]
450mod failure_tests;