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/// Default TCP connection timeout for background git operations (fetch/pull on every shell
34/// prompt). Short enough that a stuck remote doesn't freeze the prompt; long enough that a
35/// briefly loaded GitHub doesn't produce spurious failures.
36pub const DEFAULT_GIT_TIMEOUT_SECS: u64 = 10;
37
38/// Default TCP connection timeout for explicit, user-initiated git operations (clone/fetch
39/// for plugin installation). Longer than the background default because these are one-shot
40/// operations and users understand that a clone can take a moment.
41pub const DEFAULT_GIT_PLUGIN_TIMEOUT_SECS: u64 = 30;
42
43/// Apply TCP connection and auth/transfer timeouts to a git command.
44///
45/// Sets three timeout mechanisms to prevent indefinite hangs (#449, #453):
46/// - `GIT_CONNECT_TIMEOUT`: TCP handshake timeout
47/// - `GIT_SSH_COMMAND`: SSH auth negotiation timeout via `-o ConnectTimeout` and
48///   `-o BatchMode=yes` (non-interactive). Only sets this env var if not already present,
49///   preserving any user-configured SSH identity files, ProxyJump, or other customizations.
50/// - `http.lowSpeedTime` and `http.lowSpeedLimit`: HTTP pack transfer stall timeout
51pub fn apply_git_timeout(cmd: &mut Command, secs: u64) -> &mut Command {
52    cmd.env("GIT_CONNECT_TIMEOUT", secs.to_string());
53
54    // Only set GIT_SSH_COMMAND if not already in the environment; preserve user's existing config
55    if std::env::var_os("GIT_SSH_COMMAND").is_none() {
56        cmd.env(
57            "GIT_SSH_COMMAND",
58            format!("ssh -o ConnectTimeout={secs} -o BatchMode=yes"),
59        );
60    }
61
62    // HTTP transfer stall timeout: fail if download speed drops below 1 byte/sec for N seconds
63    cmd.args([
64        "-c",
65        &format!("http.lowSpeedTime={secs}"),
66        "-c",
67        "http.lowSpeedLimit=1",
68    ]);
69
70    cmd
71}
72
73/// Scrub embedded credentials from a git URL before it lands in an error
74/// message or log. A URL like `https://user:token@host/path` becomes
75/// `https://***@host/path`; an SSH-style `user@host:path` becomes `***@host:path`.
76/// Returns the input unchanged when no `@` userinfo is present.
77#[must_use]
78pub fn sanitize_git_url(url: &str) -> String {
79    if let Some(at_pos) = url.find('@') {
80        if let Some(proto_end) = url.find("://") {
81            if at_pos > proto_end {
82                let (proto, rest) = url.split_at(proto_end + 3);
83                if let Some(host_start) = rest.find('@') {
84                    return format!("{}***@{}", proto, &rest[host_start + 1..]);
85                }
86            }
87        } else {
88            return format!("***{}", &url[at_pos..]);
89        }
90    }
91    url.to_string()
92}
93
94/// Build a human-readable failure detail from a git subprocess's captured
95/// output. Prefers `stderr` (where git writes diagnostics), falls back to
96/// `stdout` (some errors — e.g. a `git add` index lock — print there), then to
97/// the exit status when both are empty. Control and ANSI escape bytes are
98/// stripped so a hostile remote's error text can't manipulate the terminal
99/// (#307), and the result is credential-scrubbed via [`sanitize_git_url`] so a
100/// URL with embedded credentials never echoes the secret to the terminal or
101/// logs (#312).
102#[must_use]
103pub fn git_failure_detail(
104    stderr: &[u8],
105    stdout: &[u8],
106    status: std::process::ExitStatus,
107) -> String {
108    let raw = if stderr.is_empty() { stdout } else { stderr };
109    let cleaned: String = String::from_utf8_lossy(raw)
110        .trim()
111        .chars()
112        .filter(|c| !c.is_control() || *c == '\n')
113        .collect();
114    if cleaned.is_empty() {
115        format!("exit code {status}")
116    } else {
117        sanitize_git_url(&cleaned)
118    }
119}
120
121/// Check if the working tree has staged or unstaged changes.
122pub fn working_tree_dirty(repo: &Path) -> bool {
123    secure_git()
124        .args(["status", "--porcelain"])
125        .current_dir(repo)
126        .stderr(Stdio::null())
127        .output()
128        .is_ok_and(|o| o.status.success() && !o.stdout.is_empty())
129}
130
131/// Check if current branch has commits not yet pushed to its upstream.
132/// Returns false if there's no upstream, git fails, or output can't be parsed —
133/// we only want to nudge the user when we're certain there are unpushed commits.
134pub fn has_unpushed_commits(repo: &Path) -> bool {
135    let output = match secure_git()
136        .args(["rev-list", "--count", "@{u}..HEAD"])
137        .current_dir(repo)
138        .output()
139    {
140        Ok(out) => out,
141        Err(e) => {
142            tracing::warn!("git rev-list count failed at {}: {}", repo.display(), e);
143            return false;
144        }
145    };
146
147    if !output.status.success() {
148        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
149        tracing::warn!(
150            "git rev-list count failed at {} with exit {}: {}",
151            repo.display(),
152            output.status,
153            stderr
154        );
155        return false;
156    }
157
158    match parse_commit_count(&output.stdout) {
159        Some(count) => count > 0,
160        None => {
161            tracing::warn!("git rev-list count output invalid at {}", repo.display());
162            false
163        }
164    }
165}
166
167fn parse_commit_count(output: &[u8]) -> Option<u32> {
168    std::str::from_utf8(output)
169        .ok()
170        .and_then(|s| s.trim().parse::<u32>().ok())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn secure_git_includes_config_flags() {
179        let cmd = secure_git();
180        // Just verify command is created; actual flag testing is in integration tests
181        assert_eq!(cmd.get_program(), "git");
182    }
183
184    #[test]
185    fn apply_git_timeout_sets_tcp_timeout() {
186        use std::ffi::OsStr;
187        let mut cmd = secure_git();
188        apply_git_timeout(&mut cmd, 10);
189        assert!(
190            cmd.get_envs()
191                .any(|(k, v)| k == OsStr::new("GIT_CONNECT_TIMEOUT") && v == Some(OsStr::new("10"))),
192            "GIT_CONNECT_TIMEOUT env var should be set to 10"
193        );
194    }
195
196    #[test]
197    fn apply_git_timeout_sets_ssh_command() {
198        use std::ffi::OsStr;
199        let mut cmd = secure_git();
200        apply_git_timeout(&mut cmd, 10);
201        // Function checks std::env::var_os to avoid clobbering existing GIT_SSH_COMMAND.
202        // In this test environment it's not set, so the function should set it.
203        assert!(
204            cmd.get_envs()
205                .any(|(k, v)| k == OsStr::new("GIT_SSH_COMMAND")
206                    && v == Some(OsStr::new("ssh -o ConnectTimeout=10 -o BatchMode=yes"))),
207            "GIT_SSH_COMMAND should be set correctly"
208        );
209    }
210
211    #[test]
212    fn apply_git_timeout_sets_http_config() {
213        let mut cmd = secure_git();
214        apply_git_timeout(&mut cmd, 10);
215        let args: Vec<String> = cmd
216            .get_args()
217            .map(|s| s.to_string_lossy().into_owned())
218            .collect();
219        assert!(
220            args.windows(2).any(|w| w == ["-c", "http.lowSpeedTime=10"]),
221            "should set http.lowSpeedTime=10"
222        );
223        assert!(
224            args.windows(2).any(|w| w == ["-c", "http.lowSpeedLimit=1"]),
225            "should set http.lowSpeedLimit=1"
226        );
227    }
228
229    #[test]
230    fn sanitize_git_url_http_with_credentials() {
231        let url = "https://user:password@github.com/owner/repo.git";
232        assert_eq!(
233            sanitize_git_url(url),
234            "https://***@github.com/owner/repo.git"
235        );
236    }
237
238    #[test]
239    fn sanitize_git_url_ssh() {
240        let url = "git@github.com:owner/repo.git";
241        assert_eq!(sanitize_git_url(url), "***@github.com:owner/repo.git");
242    }
243
244    #[test]
245    fn sanitize_git_url_no_credentials() {
246        let url = "https://github.com/owner/repo.git";
247        assert_eq!(sanitize_git_url(url), url);
248    }
249
250    #[test]
251    fn git_failure_detail_prefers_stderr() {
252        use std::os::unix::process::ExitStatusExt;
253        let status = std::process::ExitStatus::from_raw(1 << 8);
254        let detail = git_failure_detail(b"fatal: repository not found\n", b"ignored", status);
255        assert_eq!(detail, "fatal: repository not found");
256    }
257
258    #[test]
259    fn git_failure_detail_falls_back_to_stdout_when_stderr_empty() {
260        use std::os::unix::process::ExitStatusExt;
261        let status = std::process::ExitStatus::from_raw(1 << 8);
262        let detail = git_failure_detail(b"", b"index locked\n", status);
263        assert_eq!(detail, "index locked");
264    }
265
266    #[test]
267    fn git_failure_detail_scrubs_credentials_in_stderr() {
268        use std::os::unix::process::ExitStatusExt;
269        let status = std::process::ExitStatus::from_raw(1 << 8);
270        let detail = git_failure_detail(
271            b"fatal: could not read from https://user:tok@github.com/x.git\n",
272            b"",
273            status,
274        );
275        assert!(!detail.contains("tok"), "credential leaked: {detail}");
276    }
277
278    #[test]
279    fn git_failure_detail_strips_control_and_ansi_sequences() {
280        use std::os::unix::process::ExitStatusExt;
281        let status = std::process::ExitStatus::from_raw(1 << 8);
282        let hostile = b"\x1b[2Jcleared\x1b]0;title";
283        let detail = git_failure_detail(hostile, b"", status);
284        assert_eq!(detail, "[2Jcleared]0;title");
285    }
286
287    #[test]
288    fn git_failure_detail_falls_back_to_exit_code() {
289        use std::os::unix::process::ExitStatusExt;
290        let status = std::process::ExitStatus::from_raw(128 << 8);
291        let detail = git_failure_detail(b"   \n", b"", status);
292        assert!(detail.contains("exit code"), "got: {detail}");
293    }
294
295    #[cfg(test)]
296    mod prop_tests {
297        use super::*;
298        use proptest::prelude::*;
299
300        proptest! {
301            #[test]
302            fn parse_commit_count_valid_numeric(count_val in 0u32..100_000) {
303                let bytes = format!("{count_val}").into_bytes();
304                prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
305            }
306
307            #[test]
308            fn parse_commit_count_whitespace_trimmed(count_val in 0u32..100_000) {
309                let bytes = format!("  {count_val}  \n").into_bytes();
310                prop_assert_eq!(parse_commit_count(&bytes), Some(count_val));
311            }
312
313            #[test]
314            fn parse_commit_count_malformed_returns_none(junk in ".*") {
315                // Must not panic on arbitrary input; non-numeric → None
316                let _ = parse_commit_count(junk.as_bytes());
317            }
318
319            #[test]
320            fn sanitize_git_url_no_panic(s in ".*") {
321                let _ = sanitize_git_url(&s);
322            }
323
324            #[test]
325            fn sanitize_git_url_idempotent(s in ".*") {
326                let once = sanitize_git_url(&s);
327                let twice = sanitize_git_url(&once);
328                prop_assert_eq!(once, twice);
329            }
330
331            #[test]
332            fn sanitize_git_url_scrubs_http_credentials(
333                user in "[a-zA-Z0-9_]+",
334                pass in "[a-zA-Z0-9_]+",
335                host in "[a-zA-Z0-9.-]+",
336                path in "[a-zA-Z0-9/_.-]*"
337            ) {
338                let url = format!("https://{user}:{pass}@{host}/{path}");
339                let sanitized = sanitize_git_url(&url);
340                // The credential sequence user:pass@ must be gone; pass may
341                // legitimately appear in the host or path, so we check the
342                // full credential prefix, not the password alone.
343                prop_assert!(!sanitized.contains(&format!("{user}:{pass}@")),
344                    "credentials leaked in: {sanitized}");
345                prop_assert!(sanitized.contains("***@"), "expected *** in: {sanitized}");
346            }
347        }
348    }
349}