Skip to main content

kovra_core/
registry.rs

1//! The central vault registry and override resolution (spec §1.1, §10.3).
2//!
3//! Layout under the registry root (default `~/.vaults`):
4//!
5//! ```text
6//! ~/.vaults/
7//!   global/                 <- the global vault (per-secret .sec files + index.redb)
8//!   projects/
9//!     <name>/               <- one project vault per directory
10//! ```
11//!
12//! Resolution follows the §1.1 table: a project vault **shadows** the global at
13//! the exact coordinate; an explicit `secret://global/...` scope selector
14//! bypasses the project vault and resolves only against the global. The
15//! `.env.refs` fallback (step 3 of the table) and `${ENV}` substitution are L4
16//! and out of scope here.
17
18use std::path::{Path, PathBuf};
19
20use crate::coordinate::{Coordinate, Scope};
21use crate::error::CoreError;
22use crate::keyring::Keyring;
23use crate::record::SecretRecord;
24use crate::store;
25
26/// Directory name of the global vault under the registry root.
27pub const GLOBAL_DIR: &str = "global";
28/// Directory holding per-project vaults under the registry root.
29pub const PROJECTS_DIR: &str = "projects";
30
31/// Which vault a coordinate resolved against. (Distinct from
32/// [`crate::scope::Origin`], which is the request *initiator* — agent vs human.)
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum VaultOrigin {
35    /// Resolved from the global vault.
36    Global,
37    /// Resolved from the named project vault (it shadowed the global, if any).
38    Project(String),
39}
40
41/// The outcome of resolving a coordinate against the registry.
42///
43/// The `Found`/`NotFound` size disparity is inherent: a found resolution must
44/// carry the decrypted [`SecretRecord`] (whose `Keypair` variant holds an
45/// OpenSSH private key), while `NotFound` is empty. Resolutions are short-lived,
46/// pattern-matched values on the stack — boxing the record would add an
47/// allocation on the hot point-lookup path for no real benefit — so the
48/// large-variant lint is intentionally allowed here.
49#[derive(Debug)]
50#[allow(clippy::large_enum_variant)]
51pub enum Resolution {
52    /// A record was found; carries it and where it came from.
53    Found {
54        /// The decrypted record.
55        record: SecretRecord,
56        /// The vault that produced it.
57        origin: VaultOrigin,
58    },
59    /// No record at this coordinate in the applicable vault(s). (The `.env.refs`
60    /// fallback — step 3 of the §1.1 table — is L4, not handled here.)
61    NotFound,
62}
63
64/// The vault registry rooted at a directory (default `~/.vaults`).
65pub struct Registry {
66    root: PathBuf,
67}
68
69impl Registry {
70    /// Open the registry at `root`, creating `global/` and `projects/` (each
71    /// `0700`) if missing.
72    pub fn open(root: impl Into<PathBuf>) -> Result<Self, CoreError> {
73        let root = root.into();
74        let registry = Self { root };
75        store::ensure_dir(&registry.global_dir())?;
76        store::ensure_dir(&registry.projects_root())?;
77        Ok(registry)
78    }
79
80    /// The default registry root, `~/.vaults`. Errors if no home directory is
81    /// known.
82    pub fn default_root() -> Result<PathBuf, CoreError> {
83        let home = std::env::var_os("HOME")
84            .or_else(|| std::env::var_os("USERPROFILE"))
85            .ok_or_else(|| CoreError::Io("no home directory ($HOME) set".to_string()))?;
86        Ok(PathBuf::from(home).join(".vaults"))
87    }
88
89    /// The registry root directory.
90    pub fn root(&self) -> &Path {
91        &self.root
92    }
93
94    /// The global vault directory.
95    pub fn global_dir(&self) -> PathBuf {
96        self.root.join(GLOBAL_DIR)
97    }
98
99    /// The `projects/` parent directory.
100    pub fn projects_root(&self) -> PathBuf {
101        self.root.join(PROJECTS_DIR)
102    }
103
104    /// A specific project vault directory.
105    pub fn project_dir(&self, name: &str) -> PathBuf {
106        self.projects_root().join(name)
107    }
108
109    /// Enumerate project vault names (the `projects/*` directory entries),
110    /// sorted. Used by the Web UI selector (§10.3) — a later layer.
111    pub fn list_projects(&self) -> Result<Vec<String>, CoreError> {
112        let dir = self.projects_root();
113        if !dir.exists() {
114            return Ok(Vec::new());
115        }
116        let mut names = Vec::new();
117        for entry in std::fs::read_dir(&dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))? {
118            let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
119            if entry
120                .file_type()
121                .map_err(|e| CoreError::Io(format!("file_type: {e}")))?
122                .is_dir()
123                && let Some(name) = entry.file_name().to_str()
124            {
125                names.push(name.to_string());
126            }
127        }
128        names.sort();
129        Ok(names)
130    }
131
132    /// Resolve a coordinate per the §1.1 override table (steps 1–2; step 3 is
133    /// L4). With [`Scope::Global`], only the global vault is consulted. With
134    /// [`Scope::Default`] and a named project, the project record **wins** when
135    /// present, otherwise the global is used.
136    pub fn resolve(
137        &self,
138        coord: &Coordinate,
139        project: Option<&str>,
140        keyring: &dyn Keyring,
141    ) -> Result<Resolution, CoreError> {
142        let key = keyring.get_master_key()?;
143        self.resolve_with_key(coord, project, key.expose())
144    }
145
146    /// Like [`Registry::resolve`] but with an already-materialized master key.
147    /// The resolver (L4) fetches the key **once** and calls this per variable so
148    /// a passphrase-derived key (`Argon2Keyring`) is not re-derived per lookup.
149    pub fn resolve_with_key(
150        &self,
151        coord: &Coordinate,
152        project: Option<&str>,
153        key: &[u8; crate::crypto::KEY_LEN],
154    ) -> Result<Resolution, CoreError> {
155        // Step 1: project vault (only for default scope, only if a project is named).
156        if coord.scope == Scope::Default
157            && let Some(name) = project
158            && let Some(record) = store::read_record(&self.project_dir(name), coord, key)?
159        {
160            return Ok(Resolution::Found {
161                record,
162                origin: VaultOrigin::Project(name.to_string()),
163            });
164        }
165
166        // Step 2: global vault.
167        if let Some(record) = store::read_record(&self.global_dir(), coord, key)? {
168            return Ok(Resolution::Found {
169                record,
170                origin: VaultOrigin::Global,
171            });
172        }
173
174        Ok(Resolution::NotFound)
175    }
176
177    /// Whether a coordinate is **shadowed**: defined in both the named project
178    /// vault and the global vault (the project wins). Feeds the shadowing
179    /// visibility surfaced by the Web UI / `doctor` in later layers. A
180    /// `secret://global/...` coordinate is never shadowed (it ignores the
181    /// project), so this returns `false` for [`Scope::Global`].
182    pub fn shadows(&self, coord: &Coordinate, project: &str) -> Result<bool, CoreError> {
183        if coord.scope == Scope::Global {
184            return Ok(false);
185        }
186        let in_project = store::record_path(&self.project_dir(project), coord)?.exists();
187        let in_global = store::record_path(&self.global_dir(), coord)?.exists();
188        Ok(in_project && in_global)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::crypto::seal;
196    use crate::keyring::MockKeyring;
197    use crate::secret::SecretValue;
198    use crate::sensitivity::Sensitivity;
199
200    fn keyring() -> MockKeyring {
201        MockKeyring::with_key([0x55; crate::crypto::KEY_LEN])
202    }
203
204    fn master() -> [u8; crate::crypto::KEY_LEN] {
205        [0x55; crate::crypto::KEY_LEN]
206    }
207
208    fn literal(value: &str) -> SecretRecord {
209        SecretRecord::Literal {
210            value: SecretValue::from(value),
211            sensitivity: Sensitivity::Medium,
212            revealable: false,
213            environment: "prod".to_string(),
214            component: "db".to_string(),
215            key: "password".to_string(),
216            description: None,
217            created: "2026-05-30T00:00:00Z".to_string(),
218            updated: "2026-05-30T00:00:00Z".to_string(),
219        }
220    }
221
222    fn value_of(res: Resolution) -> (Vec<u8>, VaultOrigin) {
223        match res {
224            Resolution::Found { record, origin } => match record {
225                SecretRecord::Literal { value, .. } => (value.expose().to_vec(), origin),
226                other => panic!("expected literal, got {other:?}"),
227            },
228            Resolution::NotFound => panic!("expected found, got NotFound"),
229        }
230    }
231
232    #[test]
233    fn registry_creates_layout() {
234        let tmp = tempfile::tempdir().unwrap();
235        let reg = Registry::open(tmp.path()).unwrap();
236        assert!(reg.global_dir().is_dir());
237        assert!(reg.projects_root().is_dir());
238    }
239
240    #[test]
241    fn project_shadows_global_at_exact_coordinate() {
242        let tmp = tempfile::tempdir().unwrap();
243        let reg = Registry::open(tmp.path()).unwrap();
244        let c: Coordinate = "secret:prod/db/password".parse().unwrap();
245
246        store::write_record(
247            &reg.global_dir(),
248            &c,
249            &seal(&literal("global-val"), &master()).unwrap(),
250        )
251        .unwrap();
252        store::write_record(
253            &reg.project_dir("api"),
254            &c,
255            &seal(&literal("project-val"), &master()).unwrap(),
256        )
257        .unwrap();
258
259        let (val, origin) = value_of(reg.resolve(&c, Some("api"), &keyring()).unwrap());
260        assert_eq!(val, b"project-val");
261        assert_eq!(origin, VaultOrigin::Project("api".to_string()));
262        assert!(reg.shadows(&c, "api").unwrap());
263    }
264
265    #[test]
266    fn falls_back_to_global_when_project_lacks_coordinate() {
267        let tmp = tempfile::tempdir().unwrap();
268        let reg = Registry::open(tmp.path()).unwrap();
269        let c: Coordinate = "secret:prod/db/password".parse().unwrap();
270        store::write_record(
271            &reg.global_dir(),
272            &c,
273            &seal(&literal("global-val"), &master()).unwrap(),
274        )
275        .unwrap();
276
277        let (val, origin) = value_of(reg.resolve(&c, Some("api"), &keyring()).unwrap());
278        assert_eq!(val, b"global-val");
279        assert_eq!(origin, VaultOrigin::Global);
280        assert!(!reg.shadows(&c, "api").unwrap());
281    }
282
283    #[test]
284    fn global_scope_selector_bypasses_project() {
285        let tmp = tempfile::tempdir().unwrap();
286        let reg = Registry::open(tmp.path()).unwrap();
287        // Both vaults define the same address; the project would normally win.
288        let stored: Coordinate = "secret:prod/db/password".parse().unwrap();
289        store::write_record(
290            &reg.global_dir(),
291            &stored,
292            &seal(&literal("global-val"), &master()).unwrap(),
293        )
294        .unwrap();
295        store::write_record(
296            &reg.project_dir("api"),
297            &stored,
298            &seal(&literal("project-val"), &master()).unwrap(),
299        )
300        .unwrap();
301
302        // The global scope selector must ignore the project vault.
303        let global_coord: Coordinate = "secret://global/prod/db/password".parse().unwrap();
304        let (val, origin) = value_of(reg.resolve(&global_coord, Some("api"), &keyring()).unwrap());
305        assert_eq!(val, b"global-val");
306        assert_eq!(origin, VaultOrigin::Global);
307        assert!(!reg.shadows(&global_coord, "api").unwrap());
308    }
309
310    #[test]
311    fn unknown_coordinate_is_not_found() {
312        let tmp = tempfile::tempdir().unwrap();
313        let reg = Registry::open(tmp.path()).unwrap();
314        let c: Coordinate = "secret:prod/db/absent".parse().unwrap();
315        assert!(matches!(
316            reg.resolve(&c, Some("api"), &keyring()).unwrap(),
317            Resolution::NotFound
318        ));
319    }
320
321    #[test]
322    fn list_projects_enumerates_sorted() {
323        let tmp = tempfile::tempdir().unwrap();
324        let reg = Registry::open(tmp.path()).unwrap();
325        store::ensure_dir(&reg.project_dir("billing")).unwrap();
326        store::ensure_dir(&reg.project_dir("api")).unwrap();
327        assert_eq!(reg.list_projects().unwrap(), vec!["api", "billing"]);
328    }
329}