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