OXYGENHUB
# dab94366a4f0 oxygen / crates / oxy-core / src / repo / hooks.rs
4.5 KiB · Rust · last changed Unbound hosted repositories adopt the first pusher's id 1 month ago source blame history raw
1
//! Client hooks (spec §20): executables in `.oxy/hooks/<event>`, never
2
//! cloned. Each receives one JSON object on stdin; `pre-*` hooks block the
3
//! operation by exiting non-zero, and their stderr becomes the error. The
4
//! `semantic-deps` hook runs only when `hooks.semantic-deps = true` and
5
//! answers `{"deps": ["<hash hex>", ...]}`.
6
7
use super::*;
8
use std::process::{Command, Stdio};
9
10
/// Minimal JSON string escaping.
11
pub fn json_str(s: &str) -> String {
12
    let mut out = String::with_capacity(s.len() + 2);
13
    out.push('"');
14
    for c in s.chars() {
15
        match c {
16
            '"' => out.push_str("\\\""),
17
            '\\' => out.push_str("\\\\"),
18
            '\n' => out.push_str("\\n"),
19
            '\r' => out.push_str("\\r"),
20
            '\t' => out.push_str("\\t"),
21
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
22
            c => out.push(c),
23
        }
24
    }
25
    out.push('"');
26
    out
27
}
28
29
pub fn json_array<I: IntoIterator<Item = String>>(items: I) -> String {
30
    format!("[{}]", items.into_iter().collect::<Vec<_>>().join(","))
31
}
32
33
/// What running a hook produced.
34
#[derive(Debug, Clone, PartialEq, Eq)]
35
pub struct HookOutcome {
36
    pub ran: bool,
37
    pub stdout: String,
38
}
39
40
impl Repo {
41
    fn hook_path(&self, event: &str) -> PathBuf {
42
        self.oxy.join("hooks").join(event)
43
    }
44
45
    /// Run the hook for `event` with `input` on stdin, if one exists and is
46
    /// executable. A non-zero exit is an error carrying the hook's stderr.
47
    pub fn run_hook(&self, event: &str, input: &str) -> Result<HookOutcome> {
48
        let path = self.hook_path(event);
49
        if !path.is_file() {
50
            return Ok(HookOutcome {
51
                ran: false,
52
                stdout: String::new(),
53
            });
54
        }
55
        let mut child = Command::new(&path)
56
            .current_dir(&self.root)
57
            .env("OXY_EVENT", event)
58
            .env("OXY_ROOT", &self.root)
59
            .stdin(Stdio::piped())
60
            .stdout(Stdio::piped())
61
            .stderr(Stdio::piped())
62
            .spawn()
63
            .map_err(|e| RepoError::Msg(format!("running hook {event}: {e}")))?;
64
        if let Some(mut stdin) = child.stdin.take() {
65
            let _ = stdin.write_all(input.as_bytes());
66
        }
67
        let out = child.wait_with_output()?;
68
        let stdout = String::from_utf8_lossy(&out.stdout).to_string();
69
        if !out.status.success() {
70
            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
71
            return fail(format!(
72
                "hook {event} refused: {}",
73
                if stderr.is_empty() {
74
                    "(no message)".to_string()
75
                } else {
76
                    stderr
77
                }
78
            ));
79
        }
80
        Ok(HookOutcome { ran: true, stdout })
81
    }
82
83
    /// Extra dependencies from the `semantic-deps` hook (spec §8.1, §20).
84
    pub(crate) fn semantic_deps(
85
        &self,
86
        change_id: &ChangeId,
87
        touched: &[String],
88
        candidates: &[(ChangeId, ChangeHash, Vec<String>)],
89
    ) -> Result<Vec<ChangeHash>> {
90
        if !self.config.semantic_deps {
91
            return Ok(vec![]);
92
        }
93
        let input = format!(
94
            "{{\"change_id\":{},\"touched_paths\":{},\"candidate_deps\":{}}}",
95
            json_str(&change_id.to_hex()),
96
            json_array(touched.iter().map(|p| json_str(p))),
97
            json_array(candidates.iter().map(|(id, h, paths)| format!(
98
                "{{\"change_id\":{},\"hash\":{},\"paths\":{}}}",
99
                json_str(&id.to_hex()),
100
                json_str(&h.to_hex()),
101
                json_array(paths.iter().map(|p| json_str(p)))
102
            )))
103
        );
104
        let out = self.run_hook("semantic-deps", &input)?;
105
        if !out.ran {
106
            return Ok(vec![]);
107
        }
108
        // Accept every 64-hex token after "deps"; any other shape is ignored.
109
        let body = out
110
            .stdout
111
            .split_once("\"deps\"")
112
            .map(|(_, rest)| rest.to_string())
113
            .unwrap_or_default();
114
        let mut deps = Vec::new();
115
        let bytes = body.as_bytes();
116
        let mut i = 0;
117
        while i + 64 <= bytes.len() {
118
            if bytes[i..i + 64].iter().all(|b| b.is_ascii_hexdigit())
119
                && (i == 0 || !bytes[i - 1].is_ascii_hexdigit())
120
                && (i + 64 == bytes.len() || !bytes[i + 64].is_ascii_hexdigit())
121
            {
122
                if let Some(h) = ObjectRef::from_hex(&body[i..i + 64]) {
123
                    deps.push(h);
124
                }
125
                i += 64;
126
            } else {
127
                i += 1;
128
            }
129
        }
130
        Ok(deps)
131
    }
132
}