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