Skip to main content

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, 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 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(reader_subject().as_str(), 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(reader_subject().as_str(), 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;