Skip to main content

llmenv_git/
lib.rs

1//! Consolidated git utilities.
2//!
3//! Prevents malicious cloned repos from executing hooks or fsmonitors by
4//! centralizing GIT_CONFIG_FLAGS application across all git operations.
5
6use std::path::Path;
7use std::process::{Command, Stdio};
8
9/// Git config flags to protect cloned repos from executing hooks or fsmonitors.
10/// Prevents a malicious config repo from running arbitrary code via git hooks or fsmonitors.
11pub const GIT_CONFIG_FLAGS: &[&str] = &[
12    "-c",
13    "core.fsmonitor=false",
14    "-c",
15    "core.hooksPath=/dev/null",
16];
17
18/// Apply security config flags to a git command, with stdin detached.
19///
20/// No git operation in this codebase reads from stdin, so stdin is nulled at
21/// construction: a command invoked with a non-interactive stdin (CI, or the
22/// `source <(llmenv export)` eval context) can never block on an interactive
23/// prompt such as a credential helper (#299, #307). Centralizing it here means
24/// every `secure_git()` call site — including `.status()`/`.spawn()` callers
25/// that would otherwise inherit the parent's stdin — gets the guarantee.
26pub fn secure_git() -> Command {
27    let mut cmd = Command::new("git");
28    cmd.args(GIT_CONFIG_FLAGS);
29    cmd.stdin(Stdio::null());
30    cmd
31}
32
33/// Scrub embedded credentials from a git URL before it lands in an error
34/// message or log. A URL like `https://user:token@host/path` becomes
35/// `https://***@host/path`; an SSH-style `user@host:path` becomes `***@host:path`.
36/// Returns the input unchanged when no `@` userinfo is present.
37#[must_use]
38pub fn sanitize_git_url(url: &str) -> String {
39    if let Some(at_pos) = url.find('@') {
40        if let Some(proto_end) = url.find("://") {
41            if at_pos > proto_end {
42                let (proto, rest) = url.split_at(proto_end + 3);
43                if let Some(host_start) = rest.find('@') {
44                    return format!("{}***@{}", proto, &rest[host_start + 1..]);
45                }
46            }
47        } else {
48            return format!("***{}", &url[at_pos..]);
49        }
50    }
51    url.to_string()
52}
53
54/// Build a human-readable failure detail from a git subprocess's captured
55/// output. Prefers `stderr` (where git writes diagnostics), falls back to
56/// `stdout` (some errors — e.g. a `git add` index lock — print there), then to
57/// the exit status when both are empty. Control and ANSI escape bytes are
58/// stripped so a hostile remote's error text can't manipulate the terminal
59/// (#307), and the result is credential-scrubbed via [`sanitize_git_url`] so a
60/// URL with embedded credentials never echoes the secret to the terminal or
61/// logs (#312).
62#[must_use]
63pub fn git_failure_detail(
64    stderr: &[u8],
65    stdout: &[u8],
66    status: std::process::ExitStatus,
67) -> String {
68    let raw = if stderr.is_empty() { stdout } else { stderr };
69    let cleaned: String = String::from_utf8_lossy(raw)
70        .trim()
71        .chars()
72        .filter(|c| !c.is_control() || *c == '\n')
73        .collect();
74    if cleaned.is_empty() {
75        format!("exit code {status}")
76    } else {
77        sanitize_git_url(&cleaned)
78    }
79}
80
81/// Check if the working tree has staged or unstaged changes.
82pub fn working_tree_dirty(repo: &Path) -> bool {
83    secure_git()
84        .args(["status", "--porcelain"])
85        .current_dir(repo)
86        .stderr(Stdio::null())
87        .output()
88        .is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
89}
90
91/// Check if current branch has commits not yet pushed to its upstream.
92/// Returns false if there's no upstream, git fails, or output can't be parsed —
93/// we only want to nudge the user when we're certain there are unpushed commits.
94pub fn has_unpushed_commits(repo: &Path) -> bool {
95    let output = match secure_git()
96        .args(["rev-list", "--count", "@{u}..HEAD"])
97        .current_dir(repo)
98        .output()
99    {
100        Ok(out) => out,
101        Err(e) => {
102            tracing::warn!("git rev-list count failed at {}: {}", repo.display(), e);
103            return false;
104        }
105    };
106
107    if !output.status.success() {
108        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
109        tracing::warn!(
110            "git rev-list count failed at {} with exit {}: {}",
111            repo.display(),
112            output.status,
113            stderr
114        );
115        return false;
116    }
117
118    match parse_commit_count(&output.stdout) {
119        Some(count) => count > 0,
120        None => {
121            tracing::warn!("git rev-list count output invalid at {}", repo.display());
122            false
123        }
124    }
125}
126
127fn parse_commit_count(output: &[u8]) -> Option<u32> {
128    std::str::from_utf8(output)
129        .ok()
130        .and_then(|s| s.trim().parse::<u32>().ok())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn secure_git_includes_config_flags() {
139        let cmd = secure_git();
140        // Just verify command is created; actual flag testing is in integration tests
141        assert_eq!(cmd.get_program(), "git");
142    }
143
144    #[test]
145    fn sanitize_git_url_http_with_credentials() {
146        let url = "https://user:password@github.com/owner/repo.git";
147        assert_eq!(
148            sanitize_git_url(url),
149            "https://***@github.com/owner/repo.git"
150        );
151    }
152
153    #[test]
154    fn sanitize_git_url_ssh() {
155        let url = "git@github.com:owner/repo.git";
156        assert_eq!(sanitize_git_url(url), "***@github.com:owner/repo.git");
157    }
158
159    #[test]
160    fn sanitize_git_url_no_credentials() {
161        let url = "https://github.com/owner/repo.git";
162        assert_eq!(sanitize_git_url(url), url);
163    }
164
165    #[test]
166    fn git_failure_detail_prefers_stderr() {
167        use std::os::unix::process::ExitStatusExt;
168        let status = std::process::ExitStatus::from_raw(1 << 8);
169        let detail = git_failure_detail(b"fatal: repository not found\n", b"ignored", status);
170        assert_eq!(detail, "fatal: repository not found");
171    }
172
173    #[test]
174    fn git_failure_detail_falls_back_to_stdout_when_stderr_empty() {
175        use std::os::unix::process::ExitStatusExt;
176        let status = std::process::ExitStatus::from_raw(1 << 8);
177        let detail = git_failure_detail(b"", b"index locked\n", status);
178        assert_eq!(detail, "index locked");
179    }
180
181    #[test]
182    fn git_failure_detail_scrubs_credentials_in_stderr() {
183        use std::os::unix::process::ExitStatusExt;
184        let status = std::process::ExitStatus::from_raw(1 << 8);
185        let detail = git_failure_detail(
186            b"fatal: could not read from https://user:tok@github.com/x.git\n",
187            b"",
188            status,
189        );
190        assert!(!detail.contains("tok"), "credential leaked: {detail}");
191    }
192
193    #[test]
194    fn git_failure_detail_strips_control_and_ansi_sequences() {
195        use std::os::unix::process::ExitStatusExt;
196        let status = std::process::ExitStatus::from_raw(1 << 8);
197        let hostile = b"\x1b[2Jcleared\x1b]0;title";
198        let detail = git_failure_detail(hostile, b"", status);
199        assert_eq!(detail, "[2Jcleared]0;title");
200    }
201
202    #[test]
203    fn git_failure_detail_falls_back_to_exit_code() {
204        use std::os::unix::process::ExitStatusExt;
205        let status = std::process::ExitStatus::from_raw(128 << 8);
206        let detail = git_failure_detail(b"   \n", b"", status);
207        assert!(detail.contains("exit code"), "got: {detail}");
208    }
209
210    #[cfg(test)]
211    mod prop_tests {
212        use super::*;
213        use proptest::prelude::*;
214
215        proptest! {
216            #[test]
217            fn parse_commit_count_valid_numeric(count_val in 0u32..100_000) {
218                let bytes = format!("{count_val}").into_bytes();
219                prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
220            }
221
222            #[test]
223            fn parse_commit_count_whitespace_trimmed(count_val in 0u32..100_000) {
224                let bytes = format!("  {count_val}  \n").into_bytes();
225                prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
226            }
227
228            #[test]
229            fn parse_commit_count_malformed_returns_none(junk in ".*") {
230                // Must not panic on arbitrary input; non-numeric → None
231                let _ = parse_commit_count(junk.as_bytes());
232            }
233
234            #[test]
235            fn sanitize_git_url_no_panic(s in ".*") {
236                let _ = sanitize_git_url(&s);
237            }
238
239            #[test]
240            fn sanitize_git_url_idempotent(s in ".*") {
241                let once = sanitize_git_url(&s);
242                let twice = sanitize_git_url(&once);
243                prop_assert_eq!(once, twice);
244            }
245
246            #[test]
247            fn sanitize_git_url_scrubs_http_credentials(
248                user in "[a-zA-Z0-9_]+",
249                pass in "[a-zA-Z0-9_]+",
250                host in "[a-zA-Z0-9.-]+",
251                path in "[a-zA-Z0-9/_.-]*"
252            ) {
253                let url = format!("https://{user}:{pass}@{host}/{path}");
254                let sanitized = sanitize_git_url(&url);
255                // The credential sequence user:pass@ must be gone; pass may
256                // legitimately appear in the host or path, so we check the
257                // full credential prefix, not the password alone.
258                prop_assert!(!sanitized.contains(&format!("{user}:{pass}@")),
259                    "credentials leaked in: {sanitized}");
260                prop_assert!(sanitized.contains("***@"), "expected *** in: {sanitized}");
261            }
262        }
263    }
264}