1use 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
27pub const GLOBAL_DIR: &str = "global";
29pub const PROJECTS_DIR: &str = "projects";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum VaultOrigin {
36 Global,
38 Project(String),
40}
41
42#[derive(Debug)]
51#[allow(clippy::large_enum_variant)]
52pub enum Resolution {
53 Found {
55 record: SecretRecord,
57 origin: VaultOrigin,
59 },
60 NotFound,
63}
64
65pub struct Registry {
68 root: PathBuf,
69}
70
71impl Registry {
72 pub fn open(root: impl Into<PathBuf>) -> Result<Self, CoreError> {
75 let root = root.into();
76 let registry = Self { root };
77 store::ensure_dir(®istry.global_dir())?;
78 store::ensure_dir(®istry.projects_root())?;
79 Ok(registry)
80 }
81
82 #[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 #[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 pub fn root(&self) -> &Path {
116 &self.root
117 }
118
119 pub fn global_dir(&self) -> PathBuf {
121 self.root.join(GLOBAL_DIR)
122 }
123
124 pub fn projects_root(&self) -> PathBuf {
126 self.root.join(PROJECTS_DIR)
127 }
128
129 pub fn project_dir(&self, name: &str) -> PathBuf {
131 self.projects_root().join(name)
132 }
133
134 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 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 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 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 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 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 #[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 ®.global_dir(),
295 &c,
296 &seal(&literal("global-val"), &master()).unwrap(),
297 )
298 .unwrap();
299 store::write_record(
300 ®.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 ®.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 let stored: Coordinate = "secret:prod/db/password".parse().unwrap();
336 store::write_record(
337 ®.global_dir(),
338 &stored,
339 &seal(&literal("global-val"), &master()).unwrap(),
340 )
341 .unwrap();
342 store::write_record(
343 ®.project_dir("api"),
344 &stored,
345 &seal(&literal("project-val"), &master()).unwrap(),
346 )
347 .unwrap();
348
349 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(®.project_dir("billing")).unwrap();
373 store::ensure_dir(®.project_dir("api")).unwrap();
374 assert_eq!(reg.list_projects().unwrap(), vec!["api", "billing"]);
375 }
376}