use super::error::SecretError;
use super::format::{MAX_KEY_BYTES, MAX_VALUE_BYTES, SECRETS_VERSION, SecretFile};
use crate::file_lock::FileLock;
use std::collections::BTreeMap;
use std::path::Path;
#[must_use]
pub fn is_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_KEY_BYTES
&& !value.starts_with('.')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub(super) fn check_key(key: &str) -> Result<(), SecretError> {
if is_name(key) {
Ok(())
} else {
Err(SecretError::InvalidKey(key.to_string()))
}
}
pub(super) fn check_environment(environment: &str) -> Result<(), SecretError> {
if is_name(environment) {
Ok(())
} else {
Err(SecretError::InvalidEnvironment(environment.to_string()))
}
}
pub(super) fn read_file(path: &Path) -> Result<SecretFile, SecretError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(SecretFile::default()),
Err(err) => return Err(SecretError::Io(err)),
};
let file: SecretFile = serde_json::from_str(&raw)?;
if file.version > SECRETS_VERSION {
return Err(SecretError::FutureVersion(file.version));
}
Ok(file)
}
pub(super) fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
crate::atomic_file::write_json(path, "secrets", file).map_err(SecretError::Io)
}
pub fn all(path: &Path) -> Result<BTreeMap<String, BTreeMap<String, String>>, SecretError> {
Ok(read_file(path)?.entries)
}
pub fn get(path: &Path, key: &str, environment: &str) -> Result<Option<String>, SecretError> {
check_key(key)?;
check_environment(environment)?;
Ok(all(path)?
.remove(key)
.and_then(|mut by_environment| by_environment.remove(environment)))
}
pub fn set(path: &Path, key: &str, environment: &str, value: &str) -> Result<(), SecretError> {
check_key(key)?;
check_environment(environment)?;
if value.len() > MAX_VALUE_BYTES {
return Err(SecretError::ValueTooLong {
key: key.to_string(),
len: value.len(),
});
}
let _lock = FileLock::acquire(path)?;
let mut file = read_file(path)?;
file.version = SECRETS_VERSION;
file.entries
.entry(key.to_string())
.or_default()
.insert(environment.to_string(), value.to_string());
write_file(path, &file)
}
pub fn unset(path: &Path, key: &str, environment: &str) -> Result<bool, SecretError> {
check_key(key)?;
check_environment(environment)?;
let _lock = FileLock::acquire(path)?;
let mut file = read_file(path)?;
let Some(by_environment) = file.entries.get_mut(key) else {
return Ok(false);
};
let was_present = by_environment.remove(environment).is_some();
if was_present {
if by_environment.is_empty() {
file.entries.remove(key);
}
file.version = SECRETS_VERSION;
write_file(path, &file)?;
}
Ok(was_present)
}
#[cfg(test)]
mod tests {
use super::super::error::SecretError;
use super::super::format::ALL_ENVIRONMENTS;
use super::*;
#[test]
fn a_value_round_trips_through_one_environment() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "DB_PASSWORD", "production", "hunter2").unwrap();
assert_eq!(
get(&path, "DB_PASSWORD", "production").unwrap().as_deref(),
Some("hunter2")
);
assert_eq!(get(&path, "DB_PASSWORD", "staging").unwrap(), None);
}
#[test]
fn a_missing_store_reads_as_empty_rather_than_enoent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
assert!(all(&path).unwrap().is_empty());
assert_eq!(get(&path, "ANY", "production").unwrap(), None);
}
#[test]
fn unset_removes_one_environment_and_leaves_the_others() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "K", "production", "p").unwrap();
set(&path, "K", "staging", "s").unwrap();
assert!(unset(&path, "K", "staging").unwrap());
assert_eq!(get(&path, "K", "production").unwrap().as_deref(), Some("p"));
assert_eq!(get(&path, "K", "staging").unwrap(), None);
assert!(!unset(&path, "K", "staging").unwrap(), "already gone");
}
#[test]
fn a_key_that_empties_is_removed_rather_than_left_as_an_empty_map() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "K", "production", "p").unwrap();
assert!(unset(&path, "K", "production").unwrap());
assert!(all(&path).unwrap().is_empty(), "no empty husk left behind");
}
#[test]
fn a_bad_key_is_refused_by_name_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
for key in ["", ".hidden", "has space", "has/slash", "has:colon"] {
let err = set(&path, key, "production", "v").unwrap_err();
assert!(
matches!(&err, SecretError::InvalidKey(k) if k == key),
"{key:?}: {err:?}"
);
}
assert!(!path.exists(), "a refused set must not create the store");
}
#[test]
fn the_all_slot_is_writable_like_any_other_environment() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
assert_eq!(
get(&path, "K", "all").unwrap().as_deref(),
Some("everywhere")
);
}
#[test]
fn get_does_not_fall_back_to_the_all_slot() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "K", ALL_ENVIRONMENTS, "everywhere").unwrap();
assert_eq!(get(&path, "K", "staging").unwrap(), None);
}
#[test]
fn an_environment_outside_the_grammar_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
for env in ["", "has space", "has/slash"] {
let err = set(&path, "K", env, "v").unwrap_err();
assert!(
matches!(&err, SecretError::InvalidEnvironment(e) if e == env),
"{env:?}: {err:?}"
);
}
}
#[test]
fn a_future_version_is_refused_rather_than_overwritten() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
std::fs::write(&path, r#"{"version":999,"entries":{}}"#).unwrap();
assert!(matches!(all(&path), Err(SecretError::FutureVersion(999))));
assert!(matches!(
set(&path, "K", "production", "v"),
Err(SecretError::FutureVersion(999))
));
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("999"), "the refused store is untouched");
}
#[test]
#[cfg(unix)]
fn the_store_is_owner_only() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
set(&path, "K", "production", "v").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
}