1use anyhow::{Context, Result};
2use std::path::Path;
3
4fn sh_quote(s: &str) -> String {
8 format!("'{}'", s.replace('\'', "'\\''"))
9}
10
11pub fn render_post_receive(binary: &Path, repo: &Path, config: &Path) -> String {
12 format!(
15 "#!/bin/sh\n\
16 # Installed by git-ark. Runs the encrypted backup after each push.\n\
17 exec {} backup {} --config {}\n",
18 sh_quote(&binary.display().to_string()),
19 sh_quote(&repo.display().to_string()),
20 sh_quote(&config.display().to_string()),
21 )
22}
23
24pub fn install_post_receive(repo: &Path, binary: &Path, config: &Path) -> Result<()> {
25 let hooks_dir = repo.join("hooks");
26 std::fs::create_dir_all(&hooks_dir)
27 .with_context(|| format!("creating {}", hooks_dir.display()))?;
28 let hook = hooks_dir.join("post-receive");
29 std::fs::write(&hook, render_post_receive(binary, repo, config))
30 .with_context(|| format!("writing {}", hook.display()))?;
31 #[cfg(unix)]
32 {
33 use std::os::unix::fs::PermissionsExt;
34 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
35 }
36 Ok(())
37}
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42 use std::path::Path;
43
44 #[test]
45 fn hook_text_invokes_backup_with_paths() {
46 let s = render_post_receive(
47 Path::new("/opt/git-ark/bin/git-ark"),
48 Path::new("/srv/repos/x.git"),
49 Path::new("/opt/git-ark/config.toml"),
50 );
51 assert!(s.starts_with("#!/bin/sh"));
52 assert!(s.contains("/opt/git-ark/bin/git-ark"));
53 assert!(s.contains("backup"));
54 assert!(s.contains("/srv/repos/x.git"));
55 assert!(s.contains("--config"));
56 }
57
58 #[cfg(unix)]
59 #[test]
60 fn install_writes_executable_hook() {
61 use std::os::unix::fs::PermissionsExt;
62 let d = tempfile::tempdir().unwrap();
63 let repo = d.path().join("x.git");
64 std::fs::create_dir_all(repo.join("hooks")).unwrap();
65 install_post_receive(
66 &repo,
67 Path::new("/opt/git-ark/bin/git-ark"),
68 Path::new("/opt/git-ark/config.toml"),
69 )
70 .unwrap();
71 let hook = repo.join("hooks/post-receive");
72 assert!(hook.exists());
73 let mode = std::fs::metadata(&hook).unwrap().permissions().mode() & 0o777;
74 assert_eq!(mode & 0o100, 0o100, "owner-executable bit must be set");
75 }
76
77 #[test]
78 fn hook_quoting_neutralizes_command_substitution() {
79 let s = render_post_receive(
80 Path::new("/opt/git-ark/bin/git-ark"),
81 Path::new("/srv/repos/x$(touch /tmp/pwned).git"),
82 Path::new("/opt/git-ark/config.toml"),
83 );
84 assert!(
86 s.contains("'/srv/repos/x$(touch /tmp/pwned).git'"),
87 "metachar path must be single-quoted; got:\n{s}"
88 );
89 }
90
91 #[test]
92 fn hook_quoting_escapes_embedded_single_quote() {
93 let s = render_post_receive(
94 Path::new("/opt/git-ark/bin/git-ark"),
95 Path::new("/srv/repos/a'b.git"),
96 Path::new("/opt/git-ark/config.toml"),
97 );
98 assert!(
100 s.contains(r"'/srv/repos/a'\''b.git'"),
101 "embedded single quote must be escaped as '\\''; got:\n{s}"
102 );
103 }
104}