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