use zeroize::Zeroizing;
#[derive(Debug, Clone)]
pub struct SecretKeyValue {
pub key: String,
pub value: Zeroizing<String>,
}
impl SecretKeyValue {
pub fn new(key: String, value: String) -> Result<Self, crate::ValidationError> {
crate::models::secret_data::validate_key_name(&key)?;
if value.is_empty() {
return Err(crate::ValidationError::Other(
"value cannot be empty".to_string(),
));
}
Ok(Self {
key,
value: Zeroizing::new(value),
})
}
}
pub fn validate_secret_name(name: &str) -> Result<(), crate::ValidationError> {
if name.is_empty() || name.len() > 253 {
return Err(crate::ValidationError::InvalidSecretName {
name: name.to_string(),
reason: "must be 1-253 characters".to_string(),
});
}
if !name
.chars()
.next()
.map(|c| c.is_ascii_alphanumeric())
.unwrap_or(false)
{
return Err(crate::ValidationError::InvalidSecretName {
name: name.to_string(),
reason: "must start with alphanumeric character".to_string(),
});
}
if !name
.chars()
.last()
.map(|c| c.is_ascii_alphanumeric())
.unwrap_or(false)
{
return Err(crate::ValidationError::InvalidSecretName {
name: name.to_string(),
reason: "must end with alphanumeric character".to_string(),
});
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
{
return Err(crate::ValidationError::InvalidSecretName {
name: name.to_string(),
reason: "must contain only lowercase alphanumeric, hyphens, or dots".to_string(),
});
}
Ok(())
}
pub fn validate_namespace(namespace: &str) -> Result<(), crate::ValidationError> {
if namespace.is_empty() || namespace.len() > 63 {
return Err(crate::ValidationError::InvalidNamespace {
namespace: namespace.to_string(),
reason: "must be 1-63 characters".to_string(),
});
}
if !namespace
.chars()
.next()
.map(|c| c.is_ascii_alphanumeric())
.unwrap_or(false)
{
return Err(crate::ValidationError::InvalidNamespace {
namespace: namespace.to_string(),
reason: "must start with alphanumeric character".to_string(),
});
}
if !namespace
.chars()
.last()
.map(|c| c.is_ascii_alphanumeric())
.unwrap_or(false)
{
return Err(crate::ValidationError::InvalidNamespace {
namespace: namespace.to_string(),
reason: "must end with alphanumeric character".to_string(),
});
}
if !namespace
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(crate::ValidationError::InvalidNamespace {
namespace: namespace.to_string(),
reason: "must contain only lowercase alphanumeric or hyphens".to_string(),
});
}
Ok(())
}
pub fn validate_key_name(key: &str) -> Result<(), crate::ValidationError> {
if key.is_empty() {
return Err(crate::ValidationError::InvalidKeyName {
key: key.to_string(),
reason: "key name cannot be empty".to_string(),
});
}
let first_char = key.chars().next().unwrap();
if !first_char.is_ascii_alphabetic() && first_char != '_' {
return Err(crate::ValidationError::InvalidKeyName {
key: key.to_string(),
reason: "must start with letter or underscore".to_string(),
});
}
if !key
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
return Err(crate::ValidationError::InvalidKeyName {
key: key.to_string(),
reason: "must contain only alphanumeric, underscores, or hyphens".to_string(),
});
}
Ok(())
}
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretMetadata {
pub name: String,
pub namespace: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone)]
pub struct SecretRequest {
pub name: String,
pub namespace: String,
pub labels: HashMap<String, String>,
pub annotations: HashMap<String, String>,
pub data: Vec<SecretKeyValue>,
}
impl SecretRequest {
pub fn new(
name: String,
namespace: String,
labels: HashMap<String, String>,
annotations: HashMap<String, String>,
data: Vec<SecretKeyValue>,
) -> Result<Self, crate::ValidationError> {
validate_secret_name(&name)?;
validate_namespace(&namespace)?;
if data.is_empty() {
return Err(crate::ValidationError::EmptySecretData);
}
if data.len() > 50 {
return Err(crate::ValidationError::TooManyKeyValuePairs {
count: data.len(),
max: 50,
});
}
Ok(Self {
name,
namespace,
labels,
annotations,
data,
})
}
pub fn metadata(&self) -> SecretMetadata {
SecretMetadata {
name: self.name.clone(),
namespace: self.namespace.clone(),
labels: if self.labels.is_empty() {
None
} else {
Some(self.labels.clone())
},
annotations: if self.annotations.is_empty() {
None
} else {
Some(self.annotations.clone())
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretManifest {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: SecretMetadata,
#[serde(rename = "type")]
pub secret_type: String,
pub data: HashMap<String, String>,
}
impl SecretManifest {
pub fn from_request(request: &SecretRequest) -> Self {
let mut data = HashMap::new();
for kv in &request.data {
let encoded = base64::engine::general_purpose::STANDARD.encode(kv.value.as_bytes());
data.insert(kv.key.clone(), encoded);
}
Self {
api_version: "v1".to_string(),
kind: "Secret".to_string(),
metadata: request.metadata(),
secret_type: "Opaque".to_string(),
data,
}
}
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealedSecretManifest {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: SecretMetadata,
pub spec: SealedSecretSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealedSecretSpec {
#[serde(rename = "encryptedData")]
pub encrypted_data: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<SealedSecretTemplate>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SealedSecretTemplate {
pub metadata: SecretMetadata,
}
impl SealedSecretManifest {
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
}
pub fn validate_against_request(
&self,
request: &SecretRequest,
) -> Result<(), crate::ValidationError> {
if self.metadata.name != request.name {
return Err(crate::ValidationError::Other(format!(
"Output name mismatch: expected '{}', got '{}'",
request.name, self.metadata.name
)));
}
if self.metadata.namespace != request.namespace {
return Err(crate::ValidationError::Other(format!(
"Output namespace mismatch: expected '{}', got '{}'",
request.namespace, self.metadata.namespace
)));
}
for kv in &request.data {
if !self.spec.encrypted_data.contains_key(&kv.key) {
return Err(crate::ValidationError::Other(format!(
"Missing key in output: '{}'",
kv.key
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_secret_key_value_new() {
let kv = SecretKeyValue::new("my-key".to_string(), "my-value".to_string());
assert!(kv.is_ok());
let kv = kv.unwrap();
assert_eq!(kv.key, "my-key");
assert_eq!(*kv.value, "my-value");
}
#[test]
fn test_secret_key_value_empty_value() {
let kv = SecretKeyValue::new("my-key".to_string(), "".to_string());
assert!(kv.is_err());
assert!(kv
.unwrap_err()
.to_string()
.contains("value cannot be empty"));
}
#[test]
fn test_secret_key_value_invalid_key() {
let kv = SecretKeyValue::new(".invalid-key".to_string(), "value".to_string());
assert!(kv.is_err());
}
#[test]
fn test_validate_secret_name_valid() {
assert!(validate_secret_name("my-secret").is_ok());
assert!(validate_secret_name("my.secret").is_ok());
assert!(validate_secret_name("secret123").is_ok());
assert!(validate_secret_name("a").is_ok());
assert!(validate_secret_name("a-b-c").is_ok());
}
#[test]
fn test_validate_secret_name_empty() {
let result = validate_secret_name("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1-253 characters"));
}
#[test]
fn test_validate_secret_name_too_long() {
let long_name = "a".repeat(254);
let result = validate_secret_name(&long_name);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1-253 characters"));
}
#[test]
fn test_validate_secret_name_starts_with_hyphen() {
let result = validate_secret_name("-secret");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must start with alphanumeric"));
}
#[test]
fn test_validate_secret_name_ends_with_hyphen() {
let result = validate_secret_name("secret-");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must end with alphanumeric"));
}
#[test]
fn test_validate_secret_name_uppercase() {
let result = validate_secret_name("MySecret");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("lowercase alphanumeric"));
}
#[test]
fn test_validate_namespace_valid() {
assert!(validate_namespace("default").is_ok());
assert!(validate_namespace("my-namespace").is_ok());
assert!(validate_namespace("ns123").is_ok());
}
#[test]
fn test_validate_namespace_empty() {
let result = validate_namespace("");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1-63 characters"));
}
#[test]
fn test_validate_namespace_too_long() {
let long_ns = "a".repeat(64);
let result = validate_namespace(&long_ns);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("1-63 characters"));
}
#[test]
fn test_validate_key_name_valid() {
assert!(validate_key_name("my-key").is_ok());
assert!(validate_key_name("MY_KEY").is_ok());
assert!(validate_key_name("KEY_123").is_ok());
assert!(validate_key_name("_private").is_ok());
}
#[test]
fn test_validate_key_name_starts_with_digit() {
let result = validate_key_name("1key");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must start with letter or underscore"));
}
#[test]
fn test_validate_key_name_starts_with_dot() {
let result = validate_key_name(".key");
assert!(result.is_err());
}
#[test]
fn test_validate_key_name_contains_slash() {
let result = validate_key_name("key/name");
assert!(result.is_err());
}
#[test]
fn test_validate_key_name_contains_dot() {
let result = validate_key_name("key.name");
assert!(result.is_err());
}
#[test]
fn test_secret_request_new() {
let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
data,
);
assert!(request.is_ok());
}
#[test]
fn test_secret_request_empty_data() {
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
vec![],
);
assert!(request.is_err());
assert!(request
.unwrap_err()
.to_string()
.contains("Secret data cannot be empty"));
}
#[test]
fn test_secret_request_invalid_name() {
let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
let request = SecretRequest::new(
"".to_string(), "default".to_string(),
HashMap::new(),
HashMap::new(),
data,
);
assert!(request.is_err());
}
#[test]
fn test_secret_manifest_from_request() {
let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
data,
)
.unwrap();
let manifest = SecretManifest::from_request(&request);
assert_eq!(manifest.metadata.name, "my-secret");
assert_eq!(manifest.metadata.namespace, "default");
assert_eq!(manifest.kind, "Secret");
assert!(manifest.data.contains_key("key1"));
}
#[test]
fn test_sealed_secret_validate_against_request() {
let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
data,
)
.unwrap();
let mut encrypted_data = HashMap::new();
encrypted_data.insert("key1".to_string(), "encrypted-value".to_string());
let sealed_secret = SealedSecretManifest {
api_version: "bitnami.com/v1alpha1".to_string(),
kind: "SealedSecret".to_string(),
metadata: SecretMetadata {
name: "my-secret".to_string(),
namespace: "default".to_string(),
labels: Some(HashMap::new()),
annotations: Some(HashMap::new()),
},
spec: SealedSecretSpec {
encrypted_data,
template: None,
},
};
assert!(sealed_secret.validate_against_request(&request).is_ok());
}
#[test]
fn test_sealed_secret_validate_name_mismatch() {
let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
data,
)
.unwrap();
let sealed_secret = SealedSecretManifest {
api_version: "bitnami.com/v1alpha1".to_string(),
kind: "SealedSecret".to_string(),
metadata: SecretMetadata {
name: "wrong-name".to_string(),
namespace: "default".to_string(),
labels: Some(HashMap::new()),
annotations: Some(HashMap::new()),
},
spec: SealedSecretSpec {
encrypted_data: HashMap::new(),
template: None,
},
};
let result = sealed_secret.validate_against_request(&request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("name mismatch"));
}
#[test]
fn test_sealed_secret_validate_missing_key() {
let data = vec![
SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap(),
SecretKeyValue::new("key2".to_string(), "value2".to_string()).unwrap(),
];
let request = SecretRequest::new(
"my-secret".to_string(),
"default".to_string(),
HashMap::new(),
HashMap::new(),
data,
)
.unwrap();
let mut encrypted_data = HashMap::new();
encrypted_data.insert("key1".to_string(), "encrypted-value".to_string());
let sealed_secret = SealedSecretManifest {
api_version: "bitnami.com/v1alpha1".to_string(),
kind: "SealedSecret".to_string(),
metadata: SecretMetadata {
name: "my-secret".to_string(),
namespace: "default".to_string(),
labels: Some(HashMap::new()),
annotations: Some(HashMap::new()),
},
spec: SealedSecretSpec {
encrypted_data,
template: None,
},
};
let result = sealed_secret.validate_against_request(&request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Missing key"));
}
}