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