use core::fmt;
use std::collections::{BTreeMap, BTreeSet};
use std::io::Write as _;
use std::path::Path;
#[cfg(any(unix, windows))]
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::AppConfig;
use crate::config::template;
pub const SECRETS_VERSION: u32 = 1;
pub const MAX_KEY_BYTES: usize = 128;
pub const MAX_VALUE_BYTES: usize = 4096;
pub const ALL_ENVIRONMENTS: &str = "all";
#[derive(Default, Serialize, Deserialize)]
struct SecretFile {
version: u32,
entries: BTreeMap<String, BTreeMap<String, String>>,
}
impl fmt::Debug for SecretFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecretFile")
.field("version", &self.version)
.field("keys", &self.entries.len())
.finish()
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum SecretError {
Io(std::io::Error),
Decode(serde_json::Error),
InvalidKey(String),
InvalidEnvironment(String),
ValueTooLong {
key: String,
len: usize,
},
FutureVersion(u32),
}
impl fmt::Display for SecretError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "secret store I/O failed: {err}"),
Self::Decode(err) => write!(f, "secret store failed to parse: {err}"),
Self::InvalidKey(key) => write!(f, "`{key}` is not a valid secret key"),
Self::InvalidEnvironment(environment) => {
write!(f, "`{environment}` is not a valid environment name")
}
Self::ValueTooLong { key, len } => write!(
f,
"value for `{key}` is {len} bytes, over the {MAX_VALUE_BYTES}-byte limit"
),
Self::FutureVersion(version) => write!(
f,
"secret store is version {version}, newer than this build understands"
),
}
}
}
impl core::error::Error for SecretError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Decode(err) => Some(err),
Self::InvalidKey(_)
| Self::InvalidEnvironment(_)
| Self::ValueTooLong { .. }
| Self::FutureVersion(_) => None,
}
}
}
impl From<std::io::Error> for SecretError {
fn from(source: std::io::Error) -> Self {
Self::Io(source)
}
}
impl From<serde_json::Error> for SecretError {
fn from(source: serde_json::Error) -> Self {
Self::Decode(source)
}
}
#[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'-'))
}
fn check_key(key: &str) -> Result<(), SecretError> {
if is_name(key) {
Ok(())
} else {
Err(SecretError::InvalidKey(key.to_string()))
}
}
fn check_environment(environment: &str) -> Result<(), SecretError> {
if is_name(environment) {
Ok(())
} else {
Err(SecretError::InvalidEnvironment(environment.to_string()))
}
}
#[cfg(any(unix, windows))]
fn lock_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(".lock");
path.parent().unwrap_or_else(|| Path::new(".")).join(name)
}
struct SecretLock {
#[cfg(unix)]
_flock: nix::fcntl::Flock<std::fs::File>,
#[cfg(windows)]
_handle: std::fs::File,
}
impl SecretLock {
#[cfg(unix)]
fn acquire(path: &Path) -> std::io::Result<Self> {
use nix::fcntl::{Flock, FlockArg};
use std::os::unix::fs::OpenOptionsExt as _;
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
.open(lock_path(path))?;
Flock::lock(file, FlockArg::LockExclusive)
.map(|flock| Self { _flock: flock })
.map_err(|(_file, errno)| std::io::Error::from(errno))
}
#[cfg(windows)]
fn acquire(path: &Path) -> std::io::Result<Self> {
use std::os::windows::fs::OpenOptionsExt as _;
const ERROR_SHARING_VIOLATION: i32 = 32;
const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
let lock_path = lock_path(path);
loop {
match std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.share_mode(0)
.open(&lock_path)
{
Ok(handle) => return Ok(Self { _handle: handle }),
Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
std::thread::sleep(RETRY_INTERVAL);
}
Err(error) => return Err(error),
}
}
}
}
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)
}
fn write_file(path: &Path, file: &SecretFile) -> Result<(), SecretError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = crate::atomic_file::create_staging_file(parent, "secrets", ".tmp")?;
let json = serde_json::to_string_pretty(file)?;
tmp.write_all(json.as_bytes())?;
tmp.write_all(b"\n")?;
tmp.as_file().sync_all()?;
tmp.persist(path)
.map_err(|err| SecretError::Io(err.error))?;
crate::atomic_file::sync_dir(parent)?;
Ok(())
}
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 = SecretLock::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 = SecretLock::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)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecretRef<'a> {
pub namespace: Option<&'a str>,
pub key: &'a str,
}
impl<'a> SecretRef<'a> {
#[must_use]
pub fn parse(body: &'a str) -> Option<Self> {
match body.split_once('/') {
None if is_name(body) => Some(Self {
namespace: None,
key: body,
}),
Some((namespace, key)) if is_name(namespace) && is_name(key) => Some(Self {
namespace: Some(namespace),
key,
}),
_ => None,
}
}
}
impl fmt::Display for SecretRef<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("{{secret:")?;
if let Some(namespace) = self.namespace {
f.write_str(namespace)?;
f.write_str("/")?;
}
f.write_str(self.key)?;
f.write_str("}}")
}
}
#[must_use]
pub fn references(config: &AppConfig) -> BTreeSet<String> {
let mut found = BTreeSet::new();
let mut scan = |value: &str| {
let _ = template::walk::<core::convert::Infallible>(value, |segment| {
if let template::Segment::Token(token) = segment
&& let Some(reference) = template::secret_reference(token)
{
found.insert(match reference.namespace {
Some(namespace) => format!("{namespace}/{}", reference.key),
None => reference.key.to_string(),
});
}
Ok(())
});
};
for value in config.env.values() {
scan(value);
}
for value in &config.args {
scan(value);
}
if let Some(value) = &config.out_file {
scan(value);
}
if let Some(value) = &config.err_file {
scan(value);
}
found
}
#[must_use]
pub fn namespaces_of(config: &AppConfig) -> BTreeSet<String> {
references(config)
.iter()
.filter_map(|reference| SecretRef::parse(reference))
.filter_map(|reference| reference.namespace.map(str::to_string))
.collect()
}
pub type NamespaceValues = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
pub type PushedPairs = BTreeMap<String, BTreeSet<String>>;
#[derive(Default, Clone)]
pub struct ProviderCache {
pub values: NamespaceValues,
pub pushed: PushedPairs,
}
impl fmt::Debug for ProviderCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderCache")
.field("namespaces", &self.values.len())
.field("pushed", &self.pushed.len())
.finish()
}
}
#[derive(Default, Deserialize)]
struct ProviderCacheFile {
version: u32,
#[serde(default)]
namespaces: NamespaceValues,
#[serde(default)]
pushed: PushedPairs,
}
impl fmt::Debug for ProviderCacheFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderCacheFile")
.field("version", &self.version)
.field("namespaces", &self.namespaces.len())
.field("pushed", &self.pushed.len())
.finish()
}
}
pub const PROVIDER_CACHE_VERSION: u32 = 2;
#[must_use]
pub fn provider_cache_on_disk(path: &Path) -> ProviderCache {
let Ok(raw) = std::fs::read_to_string(path) else {
return ProviderCache::default();
};
match serde_json::from_str::<ProviderCacheFile>(&raw) {
Ok(file) if file.version == PROVIDER_CACHE_VERSION => ProviderCache {
values: file.namespaces,
pushed: file.pushed,
},
_ => ProviderCache::default(),
}
}
pub struct SecretView {
environment: String,
store: BTreeMap<String, BTreeMap<String, String>>,
providers: ProviderCache,
}
impl fmt::Debug for SecretView {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecretView")
.field("environment", &self.environment)
.field("keys", &self.store.len())
.field("namespaces", &self.providers.values.len())
.finish()
}
}
impl SecretView {
#[must_use]
pub fn new(
environment: String,
store: BTreeMap<String, BTreeMap<String, String>>,
providers: ProviderCache,
) -> Self {
Self {
environment,
store,
providers,
}
}
#[must_use]
pub fn empty(environment: String) -> Self {
Self::new(environment, BTreeMap::new(), ProviderCache::default())
}
#[must_use]
pub fn environment(&self) -> &str {
&self.environment
}
#[must_use]
pub fn resolve(&self, reference: &SecretRef<'_>) -> Resolution<'_> {
let table = match reference.namespace {
None => Some(&self.store),
Some(namespace) => self.providers.values.get(namespace),
};
if let Some(value) =
table
.and_then(|table| table.get(reference.key))
.and_then(|by_environment| {
by_environment
.get(&self.environment)
.or_else(|| by_environment.get(ALL_ENVIRONMENTS))
})
{
return Resolution::Found(value.as_str());
}
match reference.namespace {
None => Resolution::MissingKey,
Some(namespace) if self.is_pushed(namespace) => Resolution::MissingKey,
Some(_) => Resolution::MissingNamespace,
}
}
fn is_pushed(&self, namespace: &str) -> bool {
self.providers
.pushed
.get(namespace)
.is_some_and(|environments| {
environments.contains(&self.environment) || environments.contains(ALL_ENVIRONMENTS)
})
}
}
pub enum Resolution<'a> {
Found(&'a str),
MissingKey,
MissingNamespace,
}
impl fmt::Debug for Resolution<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Found(_) => "Found(..)",
Self::MissingKey => "MissingKey",
Self::MissingNamespace => "MissingNamespace",
})
}
}
#[cfg(test)]
mod tests {
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 an_oversized_value_is_refused_by_length() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.json");
let big = "x".repeat(MAX_VALUE_BYTES + 1);
let err = set(&path, "K", "production", &big).unwrap_err();
assert!(matches!(err, SecretError::ValueTooLong { len, .. } if len == big.len()));
}
#[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);
}
#[test]
fn a_reference_parses_with_and_without_a_namespace() {
let bare = SecretRef::parse("DB_PASSWORD").unwrap();
assert_eq!(bare.namespace, None);
assert_eq!(bare.key, "DB_PASSWORD");
let scoped = SecretRef::parse("vercel/DB_PASSWORD").unwrap();
assert_eq!(scoped.namespace, Some("vercel"));
assert_eq!(scoped.key, "DB_PASSWORD");
for bad in ["", "/KEY", "ns/", "a/b/c", "ns/bad key", "bad ns/KEY"] {
assert!(SecretRef::parse(bad).is_none(), "{bad:?} must not parse");
}
}
#[test]
fn references_finds_every_secret_in_a_config_and_nothing_else() {
let mut config = AppConfig::minimal("web", "./srv");
config.env.insert("A".into(), "{{secret:ONE}}".into());
config.env.insert("B".into(), "plain".into());
config
.env
.insert("C".into(), "{{name}}-{{secret:vercel/TWO}}".into());
config.args = vec!["--x={{secret:ONE}}".into()];
let found = references(&config);
assert_eq!(
found,
BTreeSet::from(["ONE".to_string(), "vercel/TWO".to_string()]),
"deduplicated, and no positional tokens"
);
}
#[test]
fn namespaces_of_a_config_is_the_seam_boot_ordering_will_want() {
let mut config = AppConfig::minimal("web", "./srv");
config.env.insert("A".into(), "{{secret:ONE}}".into());
config
.env
.insert("B".into(), "{{secret:vercel/TWO}}".into());
assert_eq!(
namespaces_of(&config),
BTreeSet::from(["vercel".to_string()])
);
}
#[test]
fn provider_cache_on_disk_reads_a_real_cache_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets-cache.json");
std::fs::write(
&path,
r#"{"version":2,"namespaces":{"vercel":{"API_KEY":{"production":"sk_live"}}},"pushed":{"vercel":["production"]}}"#,
)
.unwrap();
let cache = provider_cache_on_disk(&path);
assert_eq!(cache.values["vercel"]["API_KEY"]["production"], "sk_live");
assert_eq!(
cache.pushed["vercel"],
BTreeSet::from(["production".to_string()])
);
}
#[test]
fn provider_cache_on_disk_is_empty_for_a_missing_or_broken_file() {
let dir = tempfile::tempdir().unwrap();
assert!(
provider_cache_on_disk(&dir.path().join("absent.json"))
.values
.is_empty()
);
let broken = dir.path().join("broken.json");
std::fs::write(&broken, "not json").unwrap();
assert!(provider_cache_on_disk(&broken).values.is_empty());
let future = dir.path().join("future.json");
std::fs::write(&future, r#"{"version":999,"namespaces":{}}"#).unwrap();
assert!(provider_cache_on_disk(&future).values.is_empty());
}
#[test]
fn resolution_prefers_the_exact_environment_then_all_then_gives_up() {
let mut store = BTreeMap::new();
store.insert(
"K".to_string(),
BTreeMap::from([
("production".to_string(), "prod".to_string()),
("all".to_string(), "fallback".to_string()),
]),
);
store.insert(
"ONLY_ALL".to_string(),
BTreeMap::from([("all".to_string(), "everywhere".to_string())]),
);
store.insert(
"ONLY_PROD".to_string(),
BTreeMap::from([("production".to_string(), "prod".to_string())]),
);
let view = SecretView::new("staging".to_string(), store, ProviderCache::default());
assert!(matches!(
view.resolve(&SecretRef {
namespace: None,
key: "K"
}),
Resolution::Found("fallback")
));
assert!(matches!(
view.resolve(&SecretRef {
namespace: None,
key: "ONLY_ALL"
}),
Resolution::Found("everywhere")
));
assert!(matches!(
view.resolve(&SecretRef {
namespace: None,
key: "ONLY_PROD"
}),
Resolution::MissingKey
));
assert!(matches!(
view.resolve(&SecretRef {
namespace: None,
key: "ABSENT"
}),
Resolution::MissingKey
));
}
fn vercel_production() -> ProviderCache {
ProviderCache {
values: BTreeMap::from([(
"vercel".to_string(),
BTreeMap::from([(
"PRESENT".to_string(),
BTreeMap::from([("production".to_string(), "v".to_string())]),
)]),
)]),
pushed: BTreeMap::from([(
"vercel".to_string(),
BTreeSet::from(["production".to_string()]),
)]),
}
}
#[test]
fn an_unpopulated_namespace_is_told_apart_from_a_missing_key() {
let view = SecretView::new(
"production".to_string(),
BTreeMap::new(),
vercel_production(),
);
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "PRESENT"
}),
Resolution::Found("v")
));
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "ABSENT"
}),
Resolution::MissingKey
));
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vault"),
key: "ANY"
}),
Resolution::MissingNamespace
));
}
#[test]
fn a_namespace_pushed_for_another_environment_is_not_populated_for_this_one() {
let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_production());
assert!(
matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "PRESENT"
}),
Resolution::MissingNamespace
),
"staging has had no push, so waiting is what fixes this"
);
}
#[test]
fn a_pushed_pair_missing_a_key_stays_permanent() {
let view = SecretView::new(
"production".to_string(),
BTreeMap::new(),
vercel_production(),
);
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "ABSENT"
}),
Resolution::MissingKey
));
}
fn vercel_all() -> ProviderCache {
ProviderCache {
values: BTreeMap::from([(
"vercel".to_string(),
BTreeMap::from([(
"PRESENT".to_string(),
BTreeMap::from([(ALL_ENVIRONMENTS.to_string(), "v".to_string())]),
)]),
)]),
pushed: BTreeMap::from([(
"vercel".to_string(),
BTreeSet::from([ALL_ENVIRONMENTS.to_string()]),
)]),
}
}
#[test]
fn an_all_slot_push_makes_a_genuinely_missing_key_permanent() {
let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "ABSENT"
}),
Resolution::MissingKey
));
}
#[test]
fn an_all_slot_push_resolves_its_key_for_every_environment() {
let view = SecretView::new("staging".to_string(), BTreeMap::new(), vercel_all());
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "PRESENT"
}),
Resolution::Found("v")
));
}
#[test]
fn an_empty_push_populates_the_pair_it_carried() {
let view = SecretView::new(
"production".to_string(),
BTreeMap::new(),
ProviderCache {
values: BTreeMap::from([("vercel".to_string(), BTreeMap::new())]),
pushed: BTreeMap::from([(
"vercel".to_string(),
BTreeSet::from(["production".to_string()]),
)]),
},
);
assert!(matches!(
view.resolve(&SecretRef {
namespace: Some("vercel"),
key: "ANY"
}),
Resolution::MissingKey
));
}
#[test]
fn a_reference_displays_the_way_an_operator_wrote_it() {
assert_eq!(
SecretRef {
namespace: None,
key: "K"
}
.to_string(),
"{{secret:K}}"
);
assert_eq!(
SecretRef {
namespace: Some("vercel"),
key: "K"
}
.to_string(),
"{{secret:vercel/K}}"
);
}
#[test]
fn debug_never_prints_a_value() {
let store = BTreeMap::from([(
"K".to_string(),
BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
)]);
let view = SecretView::new("production".to_string(), store, ProviderCache::default());
let rendered = format!("{view:?}");
assert_eq!(
rendered,
"SecretView { environment: \"production\", keys: 1, namespaces: 0 }"
);
assert!(!rendered.contains("hunter2"));
}
#[test]
fn a_secret_file_debug_never_prints_a_value() {
let file = SecretFile {
version: SECRETS_VERSION,
entries: BTreeMap::from([(
"K".to_string(),
BTreeMap::from([("production".to_string(), "hunter2".to_string())]),
)]),
};
let rendered = format!("{file:?}");
assert_eq!(rendered, "SecretFile { version: 1, keys: 1 }");
assert!(!rendered.contains("hunter2"));
}
#[test]
fn a_provider_cache_file_debug_never_prints_a_value() {
let file = ProviderCacheFile {
version: PROVIDER_CACHE_VERSION,
namespaces: BTreeMap::from([(
"vercel".to_string(),
BTreeMap::from([(
"API_KEY".to_string(),
BTreeMap::from([("production".to_string(), "sk_live".to_string())]),
)]),
)]),
pushed: BTreeMap::from([(
"vercel".to_string(),
BTreeSet::from(["production".to_string()]),
)]),
};
let rendered = format!("{file:?}");
assert_eq!(
rendered,
"ProviderCacheFile { version: 2, namespaces: 1, pushed: 1 }"
);
assert!(!rendered.contains("sk_live"));
}
#[test]
fn a_provider_cache_debug_never_prints_a_value() {
let cache = vercel_production();
assert_eq!(
format!("{cache:?}"),
"ProviderCache { namespaces: 1, pushed: 1 }"
);
}
#[test]
fn a_resolution_debug_never_prints_the_value_it_found() {
assert_eq!(format!("{:?}", Resolution::Found("hunter2")), "Found(..)");
assert_eq!(format!("{:?}", Resolution::MissingKey), "MissingKey");
assert_eq!(
format!("{:?}", Resolution::MissingNamespace),
"MissingNamespace"
);
}
#[test]
fn error_messages_name_the_key_and_never_a_value() {
let too_long = SecretError::ValueTooLong {
key: "K".to_string(),
len: 9999,
};
assert_eq!(
too_long.to_string(),
format!("value for `K` is 9999 bytes, over the {MAX_VALUE_BYTES}-byte limit")
);
assert_eq!(
format!("{too_long:?}"),
"ValueTooLong { key: \"K\", len: 9999 }"
);
let bad_key = SecretError::InvalidKey("has space".to_string());
assert_eq!(bad_key.to_string(), "`has space` is not a valid secret key");
assert_eq!(format!("{bad_key:?}"), "InvalidKey(\"has space\")");
for rendered in [too_long.to_string(), bad_key.to_string()] {
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
}
}