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(repo_path: &Path, commit_sha: &str) -> Result<Vec<PathBuf>> {
185    let output = Command::new("git")
186        .arg("-C")
187        .arg(repo_path)
188        .args(["diff", "--name-only", &format!("{commit_sha}..HEAD")])
189        .output()
190        .await
191        .context("running git diff --name-only")?;
192
193    let stderr = String::from_utf8_lossy(&output.stderr);
194    if !output.status.success() {
195        warn!(repo_path = %repo_path.display(), commit_sha, %stderr, "git diff failed");
196        return Ok(Vec::new());
197    }
198
199    Ok(output
200        .stdout
201        .split(|byte| *byte == b'\n')
202        .filter(|line| !line.is_empty())
203        .map(path_from_bytes)
204        .collect())
205}
206
207pub async fn is_commit_in_branch(
208    repo_path: &Path,
209    branch: &str,
210    commit_sha: &str,
211) -> Result<bool, GitCallError> {
212    let mut git = Command::new("git");
213    // A missing path fails to start, as it did under the python's cwd.
214    git.current_dir(repo_path).args([
215        "merge-base",
216        "--is-ancestor",
217        "--end-of-options",
218        commit_sha,
219        &format!("refs/heads/{branch}"),
220    ]);
221    match call::run("merge-base", &mut git).await {
222        Ok(_) => Ok(true),
223        Err(GitCallError::Refused { status, .. }) if status.code() == Some(1) => Ok(false),
224        Err(error) => Err(error),
225    }
226}
227
228pub async fn show_file_at_rev(
229    repo_path: &Path,
230    rev: &str,
231    file_path: &Path,
232) -> Result<Option<String>> {
233    let mut rev_path = OsString::from(format!("{rev}:"));
234    rev_path.push(file_path);
235    let output = Command::new("git")
236        .arg("-C")
237        .arg(repo_path)
238        .arg("show")
239        .arg(&rev_path)
240        .output()
241        .await
242        .context("running git show")?;
243
244    if !output.status.success() {
245        return Ok(None);
246    }
247
248    Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
249}
250
251#[allow(clippy::too_many_arguments)]
252pub async fn blame_reverse(
253    repo_path: &Path,
254    file_path: &Path,
255    line: usize,
256    rev_a: &str,
257    rev_b: &str,
258    enable_copy_detection: bool,
259) -> Result<Option<BlameResult>> {
260    let line_spec = format!("{line},+1");
261    let rev_range = format!("{rev_a}..{rev_b}");
262
263    let mut args: Vec<&OsStr> = vec![
264        OsStr::new("-C"),
265        repo_path.as_os_str(),
266        OsStr::new("-c"),
267        OsStr::new("core.quotepath=off"),
268        OsStr::new("blame"),
269        OsStr::new("-p"),
270        OsStr::new("-M"),
271    ];
272    if enable_copy_detection {
273        args.push(OsStr::new("-C"));
274    }
275    args.extend([
276        OsStr::new("-L"),
277        OsStr::new(&line_spec),
278        OsStr::new("--reverse"),
279        OsStr::new(&rev_range),
280        OsStr::new("--"),
281        file_path.as_os_str(),
282    ]);
283
284    let output = Command::new("git")
285        .args(&args)
286        .output()
287        .await
288        .context("running git blame --reverse")?;
289
290    if !output.status.success() {
291        warn!(
292            repo_path = %repo_path.display(),
293            file_path = %file_path.display(),
294            line,
295            rev_range = %rev_range,
296            stderr = %String::from_utf8_lossy(&output.stderr),
297            "git blame --reverse failed",
298        );
299        return Ok(None);
300    }
301
302    Ok(parse_blame_reverse_porcelain(&output.stdout))
303}
304
305pub(crate) fn parse_blame_porcelain(output: &str) -> Result<Option<CommitInfo>> {
306    let mut sha = String::new();
307    let mut author_mail = String::new();
308    let mut committer_time: Option<&str> = None;
309
310    for line in output.lines() {
311        if sha.is_empty() && line.len() >= 40 {
312            line.split_whitespace()
313                .next()
314                .unwrap_or("")
315                .clone_into(&mut sha);
316        } else if let Some(val) = line.strip_prefix("author-mail <") {
317            val.trim_end_matches('>').clone_into(&mut author_mail);
318        } else if let Some(val) = line.strip_prefix("committer-time ") {
319            committer_time = Some(val);
320        }
321    }
322
323    if sha.is_empty() {
324        return Ok(None);
325    }
326
327    let date = match committer_time {
328        None => DateTime::UNIX_EPOCH.fixed_offset(),
329        Some(seconds) => seconds
330            .parse::<i64>()
331            .ok()
332            .and_then(|seconds| DateTime::from_timestamp(seconds, 0))
333            .context("parsing the committer time of git blame")?
334            .fixed_offset(),
335    };
336
337    Ok(Some(CommitInfo {
338        sha,
339        author_email: author_mail,
340        date,
341    }))
342}
343
344fn read_octal_escape(
345    first: u8,
346    bytes: &mut std::iter::Peekable<std::iter::Copied<std::slice::Iter<'_, u8>>>,
347) -> u8 {
348    let mut code = u32::from(first.saturating_sub(b'0'));
349    for _ in 0..2 {
350        let Some(&digit @ b'0'..=b'7') = bytes.peek() else {
351            break;
352        };
353        bytes.next();
354        code = code
355            .saturating_mul(8)
356            .saturating_add(u32::from(digit.saturating_sub(b'0')));
357    }
358    u8::try_from(code).unwrap_or(b'?')
359}
360
361fn unquote_path(value: &[u8]) -> PathBuf {
362    let Some(inner) = value
363        .strip_prefix(b"\"")
364        .and_then(|rest| rest.strip_suffix(b"\""))
365    else {
366        return path_from_bytes(value);
367    };
368
369    let mut out: Vec<u8> = Vec::with_capacity(inner.len());
370    let mut bytes = inner.iter().copied().peekable();
371    while let Some(byte) = bytes.next() {
372        if byte != b'\\' {
373            out.push(byte);
374            continue;
375        }
376        match bytes.next() {
377            Some(b'n') => out.push(b'\n'),
378            Some(b't') => out.push(b'\t'),
379            Some(b'r') => out.push(b'\r'),
380            Some(b'a') => out.push(0x07),
381            Some(b'b') => out.push(0x08),
382            Some(b'f') => out.push(0x0c),
383            Some(b'v') => out.push(0x0b),
384            Some(first @ b'0'..=b'7') => out.push(read_octal_escape(first, &mut bytes)),
385            Some(other) => out.push(other),
386            None => out.push(b'\\'),
387        }
388    }
389
390    path_from_bytes(&out)
391}
392
393pub(crate) fn parse_blame_reverse_porcelain(output: &[u8]) -> Option<BlameResult> {
394    let mut rev = String::new();
395    let mut line_num: usize = 0;
396    let mut path = PathBuf::new();
397
398    for line in output.split(|byte| *byte == b'\n') {
399        if rev.is_empty() && line.len() >= 40 {
400            let header = String::from_utf8_lossy(line);
401            let parts: Vec<&str> = header.split_whitespace().collect();
402            if let Some(hash) = parts.first() {
403                (*hash).clone_into(&mut rev);
404            }
405            if let Some(ln) = parts.get(1) {
406                line_num = ln.parse().unwrap_or(0);
407            }
408        } else if let Some(val) = line.strip_prefix(b"filename ") {
409            path = unquote_path(val);
410        }
411    }
412
413    if rev.is_empty() {
414        return None;
415    }
416
417    Some(BlameResult {
418        rev,
419        line: line_num,
420        path,
421    })
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_parse_blame_porcelain_valid() {
430        let output = "\
431abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
432author Test\n\
433author-mail <dev@example.com>\n\
434author-time 1700000000\n\
435author-tz +0000\n\
436committer Test\n\
437committer-mail <dev@example.com>\n\
438committer-time 1700000000\n\
439committer-tz +0000\n\
440summary test commit\n\
441filename file.txt\n\
442\tcode line\n";
443        let result = parse_blame_porcelain(output).unwrap().unwrap();
444        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
445        assert_eq!(result.author_email, "dev@example.com");
446        assert_eq!(result.date.timestamp(), 1_700_000_000);
447    }
448
449    #[test]
450    fn test_parse_blame_porcelain_without_a_committer_time() {
451        let output = "\
452abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
453author-mail <dev@example.com>\n\
454filename file.txt\n\
455\tcode line\n";
456        let result = parse_blame_porcelain(output).unwrap().unwrap();
457        assert_eq!(result.sha, "abc1234567890abc1234567890abc1234567890ab");
458        assert_eq!(result.author_email, "dev@example.com");
459        assert_eq!(result.date, DateTime::UNIX_EPOCH.fixed_offset());
460    }
461
462    #[test]
463    fn test_parse_blame_porcelain_with_an_unparsable_committer_time() {
464        let output = "\
465abc1234567890abc1234567890abc1234567890ab 1 1 1\n\
466author-mail <dev@example.com>\n\
467committer-time 99999999999999\n\
468filename file.txt\n\
469\tcode line\n";
470        assert!(parse_blame_porcelain(output).is_err());
471    }
472
473    #[test]
474    fn test_parse_blame_porcelain_empty() {
475        assert!(parse_blame_porcelain("").unwrap().is_none());
476    }
477
478    #[test]
479    fn test_parse_blame_reverse_porcelain_valid() {
480        let output = b"\
481abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
482author Test\n\
483filename renamed.py\n";
484        let result = parse_blame_reverse_porcelain(output).unwrap();
485        assert_eq!(result.rev, "abc1234567890abc1234567890abc1234567890ab");
486        assert_eq!(result.line, 7);
487        assert_eq!(result.path, Path::new("renamed.py"));
488    }
489
490    #[test]
491    fn a_path_git_escaped_is_read_back_as_the_name_on_disk() {
492        let output = b"\
493abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
494filename \"se\\303\\261al.txt\"\n";
495        let result = parse_blame_reverse_porcelain(output).unwrap();
496        assert_eq!(result.path, Path::new("seƱal.txt"));
497    }
498
499    #[test]
500    #[cfg(unix)]
501    fn a_path_that_is_not_utf8_keeps_the_bytes_it_had_on_disk() {
502        use std::os::unix::ffi::OsStrExt;
503
504        let output = b"\
505abc1234567890abc1234567890abc1234567890ab 7 5 1\n\
506filename sel\xe1l.txt\n";
507        let result = parse_blame_reverse_porcelain(output).unwrap();
508        assert_eq!(result.path.as_os_str().as_bytes(), b"sel\xe1l.txt");
509    }
510
511    #[test]
512    fn a_path_git_left_alone_is_taken_as_it_is() {
513        assert_eq!(unquote_path(b"src/main.rs"), Path::new("src/main.rs"));
514        assert_eq!(unquote_path(b"\"a\\\\b\""), Path::new("a\\b"));
515        assert_eq!(unquote_path(b"\"a\\\"b\""), Path::new("a\"b"));
516    }
517
518    #[test]
519    fn test_parse_blame_reverse_porcelain_empty() {
520        assert!(parse_blame_reverse_porcelain(b"").is_none());
521    }
522
523    fn git(repo: &Path, args: &[&str]) -> String {
524        let output = std::process::Command::new("git")
525            .arg("-C")
526            .arg(repo)
527            .args([
528                "-c",
529                "user.email=dev@fluidattacks.com",
530                "-c",
531                "user.name=dev",
532            ])
533            .args(args)
534            .output()
535            .unwrap();
536        assert!(output.status.success());
537        String::from_utf8(output.stdout).unwrap().trim().to_owned()
538    }
539
540    #[tokio::test]
541    async fn a_warning_from_git_does_not_hide_its_answer() {
542        let dir = tempfile::tempdir().unwrap();
543        std::env::set_var("GIT_CONFIG_GLOBAL", dir.path().join("gitconfig"));
544
545        let repo = dir.path().join("repo");
546        std::fs::create_dir_all(&repo).unwrap();
547        git(&repo, &["init", "-q", "-b", "main"]);
548        std::fs::write(repo.join("f.txt"), b"first").unwrap();
549        git(&repo, &["add", "-A"]);
550        git(&repo, &["commit", "-q", "-m", "first"]);
551        let first = git(&repo, &["rev-parse", "HEAD"]);
552        std::fs::write(repo.join("g.txt"), b"second").unwrap();
553        git(&repo, &["add", "-A"]);
554        git(&repo, &["commit", "-q", "-m", "second"]);
555
556        // Both refs make git warn that a refname is ambiguous and still exit 0
557        git(&repo, &["update-ref", "refs/heads/HEAD", "HEAD"]);
558        git(
559            &repo,
560            &["update-ref", &format!("refs/heads/{first}"), &first],
561        );
562
563        let last = get_last_commit_info(&repo, Path::new("f.txt"))
564            .await
565            .unwrap();
566        assert_eq!(last.map(|info| info.sha), Some(first.clone()));
567
568        let author = get_line_author(&repo, Path::new("f.txt"), 1, "HEAD")
569            .await
570            .unwrap();
571        assert_eq!(author.map(|info| info.sha), Some(first.clone()));
572
573        let modified = get_modified_filenames(&repo, &first).await.unwrap();
574        assert_eq!(modified, vec![PathBuf::from("g.txt")]);
575
576        assert!(is_commit_in_branch(&repo, "main", &first).await.unwrap());
577    }
578
579    fn two_branches(repo: &Path) -> (String, String) {
580        std::fs::create_dir_all(repo).unwrap();
581        git(repo, &["init", "-q", "-b", "main"]);
582        git(repo, &["commit", "-q", "--allow-empty", "-m", "on main"]);
583        let on_main = git(repo, &["rev-parse", "HEAD"]);
584        git(repo, &["checkout", "-q", "-b", "main-old"]);
585        git(
586            repo,
587            &["commit", "-q", "--allow-empty", "-m", "on main-old"],
588        );
589        let on_main_old = git(repo, &["rev-parse", "HEAD"]);
590        (on_main, on_main_old)
591    }
592
593    #[tokio::test]
594    async fn a_commit_is_in_the_branch_it_was_made_on_and_not_in_one_named_alike() {
595        let dir = tempfile::tempdir().unwrap();
596        let repo = dir.path().join("repo");
597        let (on_main, on_main_old) = two_branches(&repo);
598
599        assert!(is_commit_in_branch(&repo, "main", &on_main).await.unwrap());
600        assert!(!is_commit_in_branch(&repo, "main", &on_main_old)
601            .await
602            .unwrap());
603    }
604
605    #[tokio::test]
606    async fn a_commit_or_a_branch_the_repository_lacks_is_refused() {
607        let dir = tempfile::tempdir().unwrap();
608        let repo = dir.path().join("repo");
609        let (on_main, _) = two_branches(&repo);
610
611        for (branch, commit) in [("main", "0".repeat(40)), ("gone", on_main)] {
612            let outcome = is_commit_in_branch(&repo, branch, &commit).await;
613
614            assert!(matches!(
615                outcome,
616                Err(GitCallError::Refused { command: "merge-base", status, .. })
617                    if status.code() == Some(128)
618            ));
619        }
620    }
621
622    #[tokio::test]
623    async fn a_repository_path_that_does_not_exist_does_not_start() {
624        let dir = tempfile::tempdir().unwrap();
625
626        let outcome = is_commit_in_branch(&dir.path().join("missing"), "main", "HEAD").await;
627
628        assert!(matches!(
629            outcome,
630            Err(GitCallError::NotStarted { source, .. }) if source.kind() == std::io::ErrorKind::NotFound
631        ));
632    }
633}