Skip to main content

cfgd_core/util/
git.rs

1use super::constants::GIT_NETWORK_TIMEOUT;
2use super::paths::home_dir_var;
3use super::process::{command_output_with_timeout, stderr_lossy_trimmed, stdout_lossy_trimmed};
4use crate::config;
5
6/// Prepare a `git` CLI command with SSH hang protection.
7///
8/// Sets `GIT_TERMINAL_PROMPT=0` to prevent interactive prompts and, for SSH URLs,
9/// sets `GIT_SSH_COMMAND` with `BatchMode=yes` and configurable `StrictHostKeyChecking`
10/// to prevent hangs in non-interactive contexts (piped install scripts, daemons).
11///
12/// The `ssh_policy` parameter controls the `StrictHostKeyChecking` value:
13/// - `None` uses the default (`accept-new`)
14/// - `Some(policy)` uses the specified policy
15pub fn git_cmd_safe(
16    url: Option<&str>,
17    ssh_policy: Option<config::SshHostKeyPolicy>,
18) -> std::process::Command {
19    let mut cmd = std::process::Command::new("git");
20    // git spawns credential-helper grandchildren (osxkeychain on macOS,
21    // git-credential-manager-core on Windows) that inherit stdout/stderr
22    // pipes and outlive the watchdog's SIGKILL of the immediate `git`, leaving
23    // `wait_with_output` blocked on the still-open pipes. Disable every
24    // interactive helper at spawn so the grandchild never launches.
25    // `/dev/null` on unix, `NUL` on windows — git treats either as an empty
26    // config file when assigned to GIT_CONFIG_GLOBAL.
27    #[cfg(unix)]
28    let null_config = "/dev/null";
29    #[cfg(windows)]
30    let null_config = "NUL";
31    cmd.env("GIT_TERMINAL_PROMPT", "0")
32        .env("GIT_ASKPASS", "true")
33        .env("SSH_ASKPASS", "true")
34        .env("GIT_CONFIG_NOSYSTEM", "1")
35        .env("GIT_CONFIG_GLOBAL", null_config)
36        .stdin(std::process::Stdio::null())
37        .stdout(std::process::Stdio::null())
38        .stderr(std::process::Stdio::piped());
39    if url.is_some_and(|u| u.starts_with("git@") || u.starts_with("ssh://")) {
40        let policy = ssh_policy.unwrap_or_default();
41        cmd.env(
42            "GIT_SSH_COMMAND",
43            format!(
44                "ssh -o BatchMode=yes -o StrictHostKeyChecking={}",
45                policy.as_ssh_option()
46            ),
47        );
48    }
49    cmd
50}
51
52/// Build a `Command` for git suitable for LOCAL operations (config get/set,
53/// tag verify, add, commit, log). Sets `GIT_TERMINAL_PROMPT=0` to prevent
54/// any prompt-driven hang, but does NOT set `GIT_SSH_COMMAND` because no
55/// network is involved. Use [`git_cmd_safe`] for any operation that talks to
56/// a remote.
57pub fn git_cmd_local() -> std::process::Command {
58    let mut cmd = std::process::Command::new("git");
59    cmd.env("GIT_TERMINAL_PROMPT", "0");
60    cmd
61}
62
63/// Try a git CLI command via [`git_cmd_safe`], returning `true` on success.
64/// On failure, logs the stderr via `tracing::debug` and returns `false`.
65pub fn try_git_cmd(
66    url: Option<&str>,
67    args: &[&str],
68    label: &str,
69    ssh_policy: Option<config::SshHostKeyPolicy>,
70) -> bool {
71    let mut cmd = git_cmd_safe(url, ssh_policy);
72    cmd.args(args);
73    match command_output_with_timeout(&mut cmd, GIT_NETWORK_TIMEOUT) {
74        Ok(output) if output.status.success() => true,
75        Ok(output) => {
76            tracing::debug!(
77                "git {} CLI failed (exit {}): {}",
78                label,
79                output.status.code().unwrap_or(-1),
80                stderr_lossy_trimmed(&output),
81            );
82            false
83        }
84        Err(e) => {
85            tracing::debug!("git {} CLI unavailable: {e}", label);
86            false
87        }
88    }
89}
90
91/// Env-var seam name for the cosign binary path. See [`tool_binary_name`].
92pub const COSIGN_BIN_ENV: &str = "CFGD_COSIGN_BIN";
93
94/// Build a base `cosign` `Command` — the shared factory for signature / attestation
95/// operations across `oci.rs`, `cli/module.rs`, and `upgrade.rs`.
96///
97/// Rationale: cosign is cfgd's controlled shell-out for Sigstore signature
98/// verification, the same architectural category as [`git_cmd_safe`] for git.
99/// Centralising the factory keeps invocation-site assumptions (stderr capture,
100/// future env / timeout hardening) uniform and lets the module-boundary audit
101/// point at one place instead of tracking every caller.
102///
103/// The binary name honors `CFGD_COSIGN_BIN` for tests via [`tool_cmd`].
104///
105/// Callers add their own subcommand (`sign`, `verify-blob`, `verify-attestation`,
106/// `attest`, etc.) and any additional flags.
107pub fn cosign_cmd() -> std::process::Command {
108    super::process::tool_cmd(COSIGN_BIN_ENV, "cosign")
109}
110
111/// Verify cosign is available, honoring the `CFGD_COSIGN_BIN` test seam.
112/// Delegates to [`require_tool_with_seam`] to share the env-var-override logic
113/// with every other shimmable tool in cfgd-core.
114pub fn require_cosign() -> std::result::Result<(), String> {
115    super::process::require_tool_with_seam(COSIGN_BIN_ENV, "cosign", None)
116}
117
118/// Best-effort detection of a local git repo's default branch.
119///
120/// Tries (in order) `origin/HEAD` symbolic-ref (the remote-tracking default),
121/// then the local `HEAD` symbolic-ref. Returns `None` when the directory is not
122/// a git repo, both refs are missing, or the `git` binary is unavailable.
123///
124/// Callers should supply their own fallback (cfgd convention: `"master"`).
125pub fn detect_default_branch(repo_dir: &std::path::Path) -> Option<String> {
126    let dir = repo_dir.display().to_string();
127
128    let mut cmd = git_cmd_safe(None, None);
129    cmd.args([
130        "-C",
131        &dir,
132        "symbolic-ref",
133        "--short",
134        "refs/remotes/origin/HEAD",
135    ])
136    .stdout(std::process::Stdio::piped());
137    if let Ok(output) = cmd.output()
138        && output.status.success()
139    {
140        let raw = stdout_lossy_trimmed(&output);
141        let stripped = raw.strip_prefix("origin/").unwrap_or(&raw);
142        if !stripped.is_empty() {
143            return Some(stripped.to_string());
144        }
145    }
146
147    let mut cmd = git_cmd_safe(None, None);
148    cmd.args(["-C", &dir, "symbolic-ref", "--short", "HEAD"])
149        .stdout(std::process::Stdio::piped());
150    if let Ok(output) = cmd.output()
151        && output.status.success()
152    {
153        let branch = stdout_lossy_trimmed(&output);
154        if !branch.is_empty() {
155            return Some(branch);
156        }
157    }
158
159    None
160}
161
162/// Git credential callback for git2 — handles SSH and HTTPS authentication.
163/// Used by sources/, modules/, and daemon/ for all git operations.
164///
165/// Tries in order:
166/// 1. SSH agent (for SSH URLs)
167/// 2. SSH key files: `~/.ssh/id_ed25519`, `~/.ssh/id_rsa` (for SSH URLs)
168/// 3. Git credential helper / GIT_ASKPASS (for HTTPS URLs)
169/// 4. Default system credentials
170pub fn git_ssh_credentials(
171    _url: &str,
172    username_from_url: Option<&str>,
173    allowed_types: git2::CredentialType,
174) -> std::result::Result<git2::Cred, git2::Error> {
175    let username = username_from_url.unwrap_or("git");
176
177    if allowed_types.contains(git2::CredentialType::SSH_KEY) {
178        if let Ok(cred) = git2::Cred::ssh_key_from_agent(username) {
179            return Ok(cred);
180        }
181        let home = home_dir_var().unwrap_or_default();
182        for key_name in &["id_ed25519", "id_rsa", "id_ecdsa"] {
183            let key_path = std::path::Path::new(&home).join(".ssh").join(key_name);
184            if key_path.exists()
185                && let Ok(cred) = git2::Cred::ssh_key(username, None, &key_path, None)
186            {
187                return Ok(cred);
188            }
189        }
190    }
191
192    if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
193        return git2::Cred::credential_helper(
194            &git2::Config::open_default()
195                .map_err(|e| git2::Error::from_str(&format!("cannot open git config: {e}")))?,
196            _url,
197            username_from_url,
198        );
199    }
200
201    if allowed_types.contains(git2::CredentialType::DEFAULT) {
202        return git2::Cred::default();
203    }
204
205    Err(git2::Error::from_str("no suitable credentials found"))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serial_test::serial;
212    use std::fs;
213
214    /// Saves and restores the `CFGD_COSIGN_BIN` env var so tests stay isolated
215    /// even when one panics. Pairs with `serial_test::serial` since env-var
216    /// mutation is process-global.
217    struct EnvVarGuard {
218        key: &'static str,
219        prior: Option<String>,
220    }
221
222    impl EnvVarGuard {
223        fn capture(key: &'static str) -> Self {
224            Self {
225                key,
226                prior: std::env::var(key).ok(),
227            }
228        }
229    }
230
231    impl Drop for EnvVarGuard {
232        fn drop(&mut self) {
233            // SAFETY: serial_test::serial gates execution; no concurrent reader.
234            unsafe {
235                match self.prior.take() {
236                    Some(v) => std::env::set_var(self.key, v),
237                    None => std::env::remove_var(self.key),
238                }
239            }
240        }
241    }
242
243    #[test]
244    fn git_cmd_local_sets_terminal_prompt_zero_and_no_ssh_env() {
245        let cmd = git_cmd_local();
246        let prog = std::path::Path::new(cmd.get_program())
247            .file_name()
248            .and_then(|s| s.to_str())
249            .unwrap_or("");
250        assert_eq!(prog, "git", "program must resolve to `git`");
251
252        let envs: std::collections::HashMap<&std::ffi::OsStr, Option<&std::ffi::OsStr>> =
253            cmd.get_envs().collect();
254        let term = envs
255            .get(std::ffi::OsStr::new("GIT_TERMINAL_PROMPT"))
256            .and_then(|v| v.as_deref())
257            .and_then(|s| s.to_str());
258        assert_eq!(
259            term,
260            Some("0"),
261            "GIT_TERMINAL_PROMPT must be set to 0 to prevent prompt-driven hangs"
262        );
263        assert!(
264            !envs.contains_key(std::ffi::OsStr::new("GIT_SSH_COMMAND")),
265            "git_cmd_local is for local-only ops and must not configure GIT_SSH_COMMAND"
266        );
267    }
268
269    #[test]
270    #[serial]
271    fn require_cosign_with_env_var_pointing_to_real_file_succeeds() {
272        let tmp = tempfile::TempDir::new().expect("tempdir");
273        let bin = tmp.path().join("anything");
274        fs::write(&bin, "").expect("write");
275
276        let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
277        // SAFETY: serial.
278        unsafe {
279            std::env::set_var("CFGD_COSIGN_BIN", &bin);
280        }
281        require_cosign().expect("env-var pointing to existing file → Ok");
282    }
283
284    #[test]
285    #[serial]
286    fn require_cosign_with_env_var_pointing_to_missing_file_errors_out() {
287        let _guard = EnvVarGuard::capture("CFGD_COSIGN_BIN");
288        // SAFETY: serial.
289        unsafe {
290            std::env::set_var("CFGD_COSIGN_BIN", "/no/such/file/at/all");
291        }
292        let err = require_cosign().expect_err("missing file → Err");
293        assert!(
294            err.contains("CFGD_COSIGN_BIN") && err.contains("not a file"),
295            "error must call out env-var + missing-file: {err}"
296        );
297    }
298
299    #[test]
300    fn detect_default_branch_on_current_repo() {
301        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
302            .parent()
303            .unwrap()
304            .parent()
305            .unwrap();
306        let result = detect_default_branch(repo_root);
307        assert!(
308            result.is_some(),
309            "should detect default branch in the cfgd repo"
310        );
311        let branch = result.unwrap();
312        assert!(!branch.is_empty(), "detected branch name must not be empty");
313    }
314
315    #[test]
316    fn detect_default_branch_returns_none_for_non_repo() {
317        let tmp = tempfile::TempDir::new().unwrap();
318        let result = detect_default_branch(tmp.path());
319        assert!(result.is_none(), "non-git directory must return None");
320    }
321
322    #[test]
323    fn detect_default_branch_on_fresh_init_repo() {
324        let tmp = tempfile::TempDir::new().unwrap();
325        let repo = git2::Repository::init(tmp.path()).unwrap();
326        let sig = git2::Signature::now("test", "test@test.com").unwrap();
327        let tree_id = repo.index().unwrap().write_tree().unwrap();
328        let tree = repo.find_tree(tree_id).unwrap();
329        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
330            .unwrap();
331        let result = detect_default_branch(tmp.path());
332        assert!(result.is_some());
333    }
334
335    #[test]
336    fn try_git_cmd_succeeds_on_version() {
337        let ok = try_git_cmd(None, &["--version"], "version-check", None);
338        assert!(ok, "git --version should succeed");
339    }
340
341    #[test]
342    fn try_git_cmd_fails_on_invalid_subcommand() {
343        let ok = try_git_cmd(None, &["not-a-real-subcommand-xyz"], "invalid-cmd", None);
344        assert!(!ok, "invalid git subcommand should return false");
345    }
346}