Skip to main content

symbi_runtime/integrations/agentpin/
key_store.rs

1//! File-backed TOFU Key Pin Store for AgentPin
2//!
3//! Wraps agentpin::pinning::KeyPinStore with file-backed persistence
4//! at `~/.symbiont/agentpin_keys.json` with 0o600 permissions.
5
6use agentpin::pinning::KeyPinStore;
7use std::fs::{self, File, OpenOptions};
8use std::io::{BufReader, Write};
9use std::path::{Path, PathBuf};
10
11use super::types::AgentPinError;
12
13/// File-backed key pin store for TOFU key pinning
14#[derive(Debug, Clone)]
15pub struct AgentPinKeyStore {
16    /// Path to the key store JSON file
17    store_path: PathBuf,
18}
19
20impl AgentPinKeyStore {
21    /// Create a new key store backed by the given file path.
22    /// Creates parent directories and an empty store if it doesn't exist.
23    pub fn new(store_path: &Path) -> Result<Self, AgentPinError> {
24        // Create parent directories
25        if let Some(parent) = store_path.parent() {
26            fs::create_dir_all(parent).map_err(|e| AgentPinError::IoError {
27                reason: format!("Failed to create key store directory: {}", e),
28            })?;
29        }
30
31        let ks = Self {
32            store_path: store_path.to_path_buf(),
33        };
34
35        // Initialize empty store if file doesn't exist
36        if !store_path.exists() {
37            let empty = KeyPinStore::new();
38            ks.save_pin_store(&empty)?;
39        }
40
41        Ok(ks)
42    }
43
44    /// Load the pin store from disk
45    pub fn load_pin_store(&self) -> Result<KeyPinStore, AgentPinError> {
46        if !self.store_path.exists() {
47            return Ok(KeyPinStore::new());
48        }
49
50        let file = File::open(&self.store_path).map_err(|e| AgentPinError::IoError {
51            reason: format!("Failed to open key store: {}", e),
52        })?;
53
54        let reader = BufReader::new(file);
55        let json: String =
56            serde_json::from_reader(reader).map_err(|e| AgentPinError::KeyStoreError {
57                reason: format!("Failed to deserialize key store: {}", e),
58            })?;
59
60        KeyPinStore::from_json(&json).map_err(|e| AgentPinError::KeyStoreError {
61            reason: format!("Failed to parse key store: {}", e),
62        })
63    }
64
65    /// Save the pin store to disk with restricted permissions
66    pub fn save_pin_store(&self, store: &KeyPinStore) -> Result<(), AgentPinError> {
67        let json = store.to_json().map_err(|e| AgentPinError::KeyStoreError {
68            reason: format!("Failed to serialize key store: {}", e),
69        })?;
70
71        // Create parent directories
72        if let Some(parent) = self.store_path.parent() {
73            fs::create_dir_all(parent).map_err(|e| AgentPinError::IoError {
74                reason: format!("Failed to create key store directory: {}", e),
75            })?;
76        }
77
78        let mut file = OpenOptions::new()
79            .write(true)
80            .create(true)
81            .truncate(true)
82            .open(&self.store_path)
83            .map_err(|e| AgentPinError::IoError {
84                reason: format!("Failed to open key store for writing: {}", e),
85            })?;
86
87        // Set restrictive permissions on Unix
88        #[cfg(unix)]
89        {
90            use std::os::unix::fs::PermissionsExt;
91            let perms = std::fs::Permissions::from_mode(0o600);
92            fs::set_permissions(&self.store_path, perms).map_err(|e| AgentPinError::IoError {
93                reason: format!("Failed to set key store permissions: {}", e),
94            })?;
95        }
96
97        // Write as a JSON string value so we can round-trip through KeyPinStore::from_json
98        serde_json::to_writer_pretty(&mut file, &json).map_err(|e| AgentPinError::IoError {
99            reason: format!("Failed to write key store: {}", e),
100        })?;
101
102        file.flush().map_err(|e| AgentPinError::IoError {
103            reason: format!("Failed to flush key store: {}", e),
104        })?;
105
106        Ok(())
107    }
108
109    /// Get the path to the store file
110    pub fn store_path(&self) -> &Path {
111        &self.store_path
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use tempfile::TempDir;
119
120    #[test]
121    fn test_create_new_key_store() {
122        let temp_dir = TempDir::new().unwrap();
123        let store_path = temp_dir.path().join("agentpin_keys.json");
124
125        let ks = AgentPinKeyStore::new(&store_path).unwrap();
126        assert!(ks.store_path().exists());
127    }
128
129    #[test]
130    fn test_load_empty_store() {
131        let temp_dir = TempDir::new().unwrap();
132        let store_path = temp_dir.path().join("agentpin_keys.json");
133
134        let ks = AgentPinKeyStore::new(&store_path).unwrap();
135        let pin_store = ks.load_pin_store().unwrap();
136
137        // A fresh pin store should have no domains
138        assert!(pin_store.get_domain("anything.com").is_none());
139    }
140
141    #[test]
142    fn test_save_and_load_roundtrip() {
143        let temp_dir = TempDir::new().unwrap();
144        let store_path = temp_dir.path().join("agentpin_keys.json");
145
146        let ks = AgentPinKeyStore::new(&store_path).unwrap();
147        let store = KeyPinStore::new();
148
149        ks.save_pin_store(&store).unwrap();
150        let loaded = ks.load_pin_store().unwrap();
151
152        // Both should serialize to the same JSON
153        assert_eq!(store.to_json().unwrap(), loaded.to_json().unwrap());
154    }
155
156    #[test]
157    fn test_creates_parent_directories() {
158        let temp_dir = TempDir::new().unwrap();
159        let store_path = temp_dir.path().join("nested").join("dir").join("keys.json");
160
161        let ks = AgentPinKeyStore::new(&store_path).unwrap();
162        assert!(ks.store_path().exists());
163    }
164
165    #[cfg(unix)]
166    #[test]
167    fn test_file_permissions() {
168        use std::os::unix::fs::PermissionsExt;
169
170        let temp_dir = TempDir::new().unwrap();
171        let store_path = temp_dir.path().join("agentpin_keys.json");
172
173        let ks = AgentPinKeyStore::new(&store_path).unwrap();
174        let store = KeyPinStore::new();
175        ks.save_pin_store(&store).unwrap();
176
177        let metadata = fs::metadata(&store_path).unwrap();
178        let mode = metadata.permissions().mode() & 0o777;
179        assert_eq!(mode, 0o600);
180    }
181
182    #[test]
183    fn test_load_nonexistent_returns_empty() {
184        let temp_dir = TempDir::new().unwrap();
185        let store_path = temp_dir.path().join("does_not_exist.json");
186
187        // Don't create via new(), just construct directly
188        let ks = AgentPinKeyStore {
189            store_path: store_path.clone(),
190        };
191
192        let pin_store = ks.load_pin_store().unwrap();
193        assert!(pin_store.get_domain("anything").is_none());
194    }
195}