Skip to main content

git_ark/
hooks.rs

1use anyhow::{Context, Result};
2use std::path::Path;
3
4/// Quote a string for POSIX `sh`: wrap in single quotes and escape any
5/// embedded single quote as '\''. Inside single quotes sh treats everything
6/// literally (no $(...), no backticks), so this is injection-safe.
7fn sh_quote(s: &str) -> String {
8    format!("'{}'", s.replace('\'', "'\\''"))
9}
10
11pub fn render_post_receive(binary: &Path, repo: &Path, config: &Path) -> String {
12    // The hook ignores stdin (the ref updates); git already applied them.
13    // Backup runs synchronously so its output streams to the client as `remote:` lines.
14    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        // The dangerous path must appear fully single-quoted, so sh can't substitute.
85        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        // Embedded single quote becomes '\'' inside the quoting.
99        assert!(
100            s.contains(r"'/srv/repos/a'\''b.git'"),
101            "embedded single quote must be escaped as '\\''; got:\n{s}"
102        );
103    }
104}