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