Skip to main content

torii_lib/util/
hooks.rs

1use std::path::Path;
2use std::process::Command;
3use std::time::Instant;
4use anyhow::{Result, anyhow};
5
6use crate::toriignore::{HookRules, SizeRules, glob_match};
7
8/// Execute every hook command in order. First non-zero exit aborts.
9///
10/// **Security:** `.toriignore` lives in the repo, so cloning a hostile repo
11/// would otherwise let it run arbitrary `sh -c …` on the very first
12/// `torii save`. Before executing, we require the user to have *trusted*
13/// this exact set of commands for this exact repo path. Trust is stored in
14/// `~/.config/torii/hook-trust.toml` keyed by repo + sha256(commands).
15///
16/// Bypass:
17///   `TORII_TRUST_HOOKS=1` — skip the prompt (CI / scripted use)
18///   `TORII_NO_HOOKS=1`    — skip hooks entirely
19///   `--skip-hooks` flag   — same as above for one invocation
20pub fn run_hooks(label: &str, commands: &[String], repo: &Path) -> Result<()> {
21    if commands.is_empty() { return Ok(()); }
22    if std::env::var("TORII_NO_HOOKS").is_ok() {
23        return Ok(());
24    }
25
26    if !is_trusted(repo, commands)? {
27        if std::env::var("TORII_TRUST_HOOKS").is_ok() {
28            // Implicit trust on CI; remember so subsequent runs don't re-trigger.
29            mark_trusted(repo, commands)?;
30        } else if !prompt_trust(repo, label, commands)? {
31            return Err(anyhow!(
32                "hook execution declined. Re-run with TORII_TRUST_HOOKS=1 to trust, \
33                 TORII_NO_HOOKS=1 to skip, or --skip-hooks for this invocation."
34            ));
35        }
36    }
37
38    println!("🪝 {} hooks: {} command(s)", label, commands.len());
39    for cmd in commands {
40        let start = Instant::now();
41        print!("   → {} ", cmd);
42        use std::io::Write;
43        std::io::stdout().flush().ok();
44
45        let status = Command::new("sh")
46            .arg("-c")
47            .arg(cmd)
48            .current_dir(repo)
49            .status()
50            .map_err(|e| anyhow!("failed to spawn `{}`: {}", cmd, e))?;
51
52        let dur = start.elapsed();
53        if !status.success() {
54            let code = status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into());
55            return Err(anyhow!(
56                "hook failed: `{}` exited with {} after {:.2}s — fix the issue or rerun with --skip-hooks",
57                cmd, code, dur.as_secs_f64()
58            ));
59        }
60        println!("✓ ({:.2}s)", dur.as_secs_f64());
61    }
62    Ok(())
63}
64
65// ── Trust store ──────────────────────────────────────────────────────────────
66
67fn trust_file_path() -> Option<std::path::PathBuf> {
68    dirs::config_dir().map(|d| d.join("torii").join("hook-trust.toml"))
69}
70
71/// SHA256 of the joined command list. Cheap, deterministic, no extra dep —
72/// stdlib lacks sha256 so we use a small FNV-1a 64-bit fallback. Collision
73/// resistance is not required: the worst case is a malicious actor crafting
74/// a hook list with the same hash as a previously trusted one for the same
75/// repo, which already requires repo-level write access (game over anyway).
76fn hash_commands(commands: &[String]) -> String {
77    let mut h: u64 = 0xcbf29ce484222325;
78    for c in commands {
79        for b in c.bytes() {
80            h ^= b as u64;
81            h = h.wrapping_mul(0x100000001b3);
82        }
83        h ^= b'\n' as u64;
84        h = h.wrapping_mul(0x100000001b3);
85    }
86    format!("{:016x}", h)
87}
88
89fn repo_key(repo: &Path) -> String {
90    repo.canonicalize()
91        .unwrap_or_else(|_| repo.to_path_buf())
92        .to_string_lossy()
93        .into_owned()
94}
95
96fn is_trusted(repo: &Path, commands: &[String]) -> Result<bool> {
97    let Some(path) = trust_file_path() else { return Ok(false) };
98    if !path.exists() { return Ok(false); }
99    let content = std::fs::read_to_string(&path)
100        .map_err(|e| anyhow!("read {}: {}", path.display(), e))?;
101    let key = repo_key(repo);
102    let hash = hash_commands(commands);
103    for line in content.lines() {
104        let line = line.trim();
105        if line.is_empty() || line.starts_with('#') { continue; }
106        let Some((k, v)) = line.split_once('=') else { continue };
107        let k = k.trim().trim_matches('"');
108        let v = v.trim().trim_matches('"');
109        if k == key && v == hash { return Ok(true); }
110    }
111    Ok(false)
112}
113
114fn mark_trusted(repo: &Path, commands: &[String]) -> Result<()> {
115    let Some(path) = trust_file_path() else { return Ok(()); };
116    if let Some(parent) = path.parent() {
117        let _ = std::fs::create_dir_all(parent);
118    }
119    let key = repo_key(repo);
120    let hash = hash_commands(commands);
121
122    // Read existing, drop any prior entry for this repo (so a re-trust
123    // replaces stale hash), then append the new line.
124    let mut buf = String::new();
125    if path.exists() {
126        if let Ok(content) = std::fs::read_to_string(&path) {
127            for line in content.lines() {
128                let trimmed = line.trim();
129                if trimmed.is_empty() || trimmed.starts_with('#') {
130                    buf.push_str(line);
131                    buf.push('\n');
132                    continue;
133                }
134                let key_in_line = trimmed
135                    .split_once('=')
136                    .map(|(k, _)| k.trim().trim_matches('"').to_string())
137                    .unwrap_or_default();
138                if key_in_line != key {
139                    buf.push_str(line);
140                    buf.push('\n');
141                }
142            }
143        }
144    }
145    if buf.is_empty() {
146        buf.push_str("# torii hook trust store — written by `torii` after explicit user consent\n");
147    }
148    buf.push_str(&format!("\"{}\" = \"{}\"\n", key, hash));
149    std::fs::write(&path, buf)
150        .map_err(|e| anyhow!("write {}: {}", path.display(), e))?;
151    Ok(())
152}
153
154fn prompt_trust(repo: &Path, label: &str, commands: &[String]) -> Result<bool> {
155    use std::io::{BufRead, IsTerminal, Write};
156    if !std::io::stdin().is_terminal() {
157        // No tty → cannot prompt. Refuse rather than silently execute.
158        eprintln!(
159            "⚠️  {} hooks defined in {} (untrusted, no tty to prompt).",
160            label, repo.display()
161        );
162        eprintln!("   Run interactively to trust, or set TORII_TRUST_HOOKS=1 / --skip-hooks.");
163        return Ok(false);
164    }
165    println!();
166    println!("⚠️  This repo defines {} hook(s) that will run via `sh -c`:", label);
167    for cmd in commands {
168        println!("     • {}", cmd);
169    }
170    println!("   repo: {}", repo.display());
171    print!("   Trust and run? [y/N] ");
172    std::io::stdout().flush().ok();
173    let mut line = String::new();
174    std::io::stdin().lock().read_line(&mut line)?;
175    let answer = line.trim().to_ascii_lowercase();
176    let yes = matches!(answer.as_str(), "y" | "yes");
177    if yes {
178        mark_trusted(repo, commands)?;
179        println!("   ✓ trusted; remembered in ~/.config/torii/hook-trust.toml");
180    }
181    Ok(yes)
182}
183
184/// Convenience: pre-save / pre-sync / post-* dispatch
185pub fn pre_save(rules: &HookRules, repo: &Path) -> Result<()> {
186    run_hooks("pre-save", &rules.pre_save, repo)
187}
188pub fn pre_sync(rules: &HookRules, repo: &Path) -> Result<()> {
189    run_hooks("pre-sync", &rules.pre_sync, repo)
190}
191pub fn post_save(rules: &HookRules, repo: &Path) {
192    let _ = run_hooks("post-save", &rules.post_save, repo);
193}
194pub fn post_sync(rules: &HookRules, repo: &Path) {
195    let _ = run_hooks("post-sync", &rules.post_sync, repo);
196}
197
198/// Check staged file sizes against [size] limits.
199/// Returns Err if any file exceeds `max`. Prints warnings for `warn` overruns.
200pub fn check_size(rules: &SizeRules, repo: &Path, staged_paths: &[String]) -> Result<()> {
201    if rules.max_bytes.is_none() && rules.warn_bytes.is_none() { return Ok(()); }
202
203    let mut blocked: Vec<(String, u64)> = Vec::new();
204    let mut warned: Vec<(String, u64)> = Vec::new();
205
206    for rel in staged_paths {
207        if rules.exclude.iter().any(|g| glob_match(rel, g)) { continue; }
208        let abs = repo.join(rel);
209        let size = match std::fs::metadata(&abs) {
210            Ok(m) => m.len(),
211            Err(_) => continue, // deleted file or unreadable
212        };
213        if let Some(max) = rules.max_bytes {
214            if size > max { blocked.push((rel.clone(), size)); continue; }
215        }
216        if let Some(warn) = rules.warn_bytes {
217            if size > warn { warned.push((rel.clone(), size)); }
218        }
219    }
220
221    for (path, size) in &warned {
222        println!("⚠️  large file: {} ({})", path, human_size(*size));
223    }
224    if !blocked.is_empty() {
225        let mut msg = String::from("size limit exceeded:\n");
226        for (path, size) in &blocked {
227            msg.push_str(&format!("   {} — {}\n", path, human_size(*size)));
228        }
229        msg.push_str("\nAdjust [size] max in .toriignore, exclude these paths, or use git LFS.");
230        return Err(anyhow!(msg));
231    }
232    Ok(())
233}
234
235fn human_size(bytes: u64) -> String {
236    const KB: u64 = 1024;
237    const MB: u64 = KB * 1024;
238    const GB: u64 = MB * 1024;
239    if bytes >= GB { format!("{:.2} GB", bytes as f64 / GB as f64) }
240    else if bytes >= MB { format!("{:.2} MB", bytes as f64 / MB as f64) }
241    else if bytes >= KB { format!("{:.1} KB", bytes as f64 / KB as f64) }
242    else { format!("{} B", bytes) }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::toriignore::SizeRules;
249
250    #[test]
251    fn human_size_boundaries() {
252        assert_eq!(human_size(512), "512 B");
253        assert_eq!(human_size(2048), "2.0 KB");
254        assert_eq!(human_size(2 * 1024 * 1024), "2.00 MB");
255    }
256
257    #[test]
258    fn size_check_blocks_oversize() {
259        let dir = tempfile::tempdir().unwrap();
260        let big = dir.path().join("big.bin");
261        std::fs::write(&big, vec![0u8; 1024 * 1024]).unwrap(); // 1 MB
262        let rules = SizeRules { max_bytes: Some(500 * 1024), warn_bytes: None, exclude: vec![] };
263        let err = check_size(&rules, dir.path(), &["big.bin".to_string()]).unwrap_err();
264        assert!(err.to_string().contains("size limit exceeded"));
265    }
266
267    #[test]
268    fn size_check_respects_exclude() {
269        let dir = tempfile::tempdir().unwrap();
270        let big = dir.path().join("artwork.psd");
271        std::fs::write(&big, vec![0u8; 1024 * 1024]).unwrap();
272        let rules = SizeRules {
273            max_bytes: Some(100),
274            warn_bytes: None,
275            exclude: vec!["*.psd".to_string()],
276        };
277        check_size(&rules, dir.path(), &["artwork.psd".to_string()]).unwrap();
278    }
279
280    #[test]
281    fn size_check_skips_missing_file() {
282        let dir = tempfile::tempdir().unwrap();
283        let rules = SizeRules { max_bytes: Some(100), warn_bytes: None, exclude: vec![] };
284        check_size(&rules, dir.path(), &["nonexistent".to_string()]).unwrap();
285    }
286}