Skip to main content

caliban_plugins/
trust.rs

1//! Trust store for marketplace plugin installs.
2//!
3//! Persisted at `~/.caliban/marketplaces-allowlist.json` (and a per-plugin
4//! trust file at `$XDG_DATA_HOME/caliban/trust/plugins.json`). The first
5//! install of a plugin from a new marketplace triggers a trust prompt; on
6//! approval the record is cached and subsequent installs from the same
7//! `(marketplace, plugin, manifest_hash)` triple skip the prompt.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::PluginError;
15
16/// Per-plugin trust record. Persisted under `plugins.json`.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct PluginTrustRecord {
19    /// Version installed.
20    pub version: String,
21    /// Marketplace URL (or `"sideload"`).
22    pub marketplace: String,
23    /// Hex-encoded sha256 of the manifest at install time.
24    pub manifest_sha256: String,
25    /// RFC 3339 timestamp.
26    pub installed_at: String,
27}
28
29/// On-disk format of the per-plugin trust file.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct TrustFile {
32    /// `<plugin-name>` → record.
33    #[serde(flatten)]
34    pub plugins: BTreeMap<String, PluginTrustRecord>,
35}
36
37/// On-disk format of the marketplaces allowlist.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct MarketplacesAllowlist {
40    /// Approved marketplace URLs.
41    #[serde(default)]
42    pub approved: Vec<String>,
43}
44
45/// In-memory wrapper around the two on-disk files.
46#[derive(Debug, Clone)]
47pub struct TrustStore {
48    /// Path to `plugins.json`.
49    pub trust_path: PathBuf,
50    /// Path to `marketplaces-allowlist.json`.
51    pub allowlist_path: PathBuf,
52    /// Cached trust records.
53    pub records: TrustFile,
54    /// Cached marketplace allowlist.
55    pub allowlist: MarketplacesAllowlist,
56}
57
58impl TrustStore {
59    /// Construct a store at the given paths and lazily load both files (if
60    /// they exist; missing files yield empty defaults).
61    ///
62    /// # Errors
63    ///
64    /// Returns [`PluginError::Io`] on read failure other than `NotFound`
65    /// and [`PluginError::Parse`] on malformed JSON.
66    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    /// Default on-disk paths under `$HOME` / `$XDG_DATA_HOME`.
78    #[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    /// Open the default trust store.
92    ///
93    /// # Errors
94    ///
95    /// See [`TrustStore::open`].
96    pub fn open_default() -> Result<Self, PluginError> {
97        let (t, a) = Self::default_paths();
98        Self::open(t, a)
99    }
100
101    /// Persist both files to disk (best-effort; creates parent dirs).
102    ///
103    /// # Errors
104    ///
105    /// Returns [`PluginError::Io`] on write failure.
106    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    /// Returns true if the marketplace URL was previously approved.
113    #[must_use]
114    pub fn is_marketplace_approved(&self, url: &str) -> bool {
115        self.allowlist.approved.iter().any(|u| u == url)
116    }
117
118    /// Mark a marketplace URL as approved (idempotent).
119    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    /// Return the existing trust record for `name`, if any.
126    #[must_use]
127    pub fn get(&self, name: &str) -> Option<&PluginTrustRecord> {
128        self.records.plugins.get(name)
129    }
130
131    /// Record an install. Replaces any existing record for the same name.
132    pub fn record(&mut self, name: &str, record: PluginTrustRecord) {
133        self.records.plugins.insert(name.to_string(), record);
134    }
135
136    /// Drop the trust record for `name`. Returns the previous record if any.
137    pub fn forget(&mut self, name: &str) -> Option<PluginTrustRecord> {
138        self.records.plugins.remove(name)
139    }
140
141    /// Decide whether an install should reprompt. The rule (from the spec):
142    /// same `(marketplace, version, manifest_sha256)` → skip; any change →
143    /// reprompt.
144    #[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}