Skip to main content

confium_registry/
trust.rs

1//! Local trust store.
2//!
3//! The user's trusted publishers live under
4//! `~/.config/confium/trust/<publisher>.toml`, one file per publisher.
5//! Each file mirrors the `[[publisher]]` row from the registry's
6//! `trust-roots.toml` so the same [`TrustRoot`] type round-trips through
7//! both. This makes "trust a publisher" a simple file write — auditable,
8//! mergeable, and trivially backed up.
9//!
10//! [`TrustStore`] owns the file-layout knowledge so the CLI's `trust`
11//! sub-commands stay thin.
12
13use std::path::{Path, PathBuf};
14
15use crate::error::{Error, Result};
16use crate::manifest::TrustRoot;
17use crate::paths::trust_dir;
18
19/// One row in the local trust store.
20pub type TrustStoreEntry = TrustRoot;
21
22/// A handle on the local trust directory.
23///
24/// Construct with [`TrustStore::new`] (real home) or
25/// [`TrustStore::for_home`] (tests). All operations are file-backed; the
26/// type holds no in-memory cache so concurrent edits by external tools
27/// are picked up on the next read.
28pub struct TrustStore {
29    override_home: Option<PathBuf>,
30}
31
32impl TrustStore {
33    /// Bind to the user's real trust directory.
34    pub fn new() -> Self {
35        TrustStore {
36            override_home: None,
37        }
38    }
39
40    /// Bind to `<override_home>/.config/confium/trust`.
41    pub fn for_home(override_home: PathBuf) -> Self {
42        TrustStore {
43            override_home: Some(override_home),
44        }
45    }
46
47    /// The trust directory backing this store.
48    pub fn dir(&self) -> Result<PathBuf> {
49        trust_dir(self.override_home.as_ref())
50    }
51
52    /// List trusted publishers, sorted by name.
53    pub fn list(&self) -> Result<Vec<TrustStoreEntry>> {
54        let dir = self.dir()?;
55        if !dir.exists() {
56            return Ok(Vec::new());
57        }
58        let mut entries = Vec::new();
59        let read = std::fs::read_dir(&dir)
60            .map_err(|e| Error::io(e, format!("failed to read {}", dir.display())))?;
61        for entry in read {
62            let entry = entry.map_err(|e| Error::io(e, "directory iteration error"))?;
63            let path = entry.path();
64            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
65                continue;
66            }
67            if let Ok(root) = self.read_file(&path) {
68                entries.push(root);
69            }
70        }
71        entries.sort_by(|a, b| a.name.cmp(&b.name));
72        Ok(entries)
73    }
74
75    /// Add (or replace) a trusted publisher.
76    pub fn add(&self, root: TrustStoreEntry) -> Result<()> {
77        let dir = self.dir()?;
78        std::fs::create_dir_all(&dir)
79            .map_err(|e| Error::io(e, format!("failed to create {}", dir.display())))?;
80        let path = self.path_for(&root.name);
81        // Single-publisher file: write just the [[publisher]] row to keep
82        // the format minimal and human-editable.
83        let body = toml::to_string(&root).map_err(|e| Error::TomlSerialize { source: e })?;
84        std::fs::write(&path, body)
85            .map_err(|e| Error::io(e, format!("failed to write {}", path.display())))?;
86        Ok(())
87    }
88
89    /// Remove a trusted publisher. Succeeds (no-op) if not present.
90    pub fn remove(&self, name: &str) -> Result<bool> {
91        let path = self.path_for(name);
92        if !path.exists() {
93            return Ok(false);
94        }
95        std::fs::remove_file(&path)
96            .map_err(|e| Error::io(e, format!("failed to remove {}", path.display())))?;
97        Ok(true)
98    }
99
100    /// True if `name` is trusted.
101    pub fn contains(&self, name: &str) -> Result<bool> {
102        Ok(self.path_for(name).exists())
103    }
104
105    fn path_for(&self, name: &str) -> PathBuf {
106        // Sanitize the publisher name so it can't escape the trust dir.
107        let safe: String = name
108            .chars()
109            .map(|c| {
110                if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
111                    c
112                } else {
113                    '_'
114                }
115            })
116            .collect();
117        self.dir()
118            .unwrap_or_else(|_| PathBuf::from("."))
119            .join(format!("{safe}.toml"))
120    }
121
122    fn read_file(&self, path: &Path) -> Result<TrustStoreEntry> {
123        let body = std::fs::read_to_string(path)
124            .map_err(|e| Error::io(e, format!("failed to read {}", path.display())))?;
125        toml::from_str(&body).map_err(|e| Error::TomlParse {
126            path: path.display().to_string(),
127            source: e,
128        })
129    }
130}
131
132impl Default for TrustStore {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn root(name: &str) -> TrustStoreEntry {
143        TrustRoot {
144            name: name.to_string(),
145            key_id: "0xABCD".to_string(),
146            fingerprint: "AAAA BBBB".to_string(),
147            key_url: format!("/publishers/{name}.asc"),
148        }
149    }
150
151    #[test]
152    fn list_on_missing_dir_is_empty() {
153        let tmp = tempfile::tempdir().unwrap();
154        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
155        assert!(store.list().unwrap().is_empty());
156    }
157
158    #[test]
159    fn add_then_list() {
160        let tmp = tempfile::tempdir().unwrap();
161        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
162        store.add(root("ribose")).unwrap();
163        let entries = store.list().unwrap();
164        assert_eq!(entries.len(), 1);
165        assert_eq!(entries[0].name, "ribose");
166    }
167
168    #[test]
169    fn add_overwrites() {
170        let tmp = tempfile::tempdir().unwrap();
171        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
172        store.add(root("ribose")).unwrap();
173        let mut updated = root("ribose");
174        updated.fingerprint = "CCCC".to_string();
175        store.add(updated).unwrap();
176        let entries = store.list().unwrap();
177        assert_eq!(entries.len(), 1);
178        assert_eq!(entries[0].fingerprint, "CCCC");
179    }
180
181    #[test]
182    fn remove_returns_true_when_present() {
183        let tmp = tempfile::tempdir().unwrap();
184        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
185        store.add(root("ribose")).unwrap();
186        assert!(store.remove("ribose").unwrap());
187        assert!(!store.contains("ribose").unwrap());
188    }
189
190    #[test]
191    fn remove_returns_false_when_absent() {
192        let tmp = tempfile::tempdir().unwrap();
193        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
194        assert!(!store.remove("ghost").unwrap());
195    }
196
197    #[test]
198    fn path_for_sanitizes_dangerous_names() {
199        let tmp = tempfile::tempdir().unwrap();
200        let store = TrustStore::for_home(PathBuf::from(tmp.path()));
201        let path = store.path_for("../etc/passwd");
202        let name = path.file_name().unwrap().to_str().unwrap();
203        assert!(!name.contains('/'));
204        assert!(!name.contains(".."));
205    }
206}