#![expect( clippy::disallowed_methods, reason = "fabro-vault: sync secret-file storage; not used on a Tokio hot path" )] mod store; use std::collections::HashMap; use std::path::{Component, Path, PathBuf}; use std::{fmt, io}; use chrono::{DateTime, Utc}; pub use fabro_db::ImportReport; use fabro_static::EnvVars; pub use fabro_types::SecretType; use fabro_types::{SecretMetadata, is_env_style_name}; pub use store::{ SecretSnapshot, SecretStore, SecretStoreError, SecretStoreWrite, import_legacy_json_once, }; #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SecretEntry { pub value: String, #[serde(rename = "type", default)] pub secret_type: SecretType, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, pub created_at: DateTime, pub updated_at: DateTime, #[serde(skip, default = "default_revision")] pub revision: i64, } const fn default_revision() -> i64 { 1 } impl fmt::Debug for SecretEntry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SecretEntry") .field("value", &"[REDACTED]") .field("secret_type", &self.secret_type) .field("description", &self.description) .field("created_at", &self.created_at) .field("updated_at", &self.updated_at) .field("revision", &self.revision) .finish() } } #[derive(Debug)] pub enum Error { InvalidName(String), NotFound(String), Io(std::io::Error), Serde(serde_json::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidName(name) => write!(f, "invalid secret name: {name}"), Self::NotFound(name) => write!(f, "secret not found: {name}"), Self::Io(err) => write!(f, "{err}"), Self::Serde(err) => write!(f, "{err}"), } } } impl std::error::Error for Error {} impl From for Error { fn from(value: std::io::Error) -> Self { Self::Io(value) } } impl From for Error { fn from(value: serde_json::Error) -> Self { Self::Serde(value) } } #[derive(Clone)] pub struct Vault { path: Option, entries: HashMap, } impl fmt::Debug for Vault { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Vault") .field("path", &self.path) .field("entry_count", &self.entries.len()) .finish() } } impl Vault { pub fn load(path: PathBuf) -> Result { let entries = match std::fs::read_to_string(&path) { Ok(contents) => serde_json::from_str(&contents)?, Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(), Err(err) => return Err(io_context("read vault", &path, &err).into()), }; Ok(Self { path: Some(path), entries, }) } /// Builds a detached in-memory vault with no backing file: mutations /// update memory only and are never persisted to disk. #[must_use] pub fn from_entries(entries: HashMap) -> Self { Self { path: None, entries, } } #[must_use] pub fn entries(&self) -> &HashMap { &self.entries } pub fn set( &mut self, name: &str, value: &str, secret_type: SecretType, description: Option<&str>, ) -> Result { Self::validate_name(name, secret_type)?; let now = Utc::now(); let (created_at, description, revision) = self.entries.get(name).map_or_else( || (now, description.map(str::to_string), 1), |entry| { ( entry.created_at, description .map(str::to_string) .or_else(|| entry.description.clone()), entry.revision.saturating_add(1), ) }, ); let entry = SecretEntry { value: value.to_string(), secret_type, description: description.clone(), created_at, updated_at: now, revision, }; self.entries.insert(name.to_string(), entry); self.write_atomic()?; Ok(SecretMetadata { name: name.to_string(), secret_type, description, created_at, updated_at: now, }) } pub fn remove(&mut self, name: &str) -> Result<(), Error> { if self.entries.remove(name).is_none() { return Err(Error::NotFound(name.to_string())); } self.write_atomic()?; Ok(()) } pub fn list(&self) -> Vec { let mut data = self .entries .iter() .map(|(name, entry)| SecretMetadata { name: name.clone(), secret_type: entry.secret_type, description: entry.description.clone(), created_at: entry.created_at, updated_at: entry.updated_at, }) .collect::>(); data.sort_by(|a, b| a.name.cmp(&b.name)); data } pub fn get(&self, name: &str) -> Option<&str> { self.entries.get(name).map(|entry| entry.value.as_str()) } pub fn get_entry(&self, name: &str) -> Option<&SecretEntry> { self.entries.get(name) } pub fn file_secrets(&self) -> Vec<(String, String)> { let mut data = self .entries .iter() .filter(|(_, entry)| entry.secret_type == SecretType::File) .map(|(name, entry)| (name.clone(), entry.value.clone())) .collect::>(); data.sort_by(|a, b| a.0.cmp(&b.0)); data } pub fn validate_name(name: &str, secret_type: SecretType) -> Result<(), Error> { match secret_type { SecretType::Token | SecretType::Oauth => Self::validate_env_name(name), SecretType::File => Self::validate_file_name(name), } } fn validate_env_name(name: &str) -> Result<(), Error> { if is_env_style_name(name) { Ok(()) } else { Err(Error::InvalidName(name.to_string())) } } fn validate_file_name(name: &str) -> Result<(), Error> { if name == EnvVars::GITHUB_APP_PRIVATE_KEY { return Ok(()); } if !name.starts_with('/') || name.ends_with('/') || name.contains('\0') { return Err(Error::InvalidName(name.to_string())); } let path = Path::new(name); if !path.is_absolute() { return Err(Error::InvalidName(name.to_string())); } if path .components() .any(|component| matches!(component, Component::ParentDir)) { return Err(Error::InvalidName(name.to_string())); } Ok(()) } fn write_atomic(&self) -> Result<(), Error> { let Some(path) = self.path.as_ref() else { return Ok(()); }; let parent = path .parent() .map_or_else(|| PathBuf::from("."), Path::to_path_buf); std::fs::create_dir_all(&parent) .map_err(|err| io_context("create vault directory", &parent, &err))?; let file_name = path .file_name() .and_then(|name| name.to_str()) .unwrap_or("secrets.json"); let tmp_path = parent.join(format!(".{file_name}.tmp-{}", ulid::Ulid::new())); let json = serde_json::to_vec_pretty(&self.entries)?; std::fs::write(&tmp_path, json) .map_err(|err| io_context("write vault temp file", &tmp_path, &err))?; set_private_permissions(&tmp_path)?; std::fs::rename(&tmp_path, path).map_err(|err| { io_context( &format!("rename vault temp file to {}", path.display()), &tmp_path, &err, ) })?; Ok(()) } } /// Wrap an `io::Error` with a human-readable verb and path so downstream /// reporting shows which operation failed on which file. fn io_context(op: &str, path: &Path, source: &io::Error) -> io::Error { io::Error::new(source.kind(), format!("{op} {}: {source}", path.display())) } #[cfg(unix)] fn set_private_permissions(path: &Path) -> Result<(), Error> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) .map_err(|err| io_context("set permissions on", path, &err))?; Ok(()) } #[cfg(not(unix))] fn set_private_permissions(_path: &Path) -> Result<(), Error> { Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn load_missing_file_returns_empty_store() { let dir = tempfile::tempdir().unwrap(); let store = Vault::load(dir.path().join("secrets.json")).unwrap(); assert!(store.list().is_empty()); } #[test] fn set_creates_entry_and_writes_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); let mut store = Vault::load(path.clone()).unwrap(); let meta = store .set("OPENAI_API_KEY", "secret", SecretType::Token, None) .unwrap(); assert_eq!(meta.name, "OPENAI_API_KEY"); assert_eq!(meta.secret_type, SecretType::Token); assert_eq!(store.get("OPENAI_API_KEY"), Some("secret")); assert!(path.exists()); } #[test] fn set_updates_existing_entry() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); let mut store = Vault::load(path).unwrap(); store .set("OPENAI_API_KEY", "first", SecretType::Token, None) .unwrap(); store .set("OPENAI_API_KEY", "second", SecretType::Token, None) .unwrap(); assert_eq!(store.get("OPENAI_API_KEY"), Some("second")); } #[test] fn remove_deletes_entry() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); let mut store = Vault::load(path.clone()).unwrap(); store .set("OPENAI_API_KEY", "secret", SecretType::Token, None) .unwrap(); store.remove("OPENAI_API_KEY").unwrap(); assert_eq!(store.get("OPENAI_API_KEY"), None); } #[test] fn file_secrets_excludes_token_and_oauth_secrets() { let dir = tempfile::tempdir().unwrap(); let mut store = Vault::load(dir.path().join("secrets.json")).unwrap(); store .set("OPENAI_API_KEY", "token", SecretType::Token, None) .unwrap(); store .set("OPENAI_CODEX", "oauth-json", SecretType::Oauth, None) .unwrap(); store .set("/tmp/key.pem", "pem", SecretType::File, None) .unwrap(); assert_eq!(store.file_secrets(), vec![( "/tmp/key.pem".to_string(), "pem".to_string() )]); } #[test] fn file_secret_listing_survives_reload() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); let mut store = Vault::load(path.clone()).unwrap(); store .set("/tmp/key.pem", "pem", SecretType::File, None) .unwrap(); let reloaded = Vault::load(path).unwrap(); assert_eq!(reloaded.file_secrets(), vec![( "/tmp/key.pem".to_string(), "pem".to_string() )]); } #[test] fn github_app_private_key_may_be_stored_as_file_secret() { let dir = tempfile::tempdir().unwrap(); let mut store = Vault::load(dir.path().join("secrets.json")).unwrap(); store .set( EnvVars::GITHUB_APP_PRIVATE_KEY, "base64-pem", SecretType::File, None, ) .unwrap(); assert_eq!(store.file_secrets(), vec![( EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(), "base64-pem".to_string() )]); } #[test] fn list_includes_schema_typed_entries_loaded_from_disk() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("secrets.json"); std::fs::write( &path, serde_json::json!({ "OPENAI_API_KEY": { "value": "token", "type": "token", "created_at": "2026-04-12T00:00:00Z", "updated_at": "2026-04-12T00:00:00Z" }, "OPENAI_CODEX": { "value": "{\"tokens\":{\"access_token\":\"access\",\"refresh_token\":\"refresh\",\"expires_at\":\"2026-04-12T01:00:00Z\"},\"config\":{\"auth_url\":\"https://auth.openai.com\",\"token_url\":\"https://auth.openai.com/oauth/token\",\"client_id\":\"client\",\"scopes\":[\"openid\"],\"redirect_uri\":null,\"use_pkce\":true}}", "type": "oauth", "created_at": "2026-04-12T00:00:00Z", "updated_at": "2026-04-12T00:00:00Z" } }) .to_string(), ) .unwrap(); let store = Vault::load(path).unwrap(); let list = store.list(); assert_eq!(list.len(), 2); assert_eq!(list[0].name, "OPENAI_API_KEY"); assert_eq!(list[0].secret_type, SecretType::Token); assert_eq!(list[1].name, "OPENAI_CODEX"); assert_eq!(list[1].secret_type, SecretType::Oauth); assert_eq!(store.get("OPENAI_API_KEY"), Some("token")); assert!(store.get("OPENAI_CODEX").is_some()); } #[test] fn get_entry_returns_full_secret_entry() { let dir = tempfile::tempdir().unwrap(); let mut store = Vault::load(dir.path().join("secrets.json")).unwrap(); store .set( "OPENAI_CODEX", "oauth-json", SecretType::Oauth, Some("saved auth"), ) .unwrap(); let entry = store.get_entry("OPENAI_CODEX").unwrap(); assert_eq!(entry.value, "oauth-json"); assert_eq!(entry.secret_type, SecretType::Oauth); assert_eq!(entry.description.as_deref(), Some("saved auth")); } #[test] fn get_entry_returns_token_entries_by_name() { let dir = tempfile::tempdir().unwrap(); let mut store = Vault::load(dir.path().join("secrets.json")).unwrap(); store .set("OPENAI_API_KEY", "token", SecretType::Token, None) .unwrap(); let entry = store.get_entry("OPENAI_API_KEY").unwrap(); assert_eq!(entry.value, "token"); assert_eq!(entry.secret_type, SecretType::Token); } }