1use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Once;
27
28const COUNT: &str = "GIT_CONFIG_COUNT";
29const HOOKS_PATH_KEY: &str = "core.hooksPath";
30const MAX_ENTRIES: usize = 256;
32const MARKER: &str = "gh-agent publish guard";
34
35pub const META_KEY_PTYSHELL_GIT_HOOKS_PATH: &str = "ptyshell:githookspath";
40
41pub fn gh_agent_hooks_dir(get: impl Fn(&str) -> Option<String>, home: &Path, windows: bool) -> PathBuf {
45 let get = |k: &str| get(k).filter(|v| !v.is_empty());
46 if let Some(dir) = get("GH_AGENT_HOOKS_DIR") {
47 return PathBuf::from(dir);
48 }
49 if windows {
50 let base = get("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home.join("AppData").join("Local"));
51 return base.join("gh-agent-hooks");
52 }
53 let base = get("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local").join("share"));
54 base.join("gh-agent").join("hooks")
55}
56
57pub fn is_guard_hooks_dir(dir: &Path) -> bool {
59 std::fs::read_to_string(dir.join("pre-push")).map(|s| s.contains(MARKER)).unwrap_or(false)
60}
61
62fn parse_count(get: &impl Fn(&str) -> Option<String>) -> usize {
63 get(COUNT)
64 .and_then(|c| c.trim().parse().ok())
65 .filter(|c: &usize| *c <= MAX_ENTRIES)
66 .unwrap_or(0)
67}
68
69pub fn git_config_entries(get: impl Fn(&str) -> Option<String>, key: &str, value: &str) -> Vec<(String, String)> {
74 let count = parse_count(&get);
75 let mut out: Vec<(String, String)> = (0..count)
76 .filter(|i| get(&format!("GIT_CONFIG_KEY_{i}")).is_some_and(|k| k.eq_ignore_ascii_case(key)))
77 .map(|i| (format!("GIT_CONFIG_VALUE_{i}"), value.to_string()))
78 .collect();
79 if !out.is_empty() {
80 out.push((COUNT.to_string(), count.to_string()));
81 return out;
82 }
83 vec![
84 (format!("GIT_CONFIG_KEY_{count}"), key.to_string()),
85 (format!("GIT_CONFIG_VALUE_{count}"), value.to_string()),
86 (COUNT.to_string(), (count + 1).to_string()),
87 ]
88}
89
90pub fn without_hooks_path(env: &HashMap<String, String>) -> HashMap<String, String> {
94 let get = |k: &str| env.get(k).cloned();
95 let count = parse_count(&get);
96 let kept: Vec<(String, String)> = (0..count)
97 .filter_map(|i| Some((get(&format!("GIT_CONFIG_KEY_{i}"))?, get(&format!("GIT_CONFIG_VALUE_{i}")).unwrap_or_default())))
98 .filter(|(k, _)| !k.eq_ignore_ascii_case(HOOKS_PATH_KEY))
99 .collect();
100 if kept.len() == count {
101 return env.clone();
102 }
103 let mut out: HashMap<String, String> = env
104 .iter()
105 .filter(|(k, _)| k.as_str() != COUNT && !k.starts_with("GIT_CONFIG_KEY_") && !k.starts_with("GIT_CONFIG_VALUE_"))
106 .map(|(k, v)| (k.clone(), v.clone()))
107 .collect();
108 for (i, (k, v)) in kept.iter().enumerate() {
109 out.insert(format!("GIT_CONFIG_KEY_{i}"), k.clone());
110 out.insert(format!("GIT_CONFIG_VALUE_{i}"), v.clone());
111 }
112 if !kept.is_empty() {
113 out.insert(COUNT.to_string(), kept.len().to_string());
114 }
115 out
116}
117
118fn env_var(env: &HashMap<String, String>, key: &str, windows: bool) -> Option<String> {
120 env.get(key)
121 .or_else(|| windows.then(|| env.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v)).flatten())
122 .cloned()
123}
124
125pub fn hooks_dir_for(env: &HashMap<String, String>, process: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
128 let get = |k: &str| env_var(env, k, cfg!(windows)).or_else(|| process(k));
129 let home = get(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
130 .filter(|h| !h.is_empty())
131 .map(PathBuf::from)
132 .or_else(dirs::home_dir)
133 .unwrap_or_default();
134 let dir = gh_agent_hooks_dir(&get, &home, cfg!(windows));
135 is_guard_hooks_dir(&dir).then_some(dir)
136}
137
138pub fn apply_publish_guard_in(env: &mut HashMap<String, String>, hooks_dir: &Path, process: impl Fn(&str) -> Option<String>) {
140 let entries = git_config_entries(|k| env.get(k).cloned().or_else(|| process(k)), HOOKS_PATH_KEY, &hooks_dir.to_string_lossy());
141 env.extend(entries);
142}
143
144#[cfg_attr(test, allow(dead_code))]
148pub fn apply_publish_guard(env: &mut HashMap<String, String>) {
149 let process = |k: &str| std::env::var(k).ok();
150 match hooks_dir_for(env, process) {
151 Some(dir) => apply_publish_guard_in(env, &dir, process),
152 None => {
153 static WARN: Once = Once::new();
154 WARN.call_once(|| {
155 tracing::warn!(
156 "publish guard: gh-agent's hooks are not written, so agents' pushes are not scanned; \
157 install gh-agent 0.5 or later and run `gh-agent setup-git`, then restart agents"
158 );
159 });
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
169 pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
170 }
171
172 fn no_process(_: &str) -> Option<String> {
173 None
174 }
175
176 fn guard_dir() -> tempfile::TempDir {
177 let dir = tempfile::tempdir().unwrap();
178 std::fs::write(dir.path().join("pre-push"), format!("#!/bin/sh\n# {MARKER}: test\n")).unwrap();
179 dir
180 }
181
182 #[test]
183 fn hooks_dir_follows_gh_agent() {
184 let home = Path::new("/home/a");
185 let none = |_: &str| None;
186 assert_eq!(gh_agent_hooks_dir(none, home, false), PathBuf::from("/home/a/.local/share/gh-agent/hooks"));
187 assert_eq!(
188 gh_agent_hooks_dir(|k| (k == "XDG_DATA_HOME").then(|| "/x".to_string()), home, false),
189 PathBuf::from("/x/gh-agent/hooks")
190 );
191 assert_eq!(
192 gh_agent_hooks_dir(|k| (k == "LOCALAPPDATA").then(|| "C:/L".to_string()), home, true),
193 PathBuf::from("C:/L/gh-agent-hooks")
194 );
195 assert_eq!(gh_agent_hooks_dir(none, home, true), PathBuf::from("/home/a/AppData/Local/gh-agent-hooks"));
196 assert_eq!(
197 gh_agent_hooks_dir(|k| (k == "GH_AGENT_HOOKS_DIR").then(|| "/o".to_string()), home, true),
198 PathBuf::from("/o")
199 );
200 }
201
202 #[test]
203 fn the_agents_own_env_decides_where_the_hooks_are() {
204 let dir = guard_dir();
205 let agent = env(&[("GH_AGENT_HOOKS_DIR", &dir.path().to_string_lossy())]);
206 assert_eq!(hooks_dir_for(&agent, no_process).as_deref(), Some(dir.path()));
207 let p = dir.path().to_string_lossy().into_owned();
209 assert_eq!(hooks_dir_for(&HashMap::new(), |k| (k == "GH_AGENT_HOOKS_DIR").then(|| p.clone())).as_deref(), Some(dir.path()));
210 let empty = tempfile::tempdir().unwrap();
212 let agent = env(&[("GH_AGENT_HOOKS_DIR", &empty.path().to_string_lossy())]);
213 assert_eq!(hooks_dir_for(&agent, no_process), None);
214 std::fs::write(empty.path().join("pre-push"), "#!/bin/sh\n# someone else's hook\n").unwrap();
215 assert_eq!(hooks_dir_for(&agent, no_process), None);
216 }
217
218 #[test]
219 fn windows_variable_names_match_in_any_casing() {
220 let e = env(&[("Gh_Agent_Hooks_Dir", "/h")]);
221 assert_eq!(env_var(&e, "GH_AGENT_HOOKS_DIR", true).as_deref(), Some("/h"));
222 assert_eq!(env_var(&e, "GH_AGENT_HOOKS_DIR", false), None);
223 }
224
225 #[test]
226 fn sets_core_hooks_path_through_the_environment() {
227 let dir = guard_dir();
228 let mut e = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
229 apply_publish_guard_in(&mut e, dir.path(), no_process);
230 assert_eq!(e[COUNT], "1");
231 assert_eq!(e["GIT_CONFIG_KEY_0"], HOOKS_PATH_KEY);
232 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
233 }
234
235 #[test]
236 fn appends_after_entries_already_there_and_replaces_its_own() {
237 let dir = guard_dir();
238 let mut e = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "user.name"), ("GIT_CONFIG_VALUE_0", "x")]);
239 apply_publish_guard_in(&mut e, dir.path(), no_process);
240 assert_eq!(e[COUNT], "2");
241 assert_eq!(e["GIT_CONFIG_KEY_0"], "user.name");
242 assert_eq!(e["GIT_CONFIG_KEY_1"], HOOKS_PATH_KEY);
243 e.insert("GIT_CONFIG_VALUE_1".into(), "/somewhere/else".into());
245 apply_publish_guard_in(&mut e, dir.path(), no_process);
246 assert_eq!(e[COUNT], "2");
247 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_1"]), dir.path());
248 }
249
250 #[test]
251 fn a_hooks_path_entry_from_elsewhere_is_replaced_not_kept() {
252 let dir = guard_dir();
253 let mut e = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "core.hookspath"), ("GIT_CONFIG_VALUE_0", "/dev/null")]);
254 apply_publish_guard_in(&mut e, dir.path(), no_process);
255 assert_eq!(e[COUNT], "1");
256 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
257 }
258
259 #[test]
260 fn builds_on_entries_the_process_env_already_has() {
261 let dir = guard_dir();
262 let mut e = HashMap::new();
263 let process = |k: &str| match k {
264 "GIT_CONFIG_COUNT" => Some("2".to_string()),
265 "GIT_CONFIG_KEY_1" => Some("core.hooksPath".to_string()),
266 _ => None,
267 };
268 apply_publish_guard_in(&mut e, dir.path(), process);
269 assert_eq!(e[COUNT], "2", "reuses the process env's own hooksPath slot");
270 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_1"]), dir.path());
271 assert!(!e.contains_key("GIT_CONFIG_KEY_0"), "the other inherited slot is left alone");
272 }
273
274 #[test]
275 fn every_hooks_path_slot_gets_the_guard_because_git_uses_the_last() {
276 let dir = guard_dir();
277 let mut e = env(&[
278 (COUNT, "3"),
279 ("GIT_CONFIG_KEY_0", "core.hooksPath"),
280 ("GIT_CONFIG_VALUE_0", "/old"),
281 ("GIT_CONFIG_KEY_1", "user.name"),
282 ("GIT_CONFIG_VALUE_1", "x"),
283 ("GIT_CONFIG_KEY_2", "CORE.HOOKSPATH"),
284 ("GIT_CONFIG_VALUE_2", "/dev/null"),
285 ]);
286 apply_publish_guard_in(&mut e, dir.path(), no_process);
287 assert_eq!(e[COUNT], "3");
288 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
289 assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_2"]), dir.path());
290 assert_eq!(e["GIT_CONFIG_VALUE_1"], "x");
291 }
292
293 #[test]
294 fn a_container_keeps_its_other_git_settings_but_not_the_host_hooks_path() {
295 let e = env(&[
296 (COUNT, "3"),
297 ("GIT_CONFIG_KEY_0", "url.x.insteadOf"),
298 ("GIT_CONFIG_VALUE_0", "y"),
299 ("GIT_CONFIG_KEY_1", "core.hooksPath"),
300 ("GIT_CONFIG_VALUE_1", "/host/hooks"),
301 ("GIT_CONFIG_KEY_2", "http.sslCAInfo"),
302 ("GIT_CONFIG_VALUE_2", "/ca.pem"),
303 ("OTHER", "kept"),
304 ]);
305 let out = without_hooks_path(&e);
306 assert_eq!(out[COUNT], "2");
307 assert_eq!(out["GIT_CONFIG_KEY_0"], "url.x.insteadOf");
308 assert_eq!(out["GIT_CONFIG_KEY_1"], "http.sslCAInfo");
309 assert_eq!(out["GIT_CONFIG_VALUE_1"], "/ca.pem");
310 assert!(!out.contains_key("GIT_CONFIG_KEY_2"));
311 assert_eq!(out["OTHER"], "kept");
312 let only = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "core.hooksPath"), ("GIT_CONFIG_VALUE_0", "/h")]);
313 assert!(without_hooks_path(&only).is_empty(), "nothing left: no count either");
314 let untouched = env(&[("A", "b")]);
315 assert_eq!(without_hooks_path(&untouched), untouched);
316 }
317
318 #[test]
319 fn an_absurd_count_is_not_trusted_or_looped_over() {
320 let dir = guard_dir();
321 let mut e = env(&[(COUNT, "999999999999")]);
322 apply_publish_guard_in(&mut e, dir.path(), no_process);
323 assert_eq!(e[COUNT], "1");
324 assert_eq!(e["GIT_CONFIG_KEY_0"], HOOKS_PATH_KEY);
325 }
326
327 #[test]
328 fn entries_for_a_pty_spawn_keep_the_slots_its_env_already_uses() {
329 let existing = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "url.x.insteadOf"), ("GIT_CONFIG_VALUE_0", "y")]);
331 let pairs = git_config_entries(|k| existing.get(k).cloned(), HOOKS_PATH_KEY, "/h");
332 assert!(pairs.contains(&("GIT_CONFIG_KEY_1".to_string(), HOOKS_PATH_KEY.to_string())));
333 assert!(pairs.contains(&("GIT_CONFIG_VALUE_1".to_string(), "/h".to_string())));
334 assert!(pairs.contains(&(COUNT.to_string(), "2".to_string())));
335 }
336}