use firebrick_spec::NetworkRule;
use microsandbox::MicrosandboxResult;
use microsandbox::sandbox::{SandboxBuilder, SandboxHandle};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::io::{self, ErrorKind};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use thiserror::Error;
const RESERVED_PREFIX: &str = "MSB_";
#[derive(Error, Debug)]
pub enum SecretError {
#[error(
"invalid secret name {0:?}: use letters, digits and underscores, don't start with a digit or {RESERVED_PREFIX}"
)]
InvalidName(String),
#[error("the value of secret {0} is empty")]
EmptyValue(String),
#[error(
"invalid allowed host {0:?}: use a host name such as api.example.com, or *.example.com for its subdomains; a wildcard needs at least two labels after *."
)]
InvalidHost(String),
#[error("secret {0} has no default allowed hosts, so name the hosts that may receive it")]
NoAllowedHosts(String),
#[error("failed to read secrets from {path}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse secrets in {path} at {position}")]
Parse { path: PathBuf, position: String },
#[error("failed to encode secrets")]
Encode(#[source] serde_yaml::Error),
#[error("secret {name} appears more than once in the same scope in {path}")]
Duplicate { path: PathBuf, name: String },
#[error("invalid secret in {path}")]
Invalid {
path: PathBuf,
#[source]
source: Box<SecretError>,
},
#[error("failed to write secrets to {path}")]
Write {
path: PathBuf,
#[source]
source: io::Error,
},
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Secret {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
sandbox: Option<String>,
value: String,
allowed_hosts: Vec<String>,
}
impl Secret {
pub fn new(
name: String,
value: String,
allowed_hosts: Vec<String>,
) -> Result<Self, SecretError> {
validate_name(&name)?;
if value.is_empty() {
return Err(SecretError::EmptyValue(name));
}
let allowed_hosts = if allowed_hosts.is_empty() {
default_allowed_hosts(&name)
.iter()
.map(|host| host.to_string())
.collect()
} else {
allowed_hosts
};
if allowed_hosts.is_empty() {
return Err(SecretError::NoAllowedHosts(name));
}
if let Some(host) = allowed_hosts.iter().find(|host| !is_valid_host(host)) {
return Err(SecretError::InvalidHost(host.clone()));
}
Ok(Self {
name,
sandbox: None,
value,
allowed_hosts,
})
}
fn checked(self) -> Result<Self, SecretError> {
let sandbox = self.sandbox;
Ok(Self::new(self.name, self.value, self.allowed_hosts)?.in_scope(sandbox))
}
pub fn in_scope(self, sandbox: Option<String>) -> Self {
Self { sandbox, ..self }
}
pub fn name(&self) -> &str {
&self.name
}
pub fn sandbox(&self) -> Option<&str> {
self.sandbox.as_deref()
}
pub fn matches(&self, name: &str, sandbox: Option<&str>) -> bool {
self.name == name && self.sandbox() == sandbox
}
pub fn allowed_hosts(&self) -> &[String] {
&self.allowed_hosts
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Secret")
.field("name", &self.name)
.field("sandbox", &self.sandbox)
.field("value", &"<redacted>")
.field("allowed_hosts", &self.allowed_hosts)
.finish()
}
}
pub fn validate_name(name: &str) -> Result<(), SecretError> {
let mut chars = name.chars();
let valid = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(RESERVED_PREFIX);
if valid {
Ok(())
} else {
Err(SecretError::InvalidName(name.to_string()))
}
}
fn is_valid_host(host: &str) -> bool {
matches!(
host.parse(),
Ok(NetworkRule::Domain(_) | NetworkRule::DomainSuffix(_))
)
}
fn default_allowed_hosts(name: &str) -> &'static [&'static str] {
match name {
"GH_TOKEN" | "GITHUB_TOKEN" => &["github.com", "api.github.com", "uploads.github.com"],
"COPILOT_GITHUB_TOKEN" => &["github.com", "api.github.com", "*.githubcopilot.com"],
"ANTHROPIC_API_KEY" | "CLAUDE_CODE_OAUTH_TOKEN" => &["api.anthropic.com"],
_ => &[],
}
}
pub struct SecretStore {
path: PathBuf,
lock: Mutex<()>,
}
impl SecretStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
lock: Mutex::new(()),
}
}
pub fn load(&self) -> Result<Vec<Secret>, SecretError> {
let content = match fs::read_to_string(&self.path) {
Ok(content) => content,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(vec![]),
Err(source) => {
return Err(SecretError::Read {
path: self.path.clone(),
source,
});
}
};
let secrets: Vec<Secret> =
serde_yaml::from_str(&content).map_err(|err| SecretError::Parse {
path: self.path.clone(),
position: parse_error_position(&err),
})?;
let secrets: Vec<Secret> = secrets
.into_iter()
.map(Secret::checked)
.collect::<Result<_, _>>()
.map_err(|source| SecretError::Invalid {
path: self.path.clone(),
source: Box::new(source),
})?;
if let Some(secret) = first_duplicate(&secrets) {
return Err(SecretError::Duplicate {
path: self.path.clone(),
name: secret.name.clone(),
});
}
Ok(secrets)
}
pub fn set(&self, secret: Secret) -> Result<(), SecretError> {
self.update(|secrets| {
secrets.retain(|existing| !existing.matches(&secret.name, secret.sandbox()));
secrets.push(secret);
true
})?;
Ok(())
}
pub fn remove(&self, name: &str, sandbox: Option<&str>) -> Result<bool, SecretError> {
self.remove_where(|secret| secret.matches(name, sandbox))
}
pub fn remove_sandbox(&self, sandbox: &str) -> Result<bool, SecretError> {
self.remove_where(|secret| secret.sandbox() == Some(sandbox))
}
fn remove_where(&self, remove: impl Fn(&Secret) -> bool) -> Result<bool, SecretError> {
self.update(|secrets| {
let count = secrets.len();
secrets.retain(|secret| !remove(secret));
secrets.len() < count
})
}
fn update(&self, change: impl FnOnce(&mut Vec<Secret>) -> bool) -> Result<bool, SecretError> {
let _guard = self.lock.lock().unwrap_or_else(PoisonError::into_inner);
let mut secrets = self.load()?;
if !change(&mut secrets) {
return Ok(false);
}
let content = serde_yaml::to_string(&secrets).map_err(SecretError::Encode)?;
firebrick_utils::write_private(&self.path, &content).map_err(|source| {
SecretError::Write {
path: self.path.clone(),
source,
}
})?;
Ok(true)
}
}
pub fn for_sandbox(secrets: Vec<Secret>, sandbox: &str) -> Vec<Secret> {
let overridden: HashSet<String> = secrets
.iter()
.filter(|secret| secret.sandbox() == Some(sandbox))
.map(|secret| secret.name.clone())
.collect();
secrets
.into_iter()
.filter(|secret| match secret.sandbox() {
Some(owner) => owner == sandbox,
None => !overridden.contains(&secret.name),
})
.collect()
}
fn first_duplicate(secrets: &[Secret]) -> Option<&Secret> {
let mut keys = HashSet::new();
secrets
.iter()
.find(|secret| !keys.insert((&secret.name, &secret.sandbox)))
}
pub fn add_to_builder(builder: SandboxBuilder, secrets: &[Secret]) -> SandboxBuilder {
secrets.iter().fold(builder, |builder, secret| {
builder.secret(|s| {
let s = s.env(&secret.name).value(&secret.value);
secret.allowed_hosts.iter().fold(s, |s, host| s.allow(host))
})
})
}
pub async fn add_to_sandbox(handle: &SandboxHandle, secret: &Secret) -> MicrosandboxResult<()> {
handle
.modify()
.secret(|s| {
let s = s.env(&secret.name).value(&secret.value);
secret.allowed_hosts.iter().fold(s, |s, host| s.allow(host))
})
.next_start()
.apply()
.await?;
Ok(())
}
pub async fn remove_from_sandbox(handle: &SandboxHandle, name: &str) -> MicrosandboxResult<()> {
handle
.modify()
.remove_secret(name)
.next_start()
.apply()
.await?;
Ok(())
}
pub fn protect_database(msb_home: &Path) -> io::Result<()> {
let dir = msb_home.join("db");
fs::create_dir_all(&dir)?;
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))
}
fn parse_error_position(err: &serde_yaml::Error) -> String {
match err.location() {
Some(location) => format!("line {}, column {}", location.line(), location.column()),
None => "an unknown position".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(name: &str, hosts: &[&str]) -> Result<Secret, SecretError> {
Secret::new(
name.to_string(),
"value".to_string(),
hosts.iter().map(|host| host.to_string()).collect(),
)
}
fn mode(path: &Path) -> u32 {
fs::metadata(path).unwrap().permissions().mode() & 0o777
}
#[test]
fn new_accepts_environment_variable_names() {
for name in ["GH_TOKEN", "_X", "a1", "My_Var_2"] {
assert!(secret(name, &["example.com"]).is_ok(), "{name}");
}
}
#[test]
fn new_rejects_invalid_and_reserved_names() {
for name in ["", "1ABC", "GH-TOKEN", "A=B", "A B", "MSB_TOKEN", "TÖKEN"] {
assert!(
matches!(
secret(name, &["example.com"]),
Err(SecretError::InvalidName(_))
),
"{name}"
);
}
}
#[test]
fn new_rejects_empty_value() {
let result = Secret::new("X".into(), String::new(), vec!["example.com".into()]);
assert!(matches!(result, Err(SecretError::EmptyValue(_))));
}
#[test]
fn new_uses_default_hosts_for_well_known_names() {
assert_eq!(
secret("GH_TOKEN", &[]).unwrap().allowed_hosts,
["github.com", "api.github.com", "uploads.github.com"]
);
assert_eq!(
secret("COPILOT_GITHUB_TOKEN", &[]).unwrap().allowed_hosts,
["github.com", "api.github.com", "*.githubcopilot.com"]
);
assert_eq!(
secret("CLAUDE_CODE_OAUTH_TOKEN", &[])
.unwrap()
.allowed_hosts,
["api.anthropic.com"]
);
}
#[test]
fn new_prefers_given_hosts_over_defaults() {
assert_eq!(
secret("GH_TOKEN", &["ghe.example.com"])
.unwrap()
.allowed_hosts,
["ghe.example.com"]
);
}
#[test]
fn new_requires_hosts_for_unknown_names() {
assert!(matches!(
secret("MY_TOKEN", &[]),
Err(SecretError::NoAllowedHosts(_))
));
}
#[test]
fn new_rejects_invalid_hosts() {
let long_label = format!("{}.example.com", "a".repeat(64));
for host in [
"*",
"",
"*.",
"*.com",
"*.localhost",
"https://example.com",
"a..b",
"a b",
"host:443",
"-bad-.example.com",
"*.-bad.example.com",
&long_label,
"192.0.2.1",
] {
assert!(
matches!(secret("X", &[host]), Err(SecretError::InvalidHost(_))),
"{host}"
);
}
}
#[test]
fn new_keeps_valid_hosts() {
for host in [
"*.example.com",
"*.githubcopilot.com",
"api.example.com",
"localhost",
] {
assert_eq!(secret("X", &[host]).unwrap().allowed_hosts, [host]);
}
}
#[test]
fn debug_leaves_out_the_value() {
let secret = Secret::new("X".into(), "hunter2".into(), vec!["example.com".into()]).unwrap();
assert!(!format!("{secret:?}").contains("hunter2"));
}
#[test]
fn load_returns_nothing_without_file() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
assert!(store.load().unwrap().is_empty());
}
#[test]
fn set_stores_secrets_readable_by_owner_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("firebrick/secrets.yml");
let store = SecretStore::new(&path);
store.set(secret("GH_TOKEN", &[]).unwrap()).unwrap();
store.set(secret("X", &["example.com"]).unwrap()).unwrap();
let names: Vec<_> = store.load().unwrap().into_iter().map(|s| s.name).collect();
assert_eq!(names, ["GH_TOKEN", "X"]);
assert_eq!(mode(&path), 0o600);
}
#[test]
fn set_replaces_secret_with_same_name() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
store.set(secret("X", &["a.example.com"]).unwrap()).unwrap();
let updated = Secret::new("X".into(), "new".into(), vec!["b.example.com".into()]).unwrap();
store.set(updated.clone()).unwrap();
assert_eq!(store.load().unwrap(), [updated]);
}
#[test]
fn remove_deletes_only_the_named_secret() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let store = SecretStore::new(&path);
store.set(secret("A", &["example.com"]).unwrap()).unwrap();
store.set(secret("B", &["example.com"]).unwrap()).unwrap();
assert!(store.remove("A", None).unwrap());
let names: Vec<_> = store.load().unwrap().into_iter().map(|s| s.name).collect();
assert_eq!(names, ["B"]);
assert_eq!(mode(&path), 0o600);
}
#[test]
fn remove_reports_missing_secret() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
store.set(secret("A", &["example.com"]).unwrap()).unwrap();
assert!(!store.remove("B", None).unwrap());
assert_eq!(store.load().unwrap().len(), 1);
}
#[test]
fn remove_without_file_creates_no_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
assert!(!SecretStore::new(&path).remove("A", None).unwrap());
assert!(!path.exists());
}
#[test]
fn load_rejects_invalid_secrets() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
fs::write(&path, "- name: X\n value: v\n allowed_hosts: ['*']\n").unwrap();
let result = SecretStore::new(&path).load();
assert!(matches!(result, Err(SecretError::Invalid { .. })));
}
#[test]
fn load_rejects_duplicate_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let entry = "- name: X\n value: v\n allowed_hosts: [example.com]\n";
fs::write(&path, entry.repeat(2)).unwrap();
let result = SecretStore::new(&path).load();
assert!(matches!(result, Err(SecretError::Duplicate { .. })));
}
fn scoped(name: &str, sandbox: &str, host: &str) -> Secret {
secret(name, &[host])
.unwrap()
.in_scope(Some(sandbox.to_string()))
}
#[test]
fn set_keeps_one_secret_per_name_and_scope() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
let global = secret("X", &["example.com"]).unwrap();
let a = scoped("X", "a", "a.example.com");
let b = scoped("X", "b", "b.example.com");
store.set(global.clone()).unwrap();
store.set(scoped("X", "a", "old.example.com")).unwrap();
store.set(a.clone()).unwrap();
store.set(b.clone()).unwrap();
assert_eq!(store.load().unwrap(), [global, a, b]);
}
#[test]
fn set_writes_the_sandbox_field_for_scoped_secrets_only() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let store = SecretStore::new(&path);
store.set(secret("G", &["example.com"]).unwrap()).unwrap();
store.set(scoped("S", "dev", "example.com")).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"- name: G\n value: value\n allowed_hosts:\n - example.com\n\
- name: S\n sandbox: dev\n value: value\n allowed_hosts:\n - example.com\n"
);
}
#[test]
fn load_reads_entries_without_sandbox_as_global() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
fs::write(
&path,
"- name: X\n value: v\n allowed_hosts: [example.com]\n",
)
.unwrap();
let secrets = SecretStore::new(&path).load().unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].sandbox(), None);
}
#[test]
fn load_accepts_the_same_name_in_different_scopes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
fs::write(
&path,
"- name: X\n value: v\n allowed_hosts: [example.com]\n\
- name: X\n sandbox: a\n value: v\n allowed_hosts: [example.com]\n\
- name: X\n sandbox: b\n value: v\n allowed_hosts: [example.com]\n",
)
.unwrap();
let scopes: Vec<_> = SecretStore::new(&path)
.load()
.unwrap()
.iter()
.map(|secret| secret.sandbox().map(str::to_string))
.collect();
assert_eq!(scopes, [None, Some("a".into()), Some("b".into())]);
}
#[test]
fn load_rejects_duplicate_names_in_one_sandbox_scope() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let entry = "- name: X\n sandbox: a\n value: v\n allowed_hosts: [example.com]\n";
fs::write(&path, entry.repeat(2)).unwrap();
let result = SecretStore::new(&path).load();
assert!(matches!(result, Err(SecretError::Duplicate { .. })));
}
#[test]
fn remove_deletes_only_the_secret_in_the_scope() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
let global = secret("X", &["example.com"]).unwrap();
let b = scoped("X", "b", "example.com");
store.set(global.clone()).unwrap();
store.set(scoped("X", "a", "example.com")).unwrap();
store.set(b.clone()).unwrap();
assert!(store.remove("X", Some("a")).unwrap());
assert!(!store.remove("X", Some("c")).unwrap());
assert_eq!(store.load().unwrap(), [global.clone(), b]);
assert!(store.remove("X", None).unwrap());
assert_eq!(store.load().unwrap(), [scoped("X", "b", "example.com")]);
}
#[test]
fn remove_sandbox_drops_only_that_sandboxes_secrets() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
let global = secret("X", &["example.com"]).unwrap();
let other = scoped("X", "b", "example.com");
store.set(global.clone()).unwrap();
store.set(scoped("X", "a", "example.com")).unwrap();
store.set(scoped("Y", "a", "example.com")).unwrap();
store.set(other.clone()).unwrap();
assert!(store.remove_sandbox("a").unwrap());
assert!(!store.remove_sandbox("a").unwrap());
assert_eq!(store.load().unwrap(), [global, other]);
}
#[test]
fn for_sandbox_returns_global_secrets_with_the_sandboxes_overrides() {
let global_x = secret("X", &["example.com"]).unwrap();
let global_y = secret("Y", &["example.com"]).unwrap();
let own_x = scoped("X", "a", "a.example.com");
let own_z = scoped("Z", "a", "a.example.com");
let other_y = scoped("Y", "b", "b.example.com");
let secrets = vec![
global_x,
global_y.clone(),
own_x.clone(),
own_z.clone(),
other_y,
];
assert_eq!(for_sandbox(secrets, "a"), [global_y, own_x, own_z]);
}
#[test]
fn for_sandbox_without_overrides_returns_the_global_secrets() {
let global = secret("X", &["example.com"]).unwrap();
let secrets = vec![global.clone(), scoped("X", "b", "b.example.com")];
assert_eq!(for_sandbox(secrets, "a"), [global]);
}
#[test]
fn load_rejects_malformed_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
fs::write(&path, "not: a list").unwrap();
let result = SecretStore::new(&path).load();
assert!(matches!(result, Err(SecretError::Parse { .. })));
}
#[test]
fn parse_error_leaves_out_the_file_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
for content in ["ghp_SECRET123", "- ghp_SECRET123"] {
fs::write(&path, content).unwrap();
let err = SecretStore::new(&path).load().unwrap_err();
assert!(!format!("{err:?}").contains("ghp_SECRET123"), "{err:?}");
assert!(!format!("{err:#}").contains("ghp_SECRET123"), "{err:#}");
}
}
#[test]
fn protect_database_makes_directory_private() {
let home = tempfile::tempdir().unwrap();
fs::create_dir(home.path().join("db")).unwrap();
fs::set_permissions(home.path().join("db"), fs::Permissions::from_mode(0o755)).unwrap();
protect_database(home.path()).unwrap();
assert_eq!(mode(&home.path().join("db")), 0o700);
}
}