1use 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
26pub const GLOBAL_DIR: &str = "global";
28pub const PROJECTS_DIR: &str = "projects";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum VaultOrigin {
35 Global,
37 Project(String),
39}
40
41#[derive(Debug)]
50#[allow(clippy::large_enum_variant)]
51pub enum Resolution {
52 Found {
54 record: SecretRecord,
56 origin: VaultOrigin,
58 },
59 NotFound,
62}
63
64pub struct Registry {
66 root: PathBuf,
67}
68
69impl Registry {
70 pub fn open(root: impl Into<PathBuf>) -> Result<Self, CoreError> {
73 let root = root.into();
74 let registry = Self { root };
75 store::ensure_dir(®istry.global_dir())?;
76 store::ensure_dir(®istry.projects_root())?;
77 Ok(registry)
78 }
79
80 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 pub fn root(&self) -> &Path {
91 &self.root
92 }
93
94 pub fn global_dir(&self) -> PathBuf {
96 self.root.join(GLOBAL_DIR)
97 }
98
99 pub fn projects_root(&self) -> PathBuf {
101 self.root.join(PROJECTS_DIR)
102 }
103
104 pub fn project_dir(&self, name: &str) -> PathBuf {
106 self.projects_root().join(name)
107 }
108
109 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 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 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 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 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 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 ®.global_dir(),
248 &c,
249 &seal(&literal("global-val"), &master()).unwrap(),
250 )
251 .unwrap();
252 store::write_record(
253 ®.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 ®.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 let stored: Coordinate = "secret:prod/db/password".parse().unwrap();
289 store::write_record(
290 ®.global_dir(),
291 &stored,
292 &seal(&literal("global-val"), &master()).unwrap(),
293 )
294 .unwrap();
295 store::write_record(
296 ®.project_dir("api"),
297 &stored,
298 &seal(&literal("project-val"), &master()).unwrap(),
299 )
300 .unwrap();
301
302 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(®.project_dir("billing")).unwrap();
326 store::ensure_dir(®.project_dir("api")).unwrap();
327 assert_eq!(reg.list_projects().unwrap(), vec!["api", "billing"]);
328 }
329}