1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
//! Git subprocess construction immune to hook-injected repository targeting.
//!
//! Git exports `GIT_DIR` (and related variables) to processes it spawns from
//! hooks. A forjar invocation inside a pre-push/post-commit hook would
//! therefore run its own `git` subprocesses against the *hook's* repository
//! instead of the stack's — `current_dir()` is ignored once `GIT_DIR` is set
//! (GH-134). Every production `git` invocation must go through these
//! constructors.
use std::path::Path;
use std::process::Command;
/// Environment variables through which git redirects repository discovery.
const GIT_REPO_ENV: [&str; 9] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_COMMON_DIR",
"GIT_NAMESPACE",
"GIT_PREFIX",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
];
/// A `git` command whose repository is resolved solely from the process cwd.
pub fn git() -> Command {
let mut cmd = Command::new("git");
for var in GIT_REPO_ENV {
cmd.env_remove(var);
}
cmd
}
/// A `git` command whose repository is resolved solely from `dir`.
pub fn git_in(dir: &Path) -> Command {
let mut cmd = git();
cmd.current_dir(dir);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_scrubs_all_repo_env_vars() {
let cmd = git();
let removed: Vec<_> = cmd
.get_envs()
.filter(|(_, v)| v.is_none())
.map(|(k, _)| k.to_os_string())
.collect();
for var in GIT_REPO_ENV {
assert!(
removed.iter().any(|k| k == var),
"expected {var} to be scrubbed"
);
}
}
#[test]
fn git_in_sets_cwd_and_scrubs() {
let dir = tempfile::tempdir().unwrap();
let cmd = git_in(dir.path());
assert_eq!(cmd.get_current_dir(), Some(dir.path()));
assert!(cmd.get_envs().any(|(k, v)| k == "GIT_DIR" && v.is_none()));
}
/// Demonstrates the GH-134 failure mode at the Command level: an
/// explicit GIT_DIR overrides current_dir for repository targeting,
/// while the scrubbed constructor resolves the cwd repository.
#[test]
fn git_dir_env_overrides_cwd_but_scrubbed_command_is_immune() {
let repo = tempfile::tempdir().unwrap();
let decoy = tempfile::tempdir().unwrap();
for dir in [repo.path(), decoy.path()] {
let ok = git_in(dir).args(["init", "-q"]).status().unwrap();
assert!(ok.success());
}
// Unscrubbed + GIT_DIR (as inside a git hook): the decoy wins.
let out = Command::new("git")
.current_dir(repo.path())
.env("GIT_DIR", decoy.path().join(".git"))
.args(["rev-parse", "--absolute-git-dir"])
.output()
.unwrap();
let hijacked = String::from_utf8_lossy(&out.stdout);
assert!(
hijacked
.trim()
.starts_with(&*decoy.path().to_string_lossy()),
"expected GIT_DIR to hijack discovery, got: {hijacked}"
);
// Scrubbed constructor: cwd repository wins. (The scrub list is
// applied by git_in; the decoy var cannot be re-injected here
// without defeating the test, so this asserts the cwd resolution.)
let out = git_in(repo.path())
.args(["rev-parse", "--absolute-git-dir"])
.output()
.unwrap();
let resolved = String::from_utf8_lossy(&out.stdout);
let canon = repo.path().canonicalize().unwrap();
let canon_resolved = Path::new(resolved.trim())
.canonicalize()
.unwrap_or_default();
assert!(
canon_resolved.starts_with(&canon),
"expected {canon:?} prefix, got {canon_resolved:?}"
);
}
}