1use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::PluginError;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct PluginTrustRecord {
19 pub version: String,
21 pub marketplace: String,
23 pub manifest_sha256: String,
25 pub installed_at: String,
27}
28
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct TrustFile {
32 #[serde(flatten)]
34 pub plugins: BTreeMap<String, PluginTrustRecord>,
35}
36
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct MarketplacesAllowlist {
40 #[serde(default)]
42 pub approved: Vec<String>,
43}
44
45#[derive(Debug, Clone)]
47pub struct TrustStore {
48 pub trust_path: PathBuf,
50 pub allowlist_path: PathBuf,
52 pub records: TrustFile,
54 pub allowlist: MarketplacesAllowlist,
56}
57
58impl TrustStore {
59 pub fn open(trust_path: PathBuf, allowlist_path: PathBuf) -> Result<Self, PluginError> {
67 let records = read_json_or_default::<TrustFile>(&trust_path)?;
68 let allowlist = read_json_or_default::<MarketplacesAllowlist>(&allowlist_path)?;
69 Ok(Self {
70 trust_path,
71 allowlist_path,
72 records,
73 allowlist,
74 })
75 }
76
77 #[must_use]
79 pub fn default_paths() -> (PathBuf, PathBuf) {
80 let trust = dirs::data_local_dir().map_or_else(
81 || PathBuf::from(".caliban-trust/plugins.json"),
82 |d| d.join("caliban").join("trust").join("plugins.json"),
83 );
84 let allow = dirs::home_dir().map_or_else(
85 || PathBuf::from(".caliban/marketplaces-allowlist.json"),
86 |h| h.join(".caliban").join("marketplaces-allowlist.json"),
87 );
88 (trust, allow)
89 }
90
91 pub fn open_default() -> Result<Self, PluginError> {
97 let (t, a) = Self::default_paths();
98 Self::open(t, a)
99 }
100
101 pub fn save(&self) -> Result<(), PluginError> {
107 write_json(&self.trust_path, &self.records)?;
108 write_json(&self.allowlist_path, &self.allowlist)?;
109 Ok(())
110 }
111
112 #[must_use]
114 pub fn is_marketplace_approved(&self, url: &str) -> bool {
115 self.allowlist.approved.iter().any(|u| u == url)
116 }
117
118 pub fn approve_marketplace(&mut self, url: &str) {
120 if !self.is_marketplace_approved(url) {
121 self.allowlist.approved.push(url.to_string());
122 }
123 }
124
125 #[must_use]
127 pub fn get(&self, name: &str) -> Option<&PluginTrustRecord> {
128 self.records.plugins.get(name)
129 }
130
131 pub fn record(&mut self, name: &str, record: PluginTrustRecord) {
133 self.records.plugins.insert(name.to_string(), record);
134 }
135
136 pub fn forget(&mut self, name: &str) -> Option<PluginTrustRecord> {
138 self.records.plugins.remove(name)
139 }
140
141 #[must_use]
145 pub fn needs_prompt(
146 &self,
147 name: &str,
148 marketplace: &str,
149 version: &str,
150 manifest_sha256: &str,
151 ) -> bool {
152 match self.records.plugins.get(name) {
153 None => true,
154 Some(rec) => {
155 rec.marketplace != marketplace
156 || rec.version != version
157 || rec.manifest_sha256 != manifest_sha256
158 }
159 }
160 }
161}
162
163fn read_json_or_default<T: serde::de::DeserializeOwned + Default>(
164 path: &Path,
165) -> Result<T, PluginError> {
166 let raw = match std::fs::read_to_string(path) {
167 Ok(s) => s,
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
169 Err(source) => {
170 return Err(PluginError::Io {
171 path: path.to_path_buf(),
172 source,
173 });
174 }
175 };
176 if raw.trim().is_empty() {
177 return Ok(T::default());
178 }
179 serde_json::from_str(&raw).map_err(|source| PluginError::Parse {
180 path: path.to_path_buf(),
181 source,
182 })
183}
184
185fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<(), PluginError> {
186 if let Some(parent) = path.parent() {
187 std::fs::create_dir_all(parent).map_err(|source| PluginError::Io {
188 path: parent.to_path_buf(),
189 source,
190 })?;
191 }
192 let body = serde_json::to_string_pretty(value).map_err(|source| PluginError::Parse {
193 path: path.to_path_buf(),
194 source,
195 })?;
196 std::fs::write(path, body).map_err(|source| PluginError::Io {
197 path: path.to_path_buf(),
198 source,
199 })?;
200 Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn roundtrip_persists_records() {
209 let tmp = tempfile::TempDir::new().unwrap();
210 let trust = tmp.path().join("plugins.json");
211 let allow = tmp.path().join("marketplaces-allowlist.json");
212 {
213 let mut s = TrustStore::open(trust.clone(), allow.clone()).unwrap();
214 s.approve_marketplace("https://m.example.com/index.json");
215 s.record(
216 "demo",
217 PluginTrustRecord {
218 version: "1.0.0".into(),
219 marketplace: "https://m.example.com/index.json".into(),
220 manifest_sha256: "abc".into(),
221 installed_at: "2026-05-24T00:00:00Z".into(),
222 },
223 );
224 s.save().unwrap();
225 }
226 let s2 = TrustStore::open(trust, allow).unwrap();
227 assert!(s2.is_marketplace_approved("https://m.example.com/index.json"));
228 assert_eq!(s2.get("demo").unwrap().version, "1.0.0");
229 }
230
231 #[test]
232 fn needs_prompt_on_version_bump() {
233 let tmp = tempfile::TempDir::new().unwrap();
234 let mut s = TrustStore::open(
235 tmp.path().join("plugins.json"),
236 tmp.path().join("allow.json"),
237 )
238 .unwrap();
239 s.record(
240 "demo",
241 PluginTrustRecord {
242 version: "1.0.0".into(),
243 marketplace: "https://m/index.json".into(),
244 manifest_sha256: "abc".into(),
245 installed_at: "now".into(),
246 },
247 );
248 assert!(!s.needs_prompt("demo", "https://m/index.json", "1.0.0", "abc"));
249 assert!(s.needs_prompt("demo", "https://m/index.json", "1.1.0", "abc"));
250 assert!(s.needs_prompt("demo", "https://m/index.json", "1.0.0", "xyz"));
251 }
252
253 #[test]
254 fn missing_files_open_with_defaults() {
255 let tmp = tempfile::TempDir::new().unwrap();
256 let s = TrustStore::open(
257 tmp.path().join("does-not-exist.json"),
258 tmp.path().join("nope.json"),
259 )
260 .unwrap();
261 assert!(s.records.plugins.is_empty());
262 assert!(s.allowlist.approved.is_empty());
263 }
264}