1use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use sha2::{Digest, Sha256};
12
13use crate::error::RippyError;
14
15#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct TrustEntry {
18 pub hash: String,
20 pub trusted_at: String,
22 #[serde(skip_serializing_if = "Option::is_none")]
26 pub repo_id: Option<String>,
27}
28
29#[derive(Debug, PartialEq, Eq)]
31pub enum TrustStatus {
32 Trusted,
34 Untrusted,
36 Modified { expected: String, actual: String },
38}
39
40#[derive(Debug)]
42pub struct TrustDb {
43 entries: HashMap<String, TrustEntry>,
44 path: PathBuf,
45}
46
47impl TrustDb {
48 pub fn load() -> Self {
52 trust_db_path().map_or_else(
53 || Self {
54 entries: HashMap::new(),
55 path: PathBuf::new(),
56 },
57 |path| Self::load_from(&path),
58 )
59 }
60
61 pub fn load_from(path: &Path) -> Self {
63 let entries = std::fs::read_to_string(path)
64 .ok()
65 .and_then(|content| serde_json::from_str(&content).ok())
66 .unwrap_or_default();
67 Self {
68 entries,
69 path: path.to_owned(),
70 }
71 }
72
73 pub fn save(&self) -> Result<(), RippyError> {
79 if let Some(parent) = self.path.parent() {
80 std::fs::create_dir_all(parent).map_err(|e| {
81 RippyError::Trust(format!(
82 "could not create directory {}: {e}",
83 parent.display()
84 ))
85 })?;
86 }
87 let json = serde_json::to_string_pretty(&self.entries)
88 .map_err(|e| RippyError::Trust(format!("could not serialize trust db: {e}")))?;
89 std::fs::write(&self.path, json)
90 .map_err(|e| RippyError::Trust(format!("could not write {}: {e}", self.path.display())))
91 }
92
93 pub fn check(&self, path: &Path, content: &str) -> TrustStatus {
100 let key = canonical_key(path);
101 match self.entries.get(&key) {
102 None => TrustStatus::Untrusted,
103 Some(entry) => {
104 if let Some(stored_repo) = &entry.repo_id
106 && detect_repo_id(path).is_some_and(|current| current == *stored_repo)
107 {
108 return TrustStatus::Trusted;
109 }
110 let actual_hash = hash_content(content);
112 if entry.hash == actual_hash {
113 TrustStatus::Trusted
114 } else {
115 TrustStatus::Modified {
116 expected: entry.hash.clone(),
117 actual: actual_hash,
118 }
119 }
120 }
121 }
122 }
123
124 pub fn trust(&mut self, path: &Path, content: &str) {
129 let key = canonical_key(path);
130 let hash = hash_content(content);
131 let repo_id = detect_repo_id(path);
132 let now = now_iso8601();
133 self.entries.insert(
134 key,
135 TrustEntry {
136 hash,
137 trusted_at: now,
138 repo_id,
139 },
140 );
141 }
142
143 pub fn revoke(&mut self, path: &Path) -> bool {
147 let key = canonical_key(path);
148 self.entries.remove(&key).is_some()
149 }
150
151 #[must_use]
153 pub const fn list(&self) -> &HashMap<String, TrustEntry> {
154 &self.entries
155 }
156
157 #[must_use]
159 pub fn is_empty(&self) -> bool {
160 self.entries.is_empty()
161 }
162}
163
164#[must_use]
166pub fn hash_content(content: &str) -> String {
167 let mut hasher = Sha256::new();
168 hasher.update(content.as_bytes());
169 let result = hasher.finalize();
170 format!("sha256:{result:x}")
171}
172
173fn canonical_key(path: &Path) -> String {
178 std::fs::canonicalize(path)
179 .unwrap_or_else(|_| path.to_owned())
180 .to_string_lossy()
181 .into_owned()
182}
183
184fn trust_db_path() -> Option<PathBuf> {
186 dirs::home_dir().map(|h| h.join(".rippy/trusted.json"))
187}
188
189fn now_iso8601() -> String {
191 let dur = std::time::SystemTime::now()
193 .duration_since(std::time::UNIX_EPOCH)
194 .unwrap_or_default();
195 let secs = dur.as_secs();
197 format_epoch_secs(secs)
198}
199
200fn format_epoch_secs(secs: u64) -> String {
202 let days = secs / 86400;
204 let time_of_day = secs % 86400;
205 let hours = time_of_day / 3600;
206 let minutes = (time_of_day % 3600) / 60;
207 let seconds = time_of_day % 60;
208
209 let (year, month, day) = days_to_ymd(days);
210 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
211}
212
213const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
215 let z = days + 719_468;
217 let era = z / 146_097;
218 let doe = z - era * 146_097;
219 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
220 let y = yoe + era * 400;
221 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
222 let mp = (5 * doy + 2) / 153;
223 let d = doy - (153 * mp + 2) / 5 + 1;
224 let m = if mp < 10 { mp + 3 } else { mp - 9 };
225 let y = if m <= 2 { y + 1 } else { y };
226 (y, m, d)
227}
228
229pub fn detect_repo_id(path: &Path) -> Option<String> {
235 let dir = path.parent()?;
236
237 let remote = std::process::Command::new("git")
239 .args(["-C", &dir.to_string_lossy(), "remote", "get-url", "origin"])
240 .stdout(std::process::Stdio::piped())
241 .stderr(std::process::Stdio::null())
242 .output()
243 .ok()?;
244
245 if remote.status.success() {
246 let url = String::from_utf8_lossy(&remote.stdout).trim().to_owned();
247 if !url.is_empty() {
248 return Some(url);
249 }
250 }
251
252 let toplevel = std::process::Command::new("git")
254 .args(["-C", &dir.to_string_lossy(), "rev-parse", "--show-toplevel"])
255 .stdout(std::process::Stdio::piped())
256 .stderr(std::process::Stdio::null())
257 .output()
258 .ok()?;
259
260 if toplevel.status.success() {
261 let root = String::from_utf8_lossy(&toplevel.stdout).trim().to_owned();
262 if !root.is_empty() {
263 return Some(format!("local:{root}"));
264 }
265 }
266
267 None
268}
269
270pub struct TrustGuard {
280 path: PathBuf,
281 was_trusted: bool,
282}
283
284impl TrustGuard {
285 pub fn before_write(path: &Path) -> Self {
290 let was_trusted = std::fs::read_to_string(path).ok().is_some_and(|content| {
291 let db = TrustDb::load();
292 db.check(path, &content) == TrustStatus::Trusted
293 });
294 Self {
295 path: path.to_owned(),
296 was_trusted,
297 }
298 }
299
300 pub fn for_new_file(path: &Path) -> Self {
305 Self {
306 path: path.to_owned(),
307 was_trusted: true,
308 }
309 }
310
311 pub fn commit(self) {
318 if !self.was_trusted {
319 return;
320 }
321
322 let content = match std::fs::read_to_string(&self.path) {
323 Ok(c) => c,
324 Err(e) => {
325 eprintln!("[rippy] could not re-trust {}: {e}", self.path.display());
326 return;
327 }
328 };
329 let mut db = TrustDb::load();
330 db.trust(&self.path, &content);
331 if let Err(e) = db.save() {
332 eprintln!("[rippy] could not save trust db: {e}");
333 }
334 }
335}
336
337#[cfg(test)]
342#[allow(clippy::unwrap_used)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn hash_content_deterministic() {
348 let h1 = hash_content("allow *\n");
349 let h2 = hash_content("allow *\n");
350 assert_eq!(h1, h2);
351 assert!(h1.starts_with("sha256:"));
352 }
353
354 #[test]
355 fn hash_content_different_for_different_input() {
356 let h1 = hash_content("allow *");
357 let h2 = hash_content("deny *");
358 assert_ne!(h1, h2);
359 }
360
361 #[test]
362 fn empty_db_returns_untrusted() {
363 let tmp = tempfile::NamedTempFile::new().unwrap();
364 let db = TrustDb::load_from(tmp.path());
365 assert_eq!(
366 db.check(Path::new("/fake/.rippy"), "content"),
367 TrustStatus::Untrusted
368 );
369 }
370
371 #[test]
372 fn trust_then_check_returns_trusted() {
373 let tmp = tempfile::NamedTempFile::new().unwrap();
374 let mut db = TrustDb::load_from(tmp.path());
375 let path = tmp.path();
376 db.trust(path, "allow git status\n");
377 assert_eq!(db.check(path, "allow git status\n"), TrustStatus::Trusted);
378 }
379
380 #[test]
381 fn modified_content_returns_modified() {
382 let tmp = tempfile::NamedTempFile::new().unwrap();
383 let mut db = TrustDb::load_from(tmp.path());
384 let path = tmp.path();
385 db.trust(path, "allow git status\n");
386 let status = db.check(path, "allow *\n");
387 assert!(matches!(status, TrustStatus::Modified { .. }));
388 }
389
390 #[test]
391 fn revoke_existing_returns_true() {
392 let tmp = tempfile::NamedTempFile::new().unwrap();
393 let mut db = TrustDb::load_from(tmp.path());
394 let path = tmp.path();
395 db.trust(path, "content");
396 assert!(db.revoke(path));
397 assert_eq!(db.check(path, "content"), TrustStatus::Untrusted);
398 }
399
400 #[test]
401 fn revoke_nonexistent_returns_false() {
402 let tmp = tempfile::NamedTempFile::new().unwrap();
403 let mut db = TrustDb::load_from(tmp.path());
404 assert!(!db.revoke(Path::new("/nonexistent/.rippy")));
405 }
406
407 #[test]
408 fn save_and_load_roundtrip() {
409 let dir = tempfile::tempdir().unwrap();
410 let db_path = dir.path().join("trusted.json");
411
412 let mut db = TrustDb::load_from(&db_path);
413 let config_path = dir.path().join(".rippy");
414 std::fs::write(&config_path, "deny rm -rf").unwrap();
415 db.trust(&config_path, "deny rm -rf");
416 db.save().unwrap();
417
418 let db2 = TrustDb::load_from(&db_path);
419 assert_eq!(db2.check(&config_path, "deny rm -rf"), TrustStatus::Trusted);
420 }
421
422 #[test]
423 fn format_epoch_known_date() {
424 let s = format_epoch_secs(1_704_067_200);
426 assert_eq!(s, "2024-01-01T00:00:00Z");
427 }
428
429 #[test]
430 fn detect_repo_id_in_git_repo_with_remote() {
431 let dir = tempfile::tempdir().unwrap();
432 std::process::Command::new("git")
433 .args(["init"])
434 .current_dir(dir.path())
435 .output()
436 .unwrap();
437 std::process::Command::new("git")
438 .args(["remote", "add", "origin", "git@github.com:test/repo.git"])
439 .current_dir(dir.path())
440 .output()
441 .unwrap();
442 let config = dir.path().join(".rippy.toml");
443 std::fs::write(&config, "# test").unwrap();
444 let repo_id = detect_repo_id(&config);
445 assert_eq!(repo_id.as_deref(), Some("git@github.com:test/repo.git"));
446 }
447
448 #[test]
449 fn detect_repo_id_local_repo_without_remote() {
450 let dir = tempfile::tempdir().unwrap();
451 std::process::Command::new("git")
452 .args(["init"])
453 .current_dir(dir.path())
454 .output()
455 .unwrap();
456 let config = dir.path().join(".rippy");
457 std::fs::write(&config, "# test").unwrap();
458 let repo_id = detect_repo_id(&config);
459 assert!(
460 repo_id.as_ref().is_some_and(|id| id.starts_with("local:")),
461 "expected local: prefix, got: {repo_id:?}"
462 );
463 }
464
465 #[test]
466 fn detect_repo_id_no_git_returns_none() {
467 let dir = tempfile::tempdir().unwrap();
468 let config = dir.path().join(".rippy");
469 std::fs::write(&config, "# test").unwrap();
470 assert_eq!(detect_repo_id(&config), None);
471 }
472
473 #[test]
474 fn repo_trust_survives_hash_change() {
475 let dir = tempfile::tempdir().unwrap();
476 std::process::Command::new("git")
478 .args(["init"])
479 .current_dir(dir.path())
480 .output()
481 .unwrap();
482 std::process::Command::new("git")
483 .args(["remote", "add", "origin", "git@github.com:test/repo.git"])
484 .current_dir(dir.path())
485 .output()
486 .unwrap();
487
488 let db_path = dir.path().join("trusted.json");
489 let config_path = dir.path().join(".rippy.toml");
490 std::fs::write(&config_path, "deny rm -rf").unwrap();
491
492 let mut db = TrustDb::load_from(&db_path);
493 db.trust(&config_path, "deny rm -rf");
494
495 assert_eq!(
497 db.check(&config_path, "allow * MALICIOUS"),
498 TrustStatus::Trusted
499 );
500 }
501
502 #[test]
503 fn legacy_entry_without_repo_id_uses_hash() {
504 let tmp = tempfile::NamedTempFile::new().unwrap();
505 let mut db = TrustDb::load_from(tmp.path());
506 let path = tmp.path();
507 let key = canonical_key(path);
509 db.entries.insert(
510 key,
511 TrustEntry {
512 hash: hash_content("original"),
513 trusted_at: "2024-01-01T00:00:00Z".to_string(),
514 repo_id: None,
515 },
516 );
517 assert_eq!(db.check(path, "original"), TrustStatus::Trusted);
518 assert!(matches!(
519 db.check(path, "changed"),
520 TrustStatus::Modified { .. }
521 ));
522 }
523
524 #[test]
525 fn re_trust_after_write_updates_hash() {
526 let dir = tempfile::tempdir().unwrap();
527 let db_path = dir.path().join("trusted.json");
528 let config_path = dir.path().join(".rippy");
529
530 std::fs::write(&config_path, "deny rm").unwrap();
532 let mut db = TrustDb::load_from(&db_path);
533 db.trust(&config_path, "deny rm");
534 db.save().unwrap();
535
536 std::fs::write(&config_path, "deny rm\nallow git status").unwrap();
538 let content = std::fs::read_to_string(&config_path).unwrap();
541 db.trust(&config_path, &content);
542 db.save().unwrap();
543
544 let db2 = TrustDb::load_from(&db_path);
545 assert_eq!(
546 db2.check(&config_path, "deny rm\nallow git status"),
547 TrustStatus::Trusted
548 );
549 }
550}