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
8pub 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 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
65fn trust_file_path() -> Option<std::path::PathBuf> {
68 dirs::config_dir().map(|d| d.join("torii").join("hook-trust.toml"))
69}
70
71fn 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 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 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
184pub 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
198pub 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, };
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(); 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}