use std::path::{Path, PathBuf};
use crate::coordinate::{Coordinate, Scope};
use crate::error::CoreError;
use crate::keyring::Keyring;
use crate::record::SecretRecord;
use crate::store;
pub const GLOBAL_DIR: &str = "global";
pub const PROJECTS_DIR: &str = "projects";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultOrigin {
Global,
Project(String),
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum Resolution {
Found {
record: SecretRecord,
origin: VaultOrigin,
},
NotFound,
}
pub struct Registry {
root: PathBuf,
}
impl Registry {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, CoreError> {
let root = root.into();
let registry = Self { root };
store::ensure_dir(®istry.global_dir())?;
store::ensure_dir(®istry.projects_root())?;
Ok(registry)
}
#[cfg(not(windows))]
pub fn default_root() -> Result<PathBuf, CoreError> {
let home = std::env::var_os("HOME")
.ok_or_else(|| CoreError::Io("no home directory ($HOME) set".to_string()))?;
Ok(PathBuf::from(home).join(".vaults"))
}
#[cfg(windows)]
pub fn default_root() -> Result<PathBuf, CoreError> {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("USERPROFILE").map(|p| {
let mut pb = PathBuf::from(p);
pb.push("AppData");
pb.push("Local");
pb
})
})
.ok_or_else(|| CoreError::Io("no %LOCALAPPDATA% or %USERPROFILE% set".to_string()))?;
Ok(base.join("kovra").join("vaults"))
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn global_dir(&self) -> PathBuf {
self.root.join(GLOBAL_DIR)
}
pub fn projects_root(&self) -> PathBuf {
self.root.join(PROJECTS_DIR)
}
pub fn project_dir(&self, name: &str) -> PathBuf {
self.projects_root().join(name)
}
pub fn list_projects(&self) -> Result<Vec<String>, CoreError> {
let dir = self.projects_root();
if !dir.exists() {
return Ok(Vec::new());
}
let mut names = Vec::new();
for entry in std::fs::read_dir(&dir).map_err(|e| CoreError::Io(format!("read_dir: {e}")))? {
let entry = entry.map_err(|e| CoreError::Io(format!("dir entry: {e}")))?;
if entry
.file_type()
.map_err(|e| CoreError::Io(format!("file_type: {e}")))?
.is_dir()
&& let Some(name) = entry.file_name().to_str()
{
names.push(name.to_string());
}
}
names.sort();
Ok(names)
}
pub fn resolve(
&self,
coord: &Coordinate,
project: Option<&str>,
keyring: &dyn Keyring,
) -> Result<Resolution, CoreError> {
let key = keyring.get_master_key()?;
self.resolve_with_key(coord, project, key.expose())
}
pub fn resolve_with_key(
&self,
coord: &Coordinate,
project: Option<&str>,
key: &[u8; crate::crypto::KEY_LEN],
) -> Result<Resolution, CoreError> {
if coord.scope == Scope::Default
&& let Some(name) = project
&& let Some(record) = store::read_record(&self.project_dir(name), coord, key)?
{
return Ok(Resolution::Found {
record,
origin: VaultOrigin::Project(name.to_string()),
});
}
if let Some(record) = store::read_record(&self.global_dir(), coord, key)? {
return Ok(Resolution::Found {
record,
origin: VaultOrigin::Global,
});
}
Ok(Resolution::NotFound)
}
pub fn shadows(&self, coord: &Coordinate, project: &str) -> Result<bool, CoreError> {
if coord.scope == Scope::Global {
return Ok(false);
}
let in_project = store::record_path(&self.project_dir(project), coord)?.exists();
let in_global = store::record_path(&self.global_dir(), coord)?.exists();
Ok(in_project && in_global)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::seal;
use crate::keyring::MockKeyring;
use crate::secret::SecretValue;
use crate::sensitivity::Sensitivity;
fn keyring() -> MockKeyring {
MockKeyring::with_key([0x55; crate::crypto::KEY_LEN])
}
#[cfg(windows)]
#[test]
fn windows_default_root_is_under_localappdata() {
let root = Registry::default_root().expect("default root resolves on Windows");
assert!(
root.ends_with("kovra\\vaults") || root.ends_with("kovra/vaults"),
"default root should be in a kovra\\vaults app folder, got {root:?}"
);
let s = root.to_string_lossy();
assert!(
s.contains("AppData\\Local") || s.contains("AppData/Local"),
"default root should be under %LOCALAPPDATA% (AppData\\Local), got {s}"
);
assert!(
!s.contains(".vaults"),
"Windows must not use the Unix-style .vaults dotfile, got {s}"
);
}
fn master() -> [u8; crate::crypto::KEY_LEN] {
[0x55; crate::crypto::KEY_LEN]
}
fn literal(value: &str) -> SecretRecord {
SecretRecord::Literal {
value: SecretValue::from(value),
sensitivity: Sensitivity::Medium,
revealable: false,
environment: "prod".to_string(),
component: "db".to_string(),
key: "password".to_string(),
description: None,
created: "2026-05-30T00:00:00Z".to_string(),
updated: "2026-05-30T00:00:00Z".to_string(),
}
}
fn value_of(res: Resolution) -> (Vec<u8>, VaultOrigin) {
match res {
Resolution::Found { record, origin } => match record {
SecretRecord::Literal { value, .. } => (value.expose().to_vec(), origin),
other => panic!("expected literal, got {other:?}"),
},
Resolution::NotFound => panic!("expected found, got NotFound"),
}
}
#[test]
fn registry_creates_layout() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
assert!(reg.global_dir().is_dir());
assert!(reg.projects_root().is_dir());
}
#[test]
fn project_shadows_global_at_exact_coordinate() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
let c: Coordinate = "secret:prod/db/password".parse().unwrap();
store::write_record(
®.global_dir(),
&c,
&seal(&literal("global-val"), &master()).unwrap(),
)
.unwrap();
store::write_record(
®.project_dir("api"),
&c,
&seal(&literal("project-val"), &master()).unwrap(),
)
.unwrap();
let (val, origin) = value_of(reg.resolve(&c, Some("api"), &keyring()).unwrap());
assert_eq!(val, b"project-val");
assert_eq!(origin, VaultOrigin::Project("api".to_string()));
assert!(reg.shadows(&c, "api").unwrap());
}
#[test]
fn falls_back_to_global_when_project_lacks_coordinate() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
let c: Coordinate = "secret:prod/db/password".parse().unwrap();
store::write_record(
®.global_dir(),
&c,
&seal(&literal("global-val"), &master()).unwrap(),
)
.unwrap();
let (val, origin) = value_of(reg.resolve(&c, Some("api"), &keyring()).unwrap());
assert_eq!(val, b"global-val");
assert_eq!(origin, VaultOrigin::Global);
assert!(!reg.shadows(&c, "api").unwrap());
}
#[test]
fn global_scope_selector_bypasses_project() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
let stored: Coordinate = "secret:prod/db/password".parse().unwrap();
store::write_record(
®.global_dir(),
&stored,
&seal(&literal("global-val"), &master()).unwrap(),
)
.unwrap();
store::write_record(
®.project_dir("api"),
&stored,
&seal(&literal("project-val"), &master()).unwrap(),
)
.unwrap();
let global_coord: Coordinate = "secret://global/prod/db/password".parse().unwrap();
let (val, origin) = value_of(reg.resolve(&global_coord, Some("api"), &keyring()).unwrap());
assert_eq!(val, b"global-val");
assert_eq!(origin, VaultOrigin::Global);
assert!(!reg.shadows(&global_coord, "api").unwrap());
}
#[test]
fn unknown_coordinate_is_not_found() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
let c: Coordinate = "secret:prod/db/absent".parse().unwrap();
assert!(matches!(
reg.resolve(&c, Some("api"), &keyring()).unwrap(),
Resolution::NotFound
));
}
#[test]
fn list_projects_enumerates_sorted() {
let tmp = tempfile::tempdir().unwrap();
let reg = Registry::open(tmp.path()).unwrap();
store::ensure_dir(®.project_dir("billing")).unwrap();
store::ensure_dir(®.project_dir("api")).unwrap();
assert_eq!(reg.list_projects().unwrap(), vec!["api", "billing"]);
}
}