aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-ai/src/edit_permissions.rs
blob: 5015a007c41af1f448ac2026746ad38dd39dcb5b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
//! Session-scoped permission cache for file edits.
//!
//! When the user selects "Allow this file for this session", the grant is
//! recorded here with a timestamp. Subsequent edits to the same file skip
//! the permission prompt as long as the grant hasn't expired.
//!
//! Grants are time-limited (1 hour TTL) so they don't outlive the user's
//! attention in long-running sessions. Persisted as JSON in session
//! metadata so they survive across CLI invocations.

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use eyre::Result;
use serde::{Deserialize, Serialize};

/// Session metadata key for persistence.
pub(crate) const METADATA_KEY: &str = "edit_permissions";

/// How long a session-scoped edit permission remains valid.
const TTL_MS: i64 = 60 * 60 * 1000; // 1 hour

/// Cache of per-file edit permission grants within a session.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub(crate) struct EditPermissionCache {
    /// Maps canonical file paths to the grant timestamp (unix millis).
    grants: HashMap<PathBuf, i64>,
}

impl EditPermissionCache {
    /// Record a permission grant for a file.
    pub fn grant(&mut self, path: PathBuf) {
        self.grants.insert(path, now_ms());
    }

    /// Check whether there's a valid (non-expired) grant for a file.
    pub fn has_valid_grant(&self, path: &Path) -> bool {
        if let Some(&granted_at) = self.grants.get(path) {
            (now_ms() - granted_at) < TTL_MS
        } else {
            false
        }
    }

    pub fn to_json(&self) -> Result<String> {
        Ok(serde_json::to_string(self)?)
    }

    pub fn from_json(json: &str) -> Result<Self> {
        Ok(serde_json::from_str(json)?)
    }
}

fn now_ms() -> i64 {
    SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or(0)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grant_and_check() {
        let mut cache = EditPermissionCache::default();
        let path = PathBuf::from("/Users/me/.config/foo.toml");

        assert!(!cache.has_valid_grant(&path));
        cache.grant(path.clone());
        assert!(cache.has_valid_grant(&path));
    }

    #[test]
    fn different_paths_are_independent() {
        let mut cache = EditPermissionCache::default();
        let a = PathBuf::from("/etc/hosts");
        let b = PathBuf::from("/etc/resolv.conf");

        cache.grant(a.clone());
        assert!(cache.has_valid_grant(&a));
        assert!(!cache.has_valid_grant(&b));
    }

    #[test]
    fn roundtrip_json() {
        let mut cache = EditPermissionCache::default();
        cache.grant(PathBuf::from("/some/file.toml"));

        let json = cache.to_json().unwrap();
        let restored = EditPermissionCache::from_json(&json).unwrap();
        assert!(restored.has_valid_grant(Path::new("/some/file.toml")));
    }

    #[test]
    fn expired_grant_is_invalid() {
        let mut cache = EditPermissionCache::default();
        let path = PathBuf::from("/expired/file.toml");

        // Insert a grant from 2 hours ago
        let two_hours_ago = now_ms() - (2 * 60 * 60 * 1000);
        cache.grants.insert(path.clone(), two_hours_ago);

        assert!(!cache.has_valid_grant(&path));
    }
}