Skip to main content

rippy_cli/
trust.rs

1//! Project config trust model.
2//!
3//! Stores SHA-256 hashes of trusted project config files in
4//! `~/.rippy/trusted.json`. When rippy encounters a project config,
5//! it checks this database before loading. Untrusted or modified
6//! configs are ignored with a stderr warning.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use sha2::{Digest, Sha256};
12
13use crate::error::RippyError;
14
15/// A single trust entry recording the hash and timestamp of a trusted config.
16#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
17pub struct TrustEntry {
18    /// Content hash in the form `"sha256:<hex>"`.
19    pub hash: String,
20    /// ISO 8601 timestamp when the file was trusted.
21    pub trusted_at: String,
22    /// Repository identity — git remote URL or `"local:<repo_root>"`.
23    /// When present, trust is repo-level: config changes within the
24    /// same repo are auto-trusted without re-running `rippy trust`.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub repo_id: Option<String>,
27}
28
29/// Result of checking a project config against the trust database.
30#[derive(Debug, PartialEq, Eq)]
31pub enum TrustStatus {
32    /// The file's hash matches the stored entry.
33    Trusted,
34    /// No entry exists for this file path.
35    Untrusted,
36    /// An entry exists but the file content has changed.
37    Modified { expected: String, actual: String },
38}
39
40/// Trust database backed by a JSON file at `~/.rippy/trusted.json`.
41#[derive(Debug)]
42pub struct TrustDb {
43    entries: HashMap<String, TrustEntry>,
44    path: PathBuf,
45}
46
47impl TrustDb {
48    /// Load the trust database from `~/.rippy/trusted.json`.
49    ///
50    /// Returns an empty database if the file does not exist or cannot be parsed.
51    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    /// Load the trust database from a specific path (for testing).
62    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    /// Save the trust database back to disk.
74    ///
75    /// # Errors
76    ///
77    /// Returns `RippyError::Trust` if the file cannot be written.
78    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    /// Check whether a project config file is trusted.
94    ///
95    /// Trust is determined in order:
96    /// 1. If the entry has a `repo_id` that matches the current repo → Trusted
97    /// 2. If the content hash matches → Trusted
98    /// 3. If neither matches → Modified (hash-based) or Untrusted (no entry)
99    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                // Repo-level trust: same repo → trusted regardless of hash.
105                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                // Fall back to hash check (legacy entries or repo mismatch).
111                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    /// Mark a project config file as trusted using its current content.
125    ///
126    /// Also detects and stores the repository identity so that future
127    /// config changes within the same repo are automatically trusted.
128    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    /// Remove trust for a project config file.
144    ///
145    /// Returns `true` if an entry was removed.
146    pub fn revoke(&mut self, path: &Path) -> bool {
147        let key = canonical_key(path);
148        self.entries.remove(&key).is_some()
149    }
150
151    /// Return all trusted entries.
152    #[must_use]
153    pub const fn list(&self) -> &HashMap<String, TrustEntry> {
154        &self.entries
155    }
156
157    /// Check if the database is empty.
158    #[must_use]
159    pub fn is_empty(&self) -> bool {
160        self.entries.is_empty()
161    }
162}
163
164/// Compute a SHA-256 hash of the given content, prefixed with `"sha256:"`.
165#[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
173/// Derive a stable key from a file path.
174///
175/// Uses the canonical (absolute) path if possible, falling back to the
176/// original path string.
177fn 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
184/// Return the path to `~/.rippy/trusted.json`.
185fn trust_db_path() -> Option<PathBuf> {
186    dirs::home_dir().map(|h| h.join(".rippy/trusted.json"))
187}
188
189/// Current time as an ISO 8601 string (UTC-like, using system time).
190fn now_iso8601() -> String {
191    // Use a simple seconds-since-epoch representation to avoid pulling in chrono.
192    let dur = std::time::SystemTime::now()
193        .duration_since(std::time::UNIX_EPOCH)
194        .unwrap_or_default();
195    // Format as a readable timestamp without external crate.
196    let secs = dur.as_secs();
197    format_epoch_secs(secs)
198}
199
200/// Format seconds since epoch as `YYYY-MM-DDTHH:MM:SSZ`.
201fn format_epoch_secs(secs: u64) -> String {
202    // Days since epoch, then decompose into y/m/d.
203    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
213/// Convert days since 1970-01-01 to (year, month, day).
214const fn days_to_ymd(days: u64) -> (u64, u64, u64) {
215    // Civil calendar algorithm from Howard Hinnant.
216    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
229/// Detect the repository identity for a config file path.
230///
231/// Prefers the git remote origin URL (`git@github.com:org/repo.git`).
232/// Falls back to `"local:<repo_root>"` for repos without a remote.
233/// Returns `None` if the file is not in a git repository.
234pub fn detect_repo_id(path: &Path) -> Option<String> {
235    let dir = path.parent()?;
236
237    // Try git remote origin URL first.
238    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    // Fall back to repo root path.
253    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
270/// Guard that wraps a config file modification to preserve trust.
271///
272/// Snapshots the file's trust status *before* the write. After the write,
273/// call [`TrustGuard::commit`] to update the trust hash — but only if
274/// the pre-write content was verified as trusted. This prevents a TOCTOU
275/// attack where a malicious actor modifies the file between the last
276/// trust check and rippy's write.
277///
278/// For newly created files (no prior content), use [`TrustGuard::for_new_file`].
279pub struct TrustGuard {
280    path: PathBuf,
281    was_trusted: bool,
282}
283
284impl TrustGuard {
285    /// Snapshot the trust state of an existing config file before modifying it.
286    ///
287    /// Reads the file, checks trust status. If the file doesn't exist yet
288    /// (will be created by the write), `was_trusted` is false.
289    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    /// Create a guard for a file that is being newly created by the user.
301    ///
302    /// `commit()` will unconditionally trust the new file since the user
303    /// explicitly created it (e.g., `rippy init`).
304    pub fn for_new_file(path: &Path) -> Self {
305        Self {
306            path: path.to_owned(),
307            was_trusted: true,
308        }
309    }
310
311    /// Update the trust hash after the write, if the pre-write state was trusted.
312    ///
313    /// If the file was not trusted before the write (tampered or never approved),
314    /// this is a no-op and a warning is logged.
315    ///
316    /// Errors are logged to stderr but do not fail the caller.
317    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// ---------------------------------------------------------------------------
338// Tests
339// ---------------------------------------------------------------------------
340
341#[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        // 2024-01-01T00:00:00Z = 1704067200
425        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        // Set up a git repo so detect_repo_id works.
477        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        // Content changed but repo is the same → still trusted.
496        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        // Manually insert entry without repo_id (simulates legacy).
508        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        // Trust the original content.
531        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        // Modify the file and re-trust.
537        std::fs::write(&config_path, "deny rm\nallow git status").unwrap();
538        // re_trust_after_write uses TrustDb::load() which reads HOME.
539        // For unit test, manually simulate:
540        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}