Skip to main content

forge_agent/env/git_diff/
file.rs

1//! One file's whole content at a revision, as its lines.
2//!
3//! The review page draws a diff's bounded hunks, so the lines BETWEEN them
4//! are exactly what its record does not carry; expanding a gap needs the
5//! file itself, read at the revision the scope's diff describes. `git show
6//! <rev>:<path>` is that read - the revision is a commit's own tree for a
7//! commit scope, and the branch tip for the all-changes one.
8//!
9//! **The read is bounded by the caller's ceiling and never by the file.**
10//! Only a blob is a file (a gitlink's size is the commit line `git show`
11//! prints for it), the size comes next (`cat-file -s`, a handful of bytes),
12//! and the content is read through a child killed at the ceiling - so a
13//! multi-gigabyte blob costs the ceiling's allocation and not its own, and
14//! the content never goes through the layer's own subprocess bound
15//! (`STDOUT_SIZE_CAP`, below the shipped ceiling), which is what keeps a
16//! file between that bound and the ceiling answering a flagged short read
17//! rather than a refusal. The LINES are bounded too ([`MAX_READ_LINES`]):
18//! a file of very short lines meets that long before either byte bound, and
19//! the answer's own JSON is the other half of what a read costs.
20//!
21//! **A revision off the wire is resolved before it reaches git.** `git show`
22//! reads a `--output=<file>`-shaped revision as an option of its own rather
23//! than as a revision, so an unvalidated one writes files wherever the
24//! caller says; [`resolve_commit`] is the one gate, and what comes back is a
25//! commit id - hex, never a dash - which every argv below interpolates.
26
27use std::path::Path;
28use std::time::Duration;
29
30use tokio::io::AsyncReadExt as _;
31
32use super::{COMMAND_TIMEOUT, GitOutput, run_git_within};
33
34/// The most lines one read carries, whatever ceiling the caller hands in: a
35/// file of very short lines meets this long before the byte ceiling, and
36/// every line past it is one the answer's own JSON would pay for. The
37/// response is the smaller of the two bounds, flagged whenever either bites.
38pub const MAX_READ_LINES: usize = 10_000;
39
40/// One file's content at a revision, bounded by the caller's ceiling.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FileLines {
43    /// The file's lines, without their terminators, in file order.
44    pub lines: Vec<String>,
45    /// The ceiling or the line cap cut the read short: what is below the
46    /// last line was not carried.
47    pub truncated: bool,
48}
49
50/// Why a file's content could not be read at a revision, per child:
51///
52/// - The revision gate answers [`Self::Missing`] when `rev` names no commit.
53/// - The type and size probes answer [`Self::Missing`] when their child
54///   fails - git refuses a revision or a path it does not carry the same way
55///   it refuses a protocol problem, and neither probe can tell those apart -
56///   and when the path is not a BLOB (a gitlink or a tree is not a file, and
57///   `git show` formats one as something whose size is not its content's).
58/// - The content read answers [`Self::Unreadable`] when its child could not
59///   start, the budget cut it off, or it exited non-zero.
60#[derive(Debug, thiserror::Error)]
61pub enum FileReadRefusal {
62    #[error("no file at {path} in {rev}")]
63    Missing { rev: String, path: String },
64    #[error("the file could not be read: {path} at {rev} ({why})")]
65    Unreadable { rev: String, path: String, why: String },
66}
67
68/// The commit `rev` names, as its full id, or `None` when it names nothing
69/// git can read.
70///
71/// **This is where an untrusted revision is made safe.** `--end-of-options`
72/// stops git reading an option-shaped string as an option of its own, and
73/// the answer is checked to be a commit id before anything else uses it -
74/// so every argv built from it is a revision and cannot be a flag.
75pub async fn resolve_commit(cwd: &Path, rev: &str, budget: Duration) -> Option<String> {
76    let spec = format!("{rev}^{{commit}}");
77    match run_git_within(cwd, &["rev-parse", "--verify", "--end-of-options", &spec], budget).await {
78        GitOutput::Ok(raw) => {
79            let id = raw.trim();
80            // A full id, and nothing else: an option-shaped or otherwise
81            // unusual answer is not a commit to interpolate into an argv.
82            (id.len() >= 40 && id.bytes().all(|byte| byte.is_ascii_hexdigit()))
83                .then(|| id.to_owned())
84        }
85        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => None,
86    }
87}
88
89/// The commit `base` and `HEAD` forked at, as a validated commit id.
90///
91/// **The branch-ahead diff is a three-dot diff**, so its old side is
92/// numbered in the merge-base tree, not at `base`'s current tip: a base that
93/// has advanced since the fork would otherwise shift every old-side line.
94pub async fn merge_base(cwd: &Path, base: &str) -> Option<String> {
95    merge_base_within(cwd, base, COMMAND_TIMEOUT).await
96}
97
98/// [`merge_base`] with its command budget spelled. `base` goes through
99/// [`resolve_commit`] first, so only a commit id reaches the argv.
100pub async fn merge_base_within(cwd: &Path, base: &str, budget: Duration) -> Option<String> {
101    let base = resolve_commit(cwd, base, budget).await?;
102    match run_git_within(cwd, &["merge-base", &base, "HEAD"], budget).await {
103        GitOutput::Ok(raw) => {
104            let id = raw.trim();
105            (id.len() >= 40 && id.bytes().all(|byte| byte.is_ascii_hexdigit()))
106                .then(|| id.to_owned())
107        }
108        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => None,
109    }
110}
111
112/// Read `path`'s content in `rev`, as its lines, up to `ceiling_bytes`.
113pub async fn read_file_lines(
114    cwd: &Path,
115    rev: &str,
116    path: &str,
117    ceiling_bytes: u64,
118) -> Result<FileLines, FileReadRefusal> {
119    read_file_lines_within(cwd, rev, path, ceiling_bytes, COMMAND_TIMEOUT).await
120}
121
122/// [`read_file_lines`] with its command budget spelled; see the module's
123/// sibling readers for why the budget bounds the machine, not git.
124pub async fn read_file_lines_within(
125    cwd: &Path,
126    rev: &str,
127    path: &str,
128    ceiling_bytes: u64,
129    budget: Duration,
130) -> Result<FileLines, FileReadRefusal> {
131    let Some(commit) = resolve_commit(cwd, rev, budget).await else {
132        return Err(FileReadRefusal::Missing { rev: rev.to_owned(), path: path.to_owned() });
133    };
134    let spec = format!("{commit}:{path}");
135    let ceiling = usize::try_from(ceiling_bytes).unwrap_or(usize::MAX);
136    // **Only a blob is a file.** A gitlink or a tree at the path answers a
137    // size that is not its content's - a gitlink's is the commit line git
138    // prints for it, a tree's is the listing's - so the type is what says
139    // whether a size, and a read, mean anything here.
140    if !is_blob(cwd, &spec, budget).await {
141        return Err(FileReadRefusal::Missing { rev: rev.to_owned(), path: path.to_owned() });
142    }
143    // **The size is known before a line is read**, so a file the ceiling
144    // cannot hold is never materialised to be refused.
145    let Some(size) = file_size(cwd, &spec, budget).await else {
146        return Err(FileReadRefusal::Missing { rev: rev.to_owned(), path: path.to_owned() });
147    };
148    // **The content never goes through the layer's own reader.** It carries
149    // an output bound of its own ([`super::STDOUT_SIZE_CAP`], below the
150    // shipped ceiling), and a file between the two would answer that bound's
151    // refusal rather than the file; this read takes at most the ceiling and
152    // nothing else.
153    let Some(capped) = show_capped(cwd, &spec, ceiling, budget).await else {
154        return Err(FileReadRefusal::Unreadable {
155            rev: rev.to_owned(),
156            path: path.to_owned(),
157            why: "the read could not be taken".to_owned(),
158        });
159    };
160    // The probe is what makes `truncated` certain rather than a guess: a
161    // file inside the ceiling is answered whole, whatever the read's take
162    // was bounded by.
163    if size <= ceiling_bytes {
164        return Ok(capped_lines(&String::from_utf8_lossy(&capped), false));
165    }
166    Ok(capped_lines(&String::from_utf8_lossy(&capped), true))
167}
168
169/// Whether the object at `spec` is a blob - the only kind this read calls a
170/// file.
171async fn is_blob(cwd: &Path, spec: &str, budget: Duration) -> bool {
172    match run_git_within(cwd, &["cat-file", "-t", "--end-of-options", spec], budget).await {
173        GitOutput::Ok(raw) => raw.trim() == "blob",
174        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => false,
175    }
176}
177
178/// The blob's size in bytes, or `None` when git cannot read it.
179async fn file_size(cwd: &Path, spec: &str, budget: Duration) -> Option<u64> {
180    match run_git_within(cwd, &["cat-file", "-s", "--end-of-options", spec], budget).await {
181        GitOutput::Ok(raw) => raw.trim().parse().ok(),
182        GitOutput::Empty | GitOutput::Failed | GitOutput::Oversize => None,
183    }
184}
185
186/// `git show <spec>`, at most `ceiling` bytes of its stdout and never a byte
187/// more: the child is killed the moment the ceiling is reached, so what this
188/// allocates is the ceiling's, not the file's. `None` when the child could
189/// not start, the budget cut the read off, or it ended in failure - a `git
190/// show` that failed after the size probe answered is a read that did not
191/// happen, never an empty file.
192async fn show_capped(cwd: &Path, spec: &str, ceiling: usize, budget: Duration) -> Option<Vec<u8>> {
193    let mut command = super::super::git_command::tokio_git_command(cwd);
194    command
195        .arg("-C")
196        .arg(cwd)
197        .args(["show", "--end-of-options", spec])
198        .stdin(std::process::Stdio::null())
199        .stdout(std::process::Stdio::piped())
200        .stderr(std::process::Stdio::null())
201        .kill_on_drop(true);
202    let read = async {
203        let mut child = command.spawn().ok()?;
204        let out = child.stdout.take()?;
205        let mut carried = Vec::with_capacity(ceiling.min(64 * 1024));
206        out.take(ceiling as u64).read_to_end(&mut carried).await.ok()?;
207        // A read that ended before the take did is the child's own end, so
208        // its exit is the read's verdict; one that filled the take was cut
209        // off on purpose and the child is killed there.
210        if carried.len() < ceiling {
211            let status = child.wait().await.ok()?;
212            if !status.success() {
213                return None;
214            }
215        } else {
216            let _ = child.kill().await;
217        }
218        Some(carried)
219    };
220    tokio::time::timeout(budget, read).await.ok().flatten()
221}
222
223/// The file's lines, cut to the counts a frame can afford.
224///
225/// **Two bounds, and a frame pays for both.** The byte ceiling bounds the
226/// content, and this bounds the LINES: a file of very short lines reaches
227/// ten thousand long before a byte ceiling, and ten million `String`s is a
228/// server-side allocation and a JSON answer nobody asked for. A line the
229/// byte bound cuts in half is dropped rather than carried in part, so every
230/// line answered is a line of the file.
231fn capped_lines(raw: &str, cut_by_bytes: bool) -> FileLines {
232    let mut lines: Vec<String> = raw.lines().map(str::to_owned).collect();
233    let mut truncated = cut_by_bytes;
234    if truncated && !raw.ends_with('\n') {
235        lines.pop();
236    }
237    if lines.len() > MAX_READ_LINES {
238        lines.truncate(MAX_READ_LINES);
239        truncated = true;
240    }
241    FileLines { lines, truncated }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::env::git_diff::TEST_GIT_BUDGET;
248    use std::process::Command as StdCommand;
249
250    fn repo(path: &Path) {
251        let git = |args: &[&str]| {
252            let out = StdCommand::new("git")
253                .env_remove("GIT_DIR")
254                .env_remove("GIT_WORK_TREE")
255                .env_remove("GIT_COMMON_DIR")
256                .arg("-C")
257                .arg(path)
258                .args(args)
259                .output()
260                .expect("run git");
261            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
262        };
263        std::fs::create_dir_all(path).expect("the fixture's directory");
264        git(&["init", "-q"]);
265        git(&["symbolic-ref", "HEAD", "refs/heads/main"]);
266        git(&["config", "user.email", "fixture@example.test"]);
267        git(&["config", "user.name", "Fixture"]);
268        git(&["config", "commit.gpgsign", "false"]);
269        std::fs::write(path.join("small.txt"), "alpha\nbeta\ngamma\n").expect("write");
270        git(&["add", "-A"]);
271        git(&["commit", "-qm", "base"]);
272    }
273
274    fn head_of(path: &Path) -> String {
275        let out = StdCommand::new("git")
276            .arg("-C")
277            .arg(path)
278            .args(["rev-parse", "HEAD"])
279            .output()
280            .expect("rev-parse");
281        String::from_utf8_lossy(&out.stdout).trim().to_owned()
282    }
283
284    /// The read answers the file at the revision asked for, and a revision
285    /// it does not carry is Missing rather than an empty answer.
286    #[tokio::test]
287    async fn reads_a_files_lines_at_a_revision() {
288        let dir = tempfile::tempdir().expect("a directory");
289        repo(dir.path());
290        let head = head_of(dir.path());
291
292        let read =
293            read_file_lines_within(dir.path(), &head, "small.txt", u64::MAX, TEST_GIT_BUDGET)
294                .await
295                .expect("the file reads");
296        assert_eq!(read.lines, vec!["alpha", "beta", "gamma"], "the lines, in file order");
297        assert!(!read.truncated, "and nothing withheld");
298
299        let missing =
300            read_file_lines_within(dir.path(), &head, "never.txt", u64::MAX, TEST_GIT_BUDGET).await;
301        assert!(
302            matches!(missing, Err(FileReadRefusal::Missing { .. })),
303            "a path the revision does not carry is missing, not empty: {missing:?}",
304        );
305    }
306
307    /// **A revision off the wire is never handed to git as an option.**
308    /// `git show --output=<file>:path` writes the blob to `<file>` and runs
309    /// at exit 0, so an unvalidated revision is a file write wherever the
310    /// caller points it. The resolution is the gate; this is the pin, and
311    /// the same shape is refused by the commit scan beside it.
312    #[tokio::test]
313    async fn an_option_shaped_revision_writes_nothing() {
314        let dir = tempfile::tempdir().expect("a directory");
315        repo(dir.path());
316        let target = dir.path().join("written-by-git");
317        let shaped = format!("--output={}", target.display());
318
319        let read =
320            read_file_lines_within(dir.path(), &shaped, "small.txt", u64::MAX, TEST_GIT_BUDGET)
321                .await;
322        assert!(
323            matches!(read, Err(FileReadRefusal::Missing { .. })),
324            "an option-shaped revision resolves to no commit: {read:?}",
325        );
326        assert!(!target.exists(), "and git was never asked to write a file");
327        assert!(
328            resolve_commit(dir.path(), &shaped, TEST_GIT_BUDGET).await.is_none(),
329            "the resolver refuses it outright",
330        );
331    }
332
333    /// **The ceiling answers a short read rather than a refusal.** The size
334    /// is known first, so a file the ceiling cannot hold is read to the
335    /// ceiling and flagged - a line the ceiling cuts in half is dropped, so
336    /// every line answered is a line of the file.
337    #[tokio::test]
338    async fn the_ceiling_carries_whole_lines_and_says_what_it_dropped() {
339        let dir = tempfile::tempdir().expect("a directory");
340        repo(dir.path());
341        let head = head_of(dir.path());
342
343        // `alpha\n` is six bytes: the second line does not fit.
344        let cut = read_file_lines_within(dir.path(), &head, "small.txt", 6, TEST_GIT_BUDGET)
345            .await
346            .expect("the read answers");
347        assert_eq!(cut.lines, vec!["alpha"], "the whole lines the ceiling reaches");
348        assert!(cut.truncated, "and the read says it stopped short");
349
350        let whole = read_file_lines_within(dir.path(), &head, "small.txt", 18, TEST_GIT_BUDGET)
351            .await
352            .expect("the read answers");
353        assert_eq!(whole.lines, vec!["alpha", "beta", "gamma"], "at its own size: everything");
354        assert!(!whole.truncated, "and nothing withheld");
355
356        // An empty file is a legitimate empty answer, not a refusal.
357        std::fs::write(dir.path().join("empty.txt"), "").expect("write");
358        let out = StdCommand::new("git")
359            .arg("-C")
360            .arg(dir.path())
361            .args(["add", "empty.txt"])
362            .output()
363            .expect("add");
364        assert!(out.status.success());
365        let out = StdCommand::new("git")
366            .arg("-C")
367            .arg(dir.path())
368            .args(["commit", "-qm", "empty"])
369            .output()
370            .expect("commit");
371        assert!(out.status.success());
372        let empty_head = head_of(dir.path());
373        let empty =
374            read_file_lines_within(dir.path(), &empty_head, "empty.txt", u64::MAX, TEST_GIT_BUDGET)
375                .await
376                .expect("an empty file reads");
377        assert!(empty.lines.is_empty());
378        assert!(!empty.truncated);
379    }
380
381    /// **A file between the git layer's subprocess bound and the caller's
382    /// ceiling reads whole.** The layer refuses an output past
383    /// [`super::STDOUT_SIZE_CAP`], and the shipped ceiling
384    /// (`[documents] max_mib`) sits above it - so a file in between is
385    /// exactly the case a read built on that layer answers a refusal for
386    /// while the design promises the file, and it is why the content read
387    /// does not go through the layer at all.
388    #[tokio::test(flavor = "current_thread")]
389    async fn a_file_past_the_git_layers_own_bound_still_reads_whole() {
390        let dir = tempfile::tempdir().expect("a directory");
391        repo(dir.path());
392        let over = super::super::STDOUT_SIZE_CAP + 4096;
393        // Long lines on purpose: the fixture must sit over the layer's bound
394        // WITHOUT meeting the line cap, which bounds the answer's other half.
395        let line = format!("{}\n", "x".repeat(899));
396        let mut big = String::with_capacity(over + 1024);
397        while big.len() < over {
398            big.push_str(&line);
399        }
400        std::fs::write(dir.path().join("huge.txt"), &big).expect("write");
401        let git = |args: &[&str]| {
402            let out = StdCommand::new("git")
403                .env_remove("GIT_DIR")
404                .env_remove("GIT_WORK_TREE")
405                .env_remove("GIT_COMMON_DIR")
406                .arg("-C")
407                .arg(dir.path())
408                .args(args)
409                .output()
410                .expect("run git");
411            assert!(out.status.success());
412        };
413        git(&["add", "huge.txt"]);
414        git(&["commit", "-qm", "huge"]);
415
416        let head = head_of(dir.path());
417        // The shipped ceiling, spelled: `[documents] max_mib` defaults to 10.
418        let read = read_file_lines_within(
419            dir.path(),
420            &head,
421            "huge.txt",
422            10 * 1024 * 1024,
423            TEST_GIT_BUDGET,
424        )
425        .await
426        .expect("a file inside the ceiling reads");
427        assert!(!read.truncated, "and nothing is withheld from it");
428        assert_eq!(
429            read.lines.iter().map(|line| line.len() + 1).sum::<usize>(),
430            big.len(),
431            "every line of it, whole",
432        );
433    }
434
435    /// **Only a blob is a file.** A tree or a gitlink at the path answers a
436    /// size that is not its content's (`git show` prints a listing, or the
437    /// commit line, for them), so a read that trusted the size would draw
438    /// lines no file has.
439    #[tokio::test(flavor = "current_thread")]
440    async fn a_path_that_is_not_a_blob_is_missing() {
441        let dir = tempfile::tempdir().expect("a directory");
442        repo(dir.path());
443        std::fs::create_dir_all(dir.path().join("dir")).expect("mkdir");
444        std::fs::write(dir.path().join("dir/inner.txt"), "x\n").expect("write");
445        let git = |args: &[&str]| {
446            let out = StdCommand::new("git")
447                .env_remove("GIT_DIR")
448                .env_remove("GIT_WORK_TREE")
449                .env_remove("GIT_COMMON_DIR")
450                .arg("-C")
451                .arg(dir.path())
452                .args(args)
453                .output()
454                .expect("run git");
455            assert!(out.status.success());
456        };
457        git(&["add", "-A"]);
458        git(&["commit", "-qm", "dir"]);
459
460        let head = head_of(dir.path());
461        let tree =
462            read_file_lines_within(dir.path(), &head, "dir", u64::MAX, TEST_GIT_BUDGET).await;
463        assert!(
464            matches!(tree, Err(FileReadRefusal::Missing { .. })),
465            "a tree is not a file: {tree:?}",
466        );
467    }
468
469    /// **A child that failed is a read that did not happen**, never an empty
470    /// file: a `git show` that exits non-zero after the probes answered must
471    /// not be drawn as a file of no lines.
472    #[tokio::test(flavor = "current_thread")]
473    async fn a_failed_show_is_no_read_at_all() {
474        let dir = tempfile::tempdir().expect("a directory");
475        repo(dir.path());
476        let head = head_of(dir.path());
477
478        let missing = format!("{head}:never.txt");
479        assert!(
480            show_capped(dir.path(), &missing, 4096, TEST_GIT_BUDGET).await.is_none(),
481            "a failing child answers no content",
482        );
483        let there = format!("{head}:small.txt");
484        assert!(
485            show_capped(dir.path(), &there, 4096, TEST_GIT_BUDGET).await.is_some(),
486            "and one that reads answers its bytes",
487        );
488    }
489
490    /// **The LINES are bounded too.** A file of very short lines meets the
491    /// line cap long before any byte ceiling, and the answer's own JSON is
492    /// what that cap is for: ten thousand lines is the most one read
493    /// carries, flagged.
494    #[tokio::test(flavor = "current_thread")]
495    async fn the_line_cap_holds_a_short_line_file() {
496        let dir = tempfile::tempdir().expect("a directory");
497        repo(dir.path());
498        let mut many = String::new();
499        for _ in 0..(MAX_READ_LINES + 500) {
500            many.push_str("l\n");
501        }
502        std::fs::write(dir.path().join("many.txt"), many).expect("write");
503        let git = |args: &[&str]| {
504            let out = StdCommand::new("git")
505                .env_remove("GIT_DIR")
506                .env_remove("GIT_WORK_TREE")
507                .env_remove("GIT_COMMON_DIR")
508                .arg("-C")
509                .arg(dir.path())
510                .args(args)
511                .output()
512                .expect("run git");
513            assert!(out.status.success());
514        };
515        git(&["add", "-A"]);
516        git(&["commit", "-qm", "many"]);
517
518        let head = head_of(dir.path());
519        let read = read_file_lines_within(dir.path(), &head, "many.txt", u64::MAX, TEST_GIT_BUDGET)
520            .await
521            .expect("the read answers");
522        assert_eq!(read.lines.len(), MAX_READ_LINES, "the cap's own count");
523        assert!(read.truncated, "and the read says it stopped");
524    }
525
526    /// The capped read stops at the ceiling, whatever the file's size: the
527    /// allocation is the harness's, so this holds at any size the fixture
528    /// can write.
529    #[tokio::test]
530    async fn the_capped_read_never_carries_more_than_the_ceiling() {
531        let dir = tempfile::tempdir().expect("a directory");
532        repo(dir.path());
533        let mut big = String::new();
534        for _ in 0..2000 {
535            big.push_str("line\n");
536        }
537        std::fs::write(dir.path().join("big.txt"), big).expect("write");
538        let git = |args: &[&str]| {
539            let out = StdCommand::new("git")
540                .env_remove("GIT_DIR")
541                .env_remove("GIT_WORK_TREE")
542                .env_remove("GIT_COMMON_DIR")
543                .arg("-C")
544                .arg(dir.path())
545                .args(args)
546                .output()
547                .expect("run git");
548            assert!(out.status.success());
549        };
550        git(&["add", "big.txt"]);
551        git(&["commit", "-qm", "big"]);
552
553        let head = head_of(dir.path());
554        let read = read_file_lines_within(dir.path(), &head, "big.txt", 64, TEST_GIT_BUDGET)
555            .await
556            .expect("the read answers");
557        assert!(read.truncated, "a file past the ceiling answers a short read");
558        assert!(
559            read.lines.iter().map(|line| line.len() + 1).sum::<usize>() <= 64,
560            "and every line it carries is inside the ceiling: {:?}",
561            read.lines,
562        );
563        assert!(read.lines.len() > 1, "with the whole lines the ceiling reaches");
564    }
565}