use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
#[schemars(
description = "Pointer into a Wyrd runtime secret source. Production builds support environment variables, mounted files, and external secret-manager keys."
)]
#[serde(tag = "source", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub enum SecretRef {
Env {
name: String,
},
File {
path: String,
},
Vault {
key: String,
},
#[cfg(any(test, feature = "test-utils"))]
#[schemars(skip)]
Inline {
value: InlineSecret,
},
}
#[cfg(any(test, feature = "test-utils"))]
#[derive(Clone)]
pub struct InlineSecret(String);
#[cfg(any(test, feature = "test-utils"))]
impl InlineSecret {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
#[cfg(any(test, feature = "test-utils"))]
impl std::fmt::Debug for InlineSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("InlineSecret(<redacted>)")
}
}
#[cfg(any(test, feature = "test-utils"))]
impl serde::Serialize for InlineSecret {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
#[cfg(any(test, feature = "test-utils"))]
impl<'de> serde::Deserialize<'de> for InlineSecret {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(Self(s))
}
}
#[cfg(any(test, feature = "test-utils"))]
impl schemars::JsonSchema for InlineSecret {
fn schema_name() -> String {
"InlineSecret".to_string()
}
fn json_schema(schema_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
<String as schemars::JsonSchema>::json_schema(schema_gen)
}
}
impl PartialEq for SecretRef {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Env { name: a }, Self::Env { name: b }) => a == b,
(Self::File { path: a }, Self::File { path: b }) => a == b,
(Self::Vault { key: a }, Self::Vault { key: b }) => a == b,
#[cfg(any(test, feature = "test-utils"))]
(Self::Inline { value: a }, Self::Inline { value: b }) => {
a.expose() == b.expose()
}
_ => false,
}
}
}
impl Eq for SecretRef {}
impl std::hash::Hash for SecretRef {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::mem::discriminant(self).hash(state);
match self {
Self::Env { name } => name.hash(state),
Self::File { path } => path.hash(state),
Self::Vault { key } => key.hash(state),
#[cfg(any(test, feature = "test-utils"))]
Self::Inline { .. } => {
}
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SecretRefError {
#[error("field '{0}' must not be empty")]
EmptyField(&'static str),
#[error("path must be absolute (must start with '/')")]
RelativePath,
}
impl SecretRef {
pub fn validate(&self) -> Result<(), SecretRefError> {
match self {
Self::Env { name } if name.is_empty() => Err(SecretRefError::EmptyField("name")),
Self::File { path } if path.is_empty() => Err(SecretRefError::EmptyField("path")),
Self::File { path } if !path.starts_with('/') => Err(SecretRefError::RelativePath),
Self::Vault { key } if key.is_empty() => Err(SecretRefError::EmptyField("key")),
_ => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_empty_name_is_invalid() {
let r = SecretRef::Env {
name: String::new(),
}
.validate();
assert_eq!(r.unwrap_err(), SecretRefError::EmptyField("name"));
}
#[test]
fn env_non_empty_name_is_valid() {
let r = SecretRef::Env {
name: "WYRD_KEY".to_string(),
}
.validate();
assert!(r.is_ok());
}
#[test]
fn file_empty_path_is_invalid() {
let r = SecretRef::File {
path: String::new(),
}
.validate();
assert_eq!(r.unwrap_err(), SecretRefError::EmptyField("path"));
}
#[test]
fn file_relative_path_is_invalid() {
let r = SecretRef::File {
path: "var/run/secrets/key".to_string(),
}
.validate();
assert_eq!(r.unwrap_err(), SecretRefError::RelativePath);
}
#[test]
fn file_absolute_path_is_valid() {
let r = SecretRef::File {
path: "/var/run/secrets/wyrd/api-key".to_string(),
}
.validate();
assert!(r.is_ok());
}
#[test]
fn vault_empty_key_is_invalid() {
let r = SecretRef::Vault { key: String::new() }.validate();
assert_eq!(r.unwrap_err(), SecretRefError::EmptyField("key"));
}
#[test]
fn vault_non_empty_key_is_valid() {
let r = SecretRef::Vault {
key: "secret/data/wyrd/api-key".to_string(),
}
.validate();
assert!(r.is_ok());
}
#[test]
fn inline_partial_eq_compares_actual_values() {
let a = SecretRef::Inline {
value: InlineSecret::new("secret-a"),
};
let b = SecretRef::Inline {
value: InlineSecret::new("secret-b"),
};
let c = SecretRef::Inline {
value: InlineSecret::new("secret-a"),
};
assert_ne!(a, b);
assert_eq!(a, c);
}
}