use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
#[derive(Clone)]
pub struct Secret {
inner: SecretString,
}
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self {
inner: SecretString::from(value.into()),
}
}
#[must_use]
pub fn expose(&self) -> &str {
self.inner.expose_secret()
}
#[must_use]
pub fn as_secret_string(&self) -> &SecretString {
&self.inner
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
impl Zeroize for Secret {
fn zeroize(&mut self) {
self.inner = SecretString::from(String::new());
}
}
impl From<String> for Secret {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for Secret {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<SecretString> for Secret {
fn from(value: SecretString) -> Self {
Self { inner: value }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretMetadata {
pub name: String,
pub created_at: i64,
pub updated_at: i64,
pub version: u32,
}
impl SecretMetadata {
#[allow(clippy::cast_possible_wrap)]
pub fn new(name: impl Into<String>) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
Self {
name: name.into(),
created_at: now,
updated_at: now,
version: 1,
}
}
#[allow(clippy::cast_possible_wrap)]
pub fn update(&mut self) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
self.updated_at = now;
self.version = self.version.saturating_add(1);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RotationResult {
pub previous_version: Option<u32>,
pub new_version: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SecretScope {
Deployment(String),
Service {
deployment: String,
service: String,
},
}
impl SecretScope {
pub fn deployment(name: impl Into<String>) -> Self {
Self::Deployment(name.into())
}
pub fn service(deployment: impl Into<String>, service: impl Into<String>) -> Self {
Self::Service {
deployment: deployment.into(),
service: service.into(),
}
}
#[must_use]
pub fn to_key_prefix(&self) -> String {
match self {
Self::Deployment(deployment) => format!("deployments/{deployment}/secrets"),
Self::Service {
deployment,
service,
} => format!("deployments/{deployment}/services/{service}/secrets"),
}
}
#[must_use]
pub fn deployment_name(&self) -> &str {
match self {
Self::Deployment(name) => name,
Self::Service { deployment, .. } => deployment,
}
}
#[must_use]
pub fn service_name(&self) -> Option<&str> {
match self {
Self::Deployment(_) => None,
Self::Service { service, .. } => Some(service),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretRef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
}
impl SecretRef {
pub const PREFIX: &'static str = "$S:";
#[must_use]
pub fn is_secret_ref(value: &str) -> bool {
value.starts_with(Self::PREFIX)
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
let rest = value.strip_prefix(Self::PREFIX)?;
if rest.is_empty() {
return None;
}
if let Some(service_rest) = rest.strip_prefix('@') {
let mut parts = service_rest.splitn(3, '/');
let service = parts.next()?;
if service.is_empty() {
return None;
}
let name = parts.next()?;
if name.is_empty() {
return None;
}
let field = parts
.next()
.map(ToString::to_string)
.filter(|s| !s.is_empty());
return Some(Self {
name: name.to_string(),
service: Some(service.to_string()),
project: None,
environment: None,
field,
});
}
if rest.starts_with('/') {
return None;
}
if let Some((scope, tail)) = rest.split_once('/') {
if scope.contains(':') {
return Self::parse_env_scoped(scope, tail);
}
if scope.is_empty() {
return None;
}
if tail.is_empty() {
return None;
}
if tail.contains('/') {
return None;
}
return Some(Self {
name: scope.to_string(),
service: None,
project: None,
environment: None,
field: Some(tail.to_string()),
});
}
if rest.contains(':') {
return None;
}
Some(Self {
name: rest.to_string(),
service: None,
project: None,
environment: None,
field: None,
})
}
fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
let (project_raw, environment) = scope.split_once(':')?;
if environment.is_empty() || environment.contains(':') {
return None;
}
let project = if project_raw.is_empty() {
None
} else {
Some(project_raw.to_string())
};
let (name, field) = match tail.split_once('/') {
Some((name, field)) => {
if field.is_empty() || field.contains('/') {
return None;
}
(name, Some(field.to_string()))
}
None => (tail, None),
};
if name.is_empty() {
return None;
}
Some(Self {
name: name.to_string(),
service: None,
project,
environment: Some(environment.to_string()),
field,
})
}
#[must_use]
pub fn to_scope(&self, deployment: &str) -> SecretScope {
match &self.service {
Some(service) => SecretScope::Service {
deployment: deployment.to_string(),
service: service.clone(),
},
None => SecretScope::Deployment(deployment.to_string()),
}
}
#[must_use]
pub fn is_deployment_level(&self) -> bool {
self.service.is_none() && self.project.is_none() && self.environment.is_none()
}
#[must_use]
pub fn is_service_level(&self) -> bool {
self.service.is_some()
}
#[must_use]
pub fn is_environment_level(&self) -> bool {
self.environment.is_some()
}
#[must_use]
pub fn is_project_environment_level(&self) -> bool {
self.project.is_some() && self.environment.is_some()
}
#[must_use]
pub fn has_field(&self) -> bool {
self.field.is_some()
}
}
impl std::fmt::Display for SecretRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(Self::PREFIX)?;
if let Some(service) = &self.service {
write!(f, "@{service}/{}", self.name)?;
if let Some(field) = &self.field {
write!(f, "/{field}")?;
}
} else if let Some(environment) = &self.environment {
if let Some(project) = &self.project {
write!(f, "{project}:{environment}/{}", self.name)?;
} else {
write!(f, ":{environment}/{}", self.name)?;
}
if let Some(field) = &self.field {
write!(f, "/{field}")?;
}
} else {
f.write_str(&self.name)?;
if let Some(field) = &self.field {
write!(f, "/{field}")?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_debug_redacted() {
let secret = Secret::new("super-secret-value");
let debug_output = format!("{secret:?}");
assert_eq!(debug_output, "[REDACTED]");
assert!(!debug_output.contains("super-secret-value"));
}
#[test]
fn test_secret_expose() {
let secret = Secret::new("my-secret");
assert_eq!(secret.expose(), "my-secret");
}
#[test]
fn test_secret_from_string() {
let secret: Secret = "test-secret".into();
assert_eq!(secret.expose(), "test-secret");
let secret: Secret = String::from("another-secret").into();
assert_eq!(secret.expose(), "another-secret");
}
#[test]
fn test_secret_zeroize() {
let mut secret = Secret::new("sensitive-data");
secret.zeroize();
assert_eq!(secret.expose(), "");
}
#[test]
fn test_secret_metadata_new() {
let metadata = SecretMetadata::new("test-secret");
assert_eq!(metadata.name, "test-secret");
assert_eq!(metadata.version, 1);
assert!(metadata.created_at > 0);
assert_eq!(metadata.created_at, metadata.updated_at);
}
#[test]
fn test_secret_metadata_update() {
let mut metadata = SecretMetadata::new("test-secret");
let original_created = metadata.created_at;
let original_version = metadata.version;
std::thread::sleep(std::time::Duration::from_millis(10));
metadata.update();
assert_eq!(metadata.created_at, original_created);
assert!(metadata.updated_at >= original_created);
assert_eq!(metadata.version, original_version + 1);
}
#[test]
fn test_secret_scope_deployment() {
let scope = SecretScope::deployment("my-deployment");
assert_eq!(scope.deployment_name(), "my-deployment");
assert!(scope.service_name().is_none());
assert_eq!(scope.to_key_prefix(), "deployments/my-deployment/secrets");
}
#[test]
fn test_secret_scope_service() {
let scope = SecretScope::service("my-deployment", "my-service");
assert_eq!(scope.deployment_name(), "my-deployment");
assert_eq!(scope.service_name(), Some("my-service"));
assert_eq!(
scope.to_key_prefix(),
"deployments/my-deployment/services/my-service/secrets"
);
}
#[test]
fn test_secret_ref_is_secret_ref() {
assert!(SecretRef::is_secret_ref("$S:my-secret"));
assert!(SecretRef::is_secret_ref("$S:@service/secret"));
assert!(!SecretRef::is_secret_ref("my-secret"));
assert!(!SecretRef::is_secret_ref("S:my-secret"));
assert!(!SecretRef::is_secret_ref("$:my-secret"));
}
#[test]
fn test_secret_ref_parse_deployment_level() {
let secret_ref = SecretRef::parse("$S:database-password").unwrap();
assert_eq!(secret_ref.name, "database-password");
assert!(secret_ref.service.is_none());
assert!(secret_ref.project.is_none());
assert!(secret_ref.environment.is_none());
assert!(secret_ref.field.is_none());
assert!(secret_ref.is_deployment_level());
}
#[test]
fn test_secret_ref_parse_service_level() {
let secret_ref = SecretRef::parse("$S:@api/database-password").unwrap();
assert_eq!(secret_ref.name, "database-password");
assert_eq!(secret_ref.service, Some("api".to_string()));
assert!(secret_ref.project.is_none());
assert!(secret_ref.environment.is_none());
assert!(secret_ref.field.is_none());
assert!(secret_ref.is_service_level());
}
#[test]
fn test_secret_ref_parse_service_level_with_field() {
let secret_ref = SecretRef::parse("$S:@api/database/password").unwrap();
assert_eq!(secret_ref.name, "database");
assert_eq!(secret_ref.service, Some("api".to_string()));
assert_eq!(secret_ref.field, Some("password".to_string()));
assert!(secret_ref.has_field());
}
#[test]
fn test_secret_ref_parse_deployment_with_field_legacy() {
let secret_ref = SecretRef::parse("$S:database/password").unwrap();
assert_eq!(secret_ref.name, "database");
assert!(secret_ref.service.is_none());
assert!(secret_ref.project.is_none());
assert!(secret_ref.environment.is_none());
assert_eq!(secret_ref.field, Some("password".to_string()));
assert!(secret_ref.has_field());
}
#[test]
fn test_secret_ref_parse_environment_level() {
let secret_ref = SecretRef::parse("$S::staging/db-password").unwrap();
assert_eq!(secret_ref.name, "db-password");
assert_eq!(secret_ref.environment, Some("staging".to_string()));
assert!(secret_ref.project.is_none());
assert!(secret_ref.service.is_none());
assert!(secret_ref.field.is_none());
assert!(secret_ref.is_environment_level());
assert!(!secret_ref.is_project_environment_level());
assert!(!secret_ref.is_deployment_level());
}
#[test]
fn test_secret_ref_parse_environment_level_with_field() {
let secret_ref = SecretRef::parse("$S::staging/db-creds/password").unwrap();
assert_eq!(secret_ref.name, "db-creds");
assert_eq!(secret_ref.environment, Some("staging".to_string()));
assert!(secret_ref.project.is_none());
assert_eq!(secret_ref.field, Some("password".to_string()));
}
#[test]
fn test_secret_ref_parse_project_environment_level() {
let secret_ref = SecretRef::parse("$S:myproj:staging/db-password").unwrap();
assert_eq!(secret_ref.name, "db-password");
assert_eq!(secret_ref.project, Some("myproj".to_string()));
assert_eq!(secret_ref.environment, Some("staging".to_string()));
assert!(secret_ref.service.is_none());
assert!(secret_ref.field.is_none());
assert!(secret_ref.is_environment_level());
assert!(secret_ref.is_project_environment_level());
}
#[test]
fn test_secret_ref_parse_project_environment_with_field() {
let secret_ref = SecretRef::parse("$S:myproj:prod/creds/api_key").unwrap();
assert_eq!(secret_ref.name, "creds");
assert_eq!(secret_ref.project, Some("myproj".to_string()));
assert_eq!(secret_ref.environment, Some("prod".to_string()));
assert_eq!(secret_ref.field, Some("api_key".to_string()));
}
#[test]
fn test_secret_ref_parse_invalid() {
assert!(SecretRef::parse("database-password").is_none());
assert!(SecretRef::parse("$S:").is_none());
assert!(SecretRef::parse("$S:@/secret").is_none());
assert!(SecretRef::parse("$S:@service/").is_none());
assert!(SecretRef::parse("$S:@").is_none());
assert!(SecretRef::parse("$S:/name").is_none());
assert!(SecretRef::parse("$S:database/").is_none());
assert!(SecretRef::parse("$S:::name").is_none());
assert!(SecretRef::parse("$S::/name").is_none());
assert!(SecretRef::parse("$S:proj:/name").is_none());
assert!(SecretRef::parse("$S:a:b:c/name").is_none());
assert!(SecretRef::parse("$S::env/").is_none());
assert!(SecretRef::parse("$S:name/field/extra").is_none());
}
#[test]
fn test_secret_ref_display_roundtrip() {
let cases = [
"$S:database-password",
"$S:database/password",
"$S:@api/database-password",
"$S:@api/database/password",
"$S::staging/db-password",
"$S::staging/db-creds/password",
"$S:myproj:staging/db-password",
"$S:myproj:prod/creds/api_key",
];
for input in cases {
let parsed =
SecretRef::parse(input).unwrap_or_else(|| panic!("failed to parse {input}"));
let rendered = parsed.to_string();
assert_eq!(rendered, input, "round-trip mismatch for {input}");
let reparsed = SecretRef::parse(&rendered)
.unwrap_or_else(|| panic!("failed to re-parse {rendered}"));
assert_eq!(parsed, reparsed);
}
}
#[test]
fn test_secret_ref_serde_backcompat() {
let json = r#"{"name":"db","service":"api","field":"password"}"#;
let parsed: SecretRef = serde_json::from_str(json).unwrap();
assert_eq!(parsed.name, "db");
assert_eq!(parsed.service, Some("api".to_string()));
assert_eq!(parsed.field, Some("password".to_string()));
assert!(parsed.project.is_none());
assert!(parsed.environment.is_none());
let minimal = r#"{"name":"db"}"#;
let parsed: SecretRef = serde_json::from_str(minimal).unwrap();
assert_eq!(parsed.name, "db");
assert!(parsed.service.is_none());
assert!(parsed.project.is_none());
assert!(parsed.environment.is_none());
assert!(parsed.field.is_none());
}
#[test]
fn test_secret_ref_to_scope() {
let secret_ref = SecretRef::parse("$S:my-secret").unwrap();
let scope = secret_ref.to_scope("prod");
assert_eq!(scope, SecretScope::Deployment("prod".to_string()));
let secret_ref = SecretRef::parse("$S:@api/my-secret").unwrap();
let scope = secret_ref.to_scope("prod");
assert_eq!(
scope,
SecretScope::Service {
deployment: "prod".to_string(),
service: "api".to_string(),
}
);
}
#[test]
fn test_secret_metadata_serialization() {
let metadata = SecretMetadata {
name: "test".to_string(),
created_at: 1_234_567_890,
updated_at: 1_234_567_900,
version: 5,
};
let json = serde_json::to_string(&metadata).unwrap();
let deserialized: SecretMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(metadata, deserialized);
}
#[test]
fn test_secret_scope_serialization() {
let deployment_scope = SecretScope::deployment("my-deploy");
let json = serde_json::to_string(&deployment_scope).unwrap();
let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
assert_eq!(deployment_scope, deserialized);
let service_scope = SecretScope::service("my-deploy", "my-service");
let json = serde_json::to_string(&service_scope).unwrap();
let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
assert_eq!(service_scope, deserialized);
}
}