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/// 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;