Skip to main content

fluidattacks_core_git/
cli.rs

1use anyhow::{bail, Context, Result};
2use chrono::DateTime;
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use tokio::process::Command;
7use tracing::warn;
8
9use super::types::{BlameResult, CommitInfo};
10use crate::call;
11use crate::errors::GitCallError;
12
13/// Matches the deadline `run_git` applied to the same call in the python.
14const GIT_CONFIG_TIMEOUT_SECS: u64 = 20;
15
16/// Git writes paths as the bytes on disk once `core.quotepath` is off, and those
17/// bytes are not always UTF-8. Decoding them loses the name of the file.
18pub(crate) fn path_from_bytes(bytes: &[u8]) -> PathBuf {
19    #[cfg(unix)]
20    {
21        use std::os::unix::ffi::OsStrExt;
22        PathBuf::from(OsStr::from_bytes(bytes))
23    }
24    #[cfg(not(unix))]
25    {
26        PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
27    }
28}
29
30/// The bytes git reads as `path`, which it separates with `/` on every system.
31pub(crate) fn path_to_bytes(path: &Path) -> Vec<u8> {
32    #[cfg(unix)]
33    {
34        use std::os::unix::ffi::OsStrExt;
35        path.as_os_str().as_bytes().to_vec()
36    }
37    #[cfg(not(unix))]
38    {
39        path.to_string_lossy().replace('\\', "/").into_bytes()
40    }
41}
42
43pub async fn get_head_commit(repo_path: &Path, branch: &str) -> Result<String> {
44    let reference = format!("refs/heads/{branch}");
45    let output = Command::new("git")
46        .arg("-C")
47        .arg(repo_path)
48        .args(["rev-parse", &reference])
49        .output()
50        .await
51        .context("running git rev-parse")?;
52
53    if !output.status.success() {
54        bail!(
55            "git rev-parse failed: {}",
56            String::from_utf8_lossy(&output.stderr)
57        );
58    }
59
60    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
61}
62
63/// Honors the repository's own config, filter drivers included, so it must be trusted.
64pub async fn reset_hard(repo_path: &Path) -> Result<()> {
65    let root = repo_path
66        .canonicalize()
67        .with_context(|| format!("resolving {}", repo_path.display()))?;
68    let mut safe_directory = OsString::from("safe.directory=");
69    safe_directory.push(&root);
70    let output = Command::new("git")
71        .arg("-c")
72        .arg(&safe_directory)
73        .args([
74            "-c",
75            "core.fsmonitor=false",
76            "-c",
77            "core.hooksPath=/dev/null",
78        ])
79        .arg("-C")
80        .arg(&root)
81        .args(["reset", "--hard", "HEAD"])
82        .output()
83        .await
84        .context("running git reset --hard")?;
85
86    if !output.status.success() {
87        bail!(
88            "git reset --hard failed: {}",
89            String::from_utf8_lossy(&output.stderr)
90        );
91    }
92    Ok(())
93}
94
95pub async fn disable_quotepath(repo_path: &Path) -> Result<()> {
96    let mut git_dir = OsString::from("--git-dir=");
97    git_dir.push(repo_path);
98    let output = tokio::time::timeout(
99        Duration::from_secs(GIT_CONFIG_TIMEOUT_SECS),
100        Command::new("git")
101            .arg(&git_dir)
102            .args(["config", "core.quotepath", "off"])
103            .kill_on_drop(true)
104            .output(),
105    )
106    .await
107    .context("git config core.quotepath timed out")?
108    .context("running git config core.quotepath")?;
109
110    if !output.status.success() {
111        bail!(
112            "git config core.quotepath failed: {}",
113            String::from_utf8_lossy(&output.stderr)
114        );
115    }
116    Ok(())
117}
118
119pub async fn get_last_commit_info(repo_path: &Path, filename: &Path) -> Result<Option<CommitInfo>> {
120    let output = Command::new("git")
121        .arg("-C")
122        .arg(repo_path)
123        .args(["log", "--max-count", "1", "--format=%H%n%ce%n%cI", "--"])
124        .arg(filename)
125        .output()
126        .await
127        .context("running git log")?;
128
129    let stderr = String::from_utf8_lossy(&output.stderr);
130    if !output.status.success() {
131        warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git log failed");
132        return Ok(None);
133    }
134
135    let stdout = String::from_utf8_lossy(&output.stdout);
136    let stdout = stdout.trim();
137    if stdout.is_empty() {
138        return Ok(None);
139    }
140
141    let lines: Vec<&str> = stdout.lines().collect();
142    if lines.len() < 3 {
143        return Ok(None);
144    }
145
146    let date = DateTime::parse_from_rfc3339(lines.get(2).copied().unwrap_or(""))
147        .context("parsing the committer date of git log")?;
148
149    Ok(Some(CommitInfo {
150        sha: lines.first().copied().unwrap_or("").to_owned(),
151        author_email: lines.get(1).copied().unwrap_or("").to_owned(),
152        date,
153    }))
154}
155
156pub async fn get_line_author(
157    repo_path: &Path,
158    filename: &Path,
159    line: usize,
160    rev: &str,
161) -> Result<Option<CommitInfo>> {
162    let line_spec = format!("{line},+1");
163    let output = Command::new("git")
164        .arg("-C")
165        .arg(repo_path)
166        .args([
167            "blame", "-L", &line_spec, "-l", "-p", "-M", "-C", "-C", rev, "--",
168        ])
169        .arg(filename)
170        .output()
171        .await
172        .context("running git blame")?;
173
174    let stderr = String::from_utf8_lossy(&output.stderr);
175    if !output.status.success() {
176        warn!(repo_path = %repo_path.display(), filename = %filename.display(), %stderr, "git blame failed");
177        return Ok(None);
178    }
179
180    let stdout = String::from_utf8_lossy(&output.stdout);
181    parse_blame_porcelain(&stdout)
182}
183
184pub async fn get_modified_filenames(
185    repo_path: &Path,
186    commit_sha: &str,
187) -> Result<Vec<PathBuf>, GitCallError> {
188    let mut git = Command::new("git");
189    git.current_dir(repo_path).args([
190        "diff",
191        "--name-only",
192        "--end-of-options",
193        &format!("{commit_sha}..HEAD"),
194    ]);
195    let output = call::run("diff", &mut git).await?;
196
197    Ok(output
198        .stdout
199        .split(|byte| *byte == b'\n')
200        .filter(|line| !line.is_empty())
201        .map(path_from_bytes)
202        .collect())
203}
204
205pub async fn is_commit_in_branch(
206    repo_path: &Path,
207    branch: &str,
208    commit_sha: &str,
209) -> Result<bool, GitCallError> {
210    let mut git = Command::new("git");
211    // A missing path fails to start, as it did under the python's cwd.
212    git.current_dir(repo_path).args([
213        "merge-base",
214        "--is-ancestor",
215        "--end-of-options",
216        commit_sha,
217        &format!("refs/heads/{branch}"),
218    ]);
219    match call::run("merge-base", &mut git).await {
220        Ok(_) => Ok(true),
221        Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
222        Err(error) => Err(error),
223    }
224}
225
226pub async fn show_file_at_rev(
227    repo_path: &Path,
228    rev: &str,
229    file_path: &Path,
230) -> Result<Option<String>> {
231    let mut rev_path = OsString::from(format!("{rev}:"));
232    rev_path.push(file_path);
233    let output = Command::new("git")
234        .arg("-C")
235        .arg(repo_path)
236        .arg("show")
237        .arg(&rev_path)
238        .output()
239        .await
240        .context("running git show")?;
241
242    if !output.status.success() {
243        return Ok(None);
244    }
245
246    Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
247}
248
249#[allow(clippy::too_many_arguments)]
250pub async fn blame_reverse(
251    repo_path: &Path,
252    file_path: &Path,
253    line: usize,
254    rev_a: &str,
255    rev_b: &str,
256    enable_copy_detection: bool,
257) -> Result<Option<BlameResult>> {
258    let line_spec = format!("{line},+1");
259    let rev_range = format!("{rev_a}..{rev_b}");
260
261    let mut args: Vec<&OsStr> = vec![
262        OsStr::new("-C"),
263        repo_path.as_os_str(),
264        OsStr::new("-c"),
265        OsStr::new("core.quotepath=off"),
266        OsStr::new("blame"),
267        OsStr::new("-p"),
268        OsStr::new("-M"),
269    ];
270    if enable_copy_detection {
271        args.push(OsStr::new("-C"));
272    }
273    args.extend([
274        OsStr::new("-L"),
275        OsStr::new(&line_spec),
276        OsStr::new("--reverse"),
277        OsStr::new(&rev_range),
278        OsStr::new("--"),
279        file_path.as_os_str(),
280    ]);
281
282    let output = Command::new("git")
283        .args(&args)
284        .output()
285        .await
286        .context("running git blame --reverse")?;
287
288    if !output.status.success() {
289        warn!(
290            repo_path = %repo_path.display(),
291            file_path = %file_path.display(),
292            line,
293            rev_range = %rev_range,
294            stderr = %String::from_utf8_lossy(&output.stderr),
295            "git blame --reverse failed",
296        );
297        return Ok(None);
298    }
299
300    Ok(parse_blame_reverse_porcelain(&output.stdout))
301}
302
303pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
304    let mut sha = String::new();
305    let mut author_mail = String::new();
306    let mut committer_time: Option<&str> = None;
307
308    for line in output.lines() {
309        if sha.is_empty() && line.len() >= 40 {
310            line.split_whitespace()
311                .next()
312                .unwrap_or("")
313                .clone_into(&mut sha);
314        } else if let Some(val) = line.strip_prefix("author-mail <") {
315            val.trim_end_matches('>').clone_into(&mut author_mail);
316        } else if let Some(val) = line.strip_prefix("committer-time ") {
317            committer_time = Some(val);
318        }
319    }
320
321    if sha.is_empty() {
322        return Ok(None);
323    }
324
325    let date = match committer_time {
326        None => DateTime::UNIX_EPOCH.fixed_offset(),
327        Some(seconds) => seconds
328            .parse::<i64>()
329            .ok()
330            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
331            .context("parsing the committer time of git blame")?
332            .fixed_offset(),
333    };
334
335    Ok(Some(CommitInfo {
336        sha,
337        author_email: author_mail,
338        date,
339    }))
340}
341
342fn read_octal_escape(
343    first: u8,
344    bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
345) -> u8 {
346    let mut code = u32::from(first.saturating_sub(b'0'));
347    for _ in 0..2 {
348        let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
349            break;
350        };
351        bytes.next();
352        code = code
353            .saturating_mul(8)
354            .saturating_add(u32::from(digit.saturating_sub(b'0')));
355    }
356    u8::try_from(code).unwrap_or(b'?')
357}
358
359fn unquote_path(value: &[u8]) -> PathBuf {
360    let Some(inner) = value
361        .strip_prefix(b"\"")
362        .and_then(|rest| rest.strip_suffix(b"\""))
363    else {
364        return path_from_bytes(value);
365    };
366
367    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
368    let mut bytes = inner.iter().copied().peekable();
369    while let Some(byte) = bytes.next() {
370        if byte != b'\\' {
371            out.push(byte);
372            continue;
373        }
374        match bytes.next() {
375            Some(b'n') => out.push(b'\n'),
376            Some(b't') => out.push(b'\t'),
377            Some(b'r') => out.push(b'\r'),
378            Some(b'a') => out.push(0x07),
379            Some(b'b') => out.push(0x08),
380            Some(b'f') => out.push(0x0c),
381            Some(b'v') => out.push(0x0b),
382            Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
383            Some(other) => out.push(other),
384            None => out.push(b'\\'),
385        }
386    }
387
388    path_from_bytes(&out)
389}
390
391pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
392    let mut rev = String::new();
393    let mut line_num: usize = 0;
394    let mut path = PathBuf::new();
395
396    for line in output.split(|byte| *byte == b'\n') {
397        if rev.is_empty() && line.len() >= 40 {
398            let header = String::from_utf8_lossy(line);
399            let parts: Vec<&str> = header.split_whitespace().collect();
400            if let Some(hash) = parts.first() {
401                (*hash).clone_into(&mut rev);
402            }
403            if let Some(ln) = parts.get(1) {
404                line_num = ln.parse().unwrap_or(0);
405            }
406        } else if let Some(val) = line.strip_prefix(b"filename ") {
407            path = unquote_path(val);
408        }
409    }
410
411    if rev.is_empty() {
412        return None;
413    }
414
415    Some(BlameResult {
416        rev,
417        line: line_num,
418        path,
419    })
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_parse_blame_porcelain_valid() {
428        let output = "\
429abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
430author Test\n\
431author-mail <dev@example.com>\n\
432author-time 1700000000\n\
433author-tz +0000\n\
434committer Test\n\
435committer-mail <dev@example.com>\n\
436committer-time 1700000000\n\
437committer-tz +0000\n\
438summary test commit\n\
439filename file.txt\n\
440\tcode line\n";
441        let result = parse_blame_porcelain(output).unwrap().unwrap();
442        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
443        assert_eq!(result.author_email, "dev@example.com");
444        assert_eq!(result.date.timestamp(), 1_700_000_000);
445    }
446
447    #[test]
448    fn test_parse_blame_porcelain_without_a_committer_time() {
449        let output = "\
450abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
451author-mail <dev@example.com>\n\
452filename file.txt\n\
453\tcode line\n";
454        let result = parse_blame_porcelain(output).unwrap().unwrap();
455        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
456        assert_eq!(result.author_email, "dev@example.com");
457        assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
458    }
459
460    #[test]
461    fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
462        let output = "\
463abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
464author-mail <dev@example.com>\n\
465committer-time 99999999999999\n\
466filename file.txt\n\
467\tcode line\n";
468        assert!(parse_blame_porcelain(output).is_err());
469    }
470
471    #[test]
472    fn test_parse_blame_porcelain_empty() {
473        assert!(parse_blame_porcelain("").unwrap().is_none());
474    }
475
476    #[test]
477    fn test_parse_blame_reverse_porcelain_valid() {
478        let output = b"\
479abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
480author Test\n\
481filename renamed.py\n";
482        let result = parse_blame_reverse_porcelain(output).unwrap();
483        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
484        assert_eq!(result.line, 7);
485        assert_eq!(result.path, Path::new("renamed.py"));
486    }
487
488    #[test]
489    fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
490        let output = b"\
491abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
492filename \"se\\303\\261al.txt\"\n";
493        let result = parse_blame_reverse_porcelain(output).unwrap();
494        assert_eq!(result.path, Path::new("seƱal.txt"));
495    }
496
497    #[test]
498    #[cfg(unix)]
499    fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
500        use std::os::unix::ffi::OsStrExt;
501
502        let output = b"\
503abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
504filename sel\xe1l.txt\n";
505        let result = parse_blame_reverse_porcelain(output).unwrap();
506        assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
507    }
508
509    #[test]
510    fn a_path_git_left_alone_is_taken_as_it_is() {
511        assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
512        assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
513        assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
514    }
515
516    #[test]
517    fn test_parse_blame_reverse_porcelain_empty() {
518        assert!(parse_blame_reverse_porcelain(b"").is_none());
519    }
520
521    fn git(repo: &Path, args: &[&str]) -> String {
522        let output = std::process::Command::new("git")
523            .arg("-C")
524            .arg(repo)
525            .args([
526                "-c",
527                "user.email=dev@fluidattacks.com",
528                "-c",
529                "user.name=dev",
530            ])
531            .args(args)
532            .output()
533            .unwrap();
534        assert!(output.status.success());
535        String::from_utf8(output.stdout).unwrap().trim().to_owned()
536    }
537
538    #[tokio::test]
539    async fn a_warning_from_git_does_not_hide_its_answer() {
540        let dir = tempfile::tempdir().unwrap();
541        std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
542
543        let repo = dir.path().join("repo");
544        std::fs::create_dir_all(&repo).unwrap();
545        git(&repo, &["init", "-q", "-b", "main"]);
546        std::fs::write(repo.join("f.txt"), b"first").unwrap();
547        git(&repo, &["add", "-A"]);
548        git(&repo, &["commit", "-q", "-m", "first"]);
549        let first = git(&repo, &["rev-parse", "HEAD"]);
550        std::fs::write(repo.join("g.txt"), b"second").unwrap();
551        git(&repo, &["add", "-A"]);
552        git(&repo, &["commit", "-q", "-m", "second"]);
553
554        // Both refs make git warn that a refname is ambiguous and still exit 0
555        git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
556        git(
557            &repo,
558            &["update-ref", &format!("refs/heads/{first}"), &first],
559        );
560
561        let last = get_last_commit_info(&repo, Path::new("f.txt"))
562            .await
563            .unwrap();
564        assert_eq!(last.map(|info| info.sha), Some(first.clone()));
565
566        let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
567            .await
568            .unwrap();
569        assert_eq!(author.map(|info| info.sha), Some(first.clone()));
570
571        let modified = get_modified_filenames(&repo, &first).await.unwrap();
572        assert_eq!(modified, vec![PathBuf::from("g.txt")]);
573
574        assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
575    }
576
577    fn two_branches(repo: &Path) -> (String, String) {
578        std::fs::create_dir_all(repo).unwrap();
579        git(repo, &["init", "-q", "-b", "main"]);
580        git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
581        let on_main = git(repo, &["rev-parse", "HEAD"]);
582        git(repo, &["checkout", "-q", "-b", "main-old"]);
583        git(
584            repo,
585            &["commit", "-q", "--allow-empty", "-m", "on main-old"],
586        );
587        let on_main_old = git(repo, &["rev-parse", "HEAD"]);
588        (on_main, on_main_old)
589    }
590
591    #[tokio::test]
592    async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
593        let dir = tempfile::tempdir().unwrap();
594        let repo = dir.path().join("repo");
595        let (on_main, on_main_old) = two_branches(&repo);
596
597        assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
598        assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
599            .await
600            .unwrap());
601    }
602
603    #[tokio::test]
604    async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
605        let dir = tempfile::tempdir().unwrap();
606        let repo = dir.path().join("repo");
607        let (on_main, _) = two_branches(&repo);
608
609        for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
610            let outcome = is_commit_in_branch(&repo, branch, &commit).await;
611
612            assert!(matches!(
613                outcome,
614                Err(GitCallError::Refused { command: "merge-base", status, .. })
615                    if status.code() == Some(128)
616            ));
617        }
618    }
619
620    #[tokio::test]
621    async fn a_diff_from_a_commit_the_repository_lacks_is_refused() {
622        let dir = tempfile::tempdir().unwrap();
623        let repo = dir.path().join("repo");
624        two_branches(&repo);
625
626        let outcome = get_modified_filenames(&repo, &"0".repeat(40)).await;
627
628        assert!(matches!(
629            outcome,
630            Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
631        ));
632    }
633
634    #[tokio::test]
635    async fn a_commit_that_looks_like_an_option_is_not_read_as_one() {
636        let dir = tempfile::tempdir().unwrap();
637        let repo = dir.path().join("repo");
638        two_branches(&repo);
639        let leak = dir.path().join("leak");
640
641        let outcome = get_modified_filenames(&repo, &format!("--output={}", leak.display())).await;
642
643        assert!(matches!(
644            outcome,
645            Err(GitCallError::Refused { command: "diff", status, .. }) if status.code() == Some(128)
646        ));
647        assert!(!dir.path().join("leak..HEAD").exists());
648    }
649
650    #[tokio::test]
651    async fn a_modified_name_is_cut_only_at_a_newline() {
652        let dir = tempfile::tempdir().unwrap();
653        let repo = dir.path().join("repo");
654        let (on_main, _) = two_branches(&repo);
655        git(&repo, &["config", "core.quotepath", "off"]);
656        std::fs::write(repo.join("a\u{85}b.txt"), b"content").unwrap();
657        git(&repo, &["add", "-A"]);
658        git(&repo, &["commit", "-q", "-m", "next line in a name"]);
659
660        let modified = get_modified_filenames(&repo, &on_main).await.unwrap();
661
662        assert_eq!(modified, vec![PathBuf::from("a\u{85}b.txt")]);
663    }
664
665    #[tokio::test]
666    async fn a_repository_path_that_does_not_exist_does_not_start() {
667        let dir = tempfile::tempdir().unwrap();
668        let missing = dir.path().join("missing");
669
670        let in_branch = is_commit_in_branch(&missing, "main", "HEAD").await;
671        let modified = get_modified_filenames(&missing, "HEAD").await;
672
673        assert!(matches!(
674            in_branch,
675            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
676        ));
677        assert!(matches!(
678            modified,
679            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
680        ));
681    }
682}