mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
483 lines
15 KiB
Rust
483 lines
15 KiB
Rust
#![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<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
#[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<std::io::Error> for Error {
|
|
fn from(value: std::io::Error) -> Self {
|
|
Self::Io(value)
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for Error {
|
|
fn from(value: serde_json::Error) -> Self {
|
|
Self::Serde(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Vault {
|
|
path: Option<PathBuf>,
|
|
entries: HashMap<String, SecretEntry>,
|
|
}
|
|
|
|
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<Self, Error> {
|
|
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<String, SecretEntry>) -> Self {
|
|
Self {
|
|
path: None,
|
|
entries,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn entries(&self) -> &HashMap<String, SecretEntry> {
|
|
&self.entries
|
|
}
|
|
|
|
pub fn set(
|
|
&mut self,
|
|
name: &str,
|
|
value: &str,
|
|
secret_type: SecretType,
|
|
description: Option<&str>,
|
|
) -> Result<SecretMetadata, Error> {
|
|
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<SecretMetadata> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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);
|
|
}
|
|
}
|