1use std::path::Path;
28use std::time::Duration;
29
30use tokio::io::AsyncReadExt as _;
31
32use super::{COMMAND_TIMEOUT, GitOutput, run_git_within};
33
34pub const MAX_READ_LINES: usize = 10_000;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FileLines {
43 pub lines: Vec<String>,
45 pub truncated: bool,
48}
49
50#[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
68pub 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 (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
89pub async fn merge_base(cwd: &Path, base: &str) -> Option<String> {
95 merge_base_within(cwd, base, COMMAND_TIMEOUT).await
96}
97
98pub 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
112pub 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
122pub 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 if !is_blob(cwd, &spec, budget).await {
141 return Err(FileReadRefusal::Missing { rev: rev.to_owned(), path: path.to_owned() });
142 }
143 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 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 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
169async 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
178async 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
186async 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 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
223fn 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 #[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 #[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 #[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 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 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 #[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 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 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 #[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 #[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 #[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 #[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}