use std::collections::BTreeMap;
use std::fmt;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use std::str::FromStr;
use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::modify::SecretSource;
pub const DEFAULT_SANDBOX_VCPUS: u8 = 1;
pub const DEFAULT_SANDBOX_MEMORY_MIB: u32 = 512;
pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum DiskImageFormat {
#[serde(alias = "Qcow2")]
Qcow2,
#[serde(alias = "Raw")]
Raw,
#[serde(alias = "Vmdk")]
Vmdk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RootfsSource {
#[serde(alias = "Bind")]
Bind(
#[cfg_attr(feature = "ts", ts(type = "string"))]
PathBuf,
),
#[serde(alias = "Oci")]
Oci(OciRootfsSource),
#[serde(alias = "DiskImage")]
DiskImage {
#[cfg_attr(feature = "ts", ts(type = "string"))]
path: PathBuf,
format: DiskImageFormat,
fstype: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct OciRootfsSource {
pub reference: String,
#[serde(
default,
alias = "disk_size_mib",
skip_serializing_if = "Option::is_none"
)]
pub upper_size_mib: Option<u32>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PullPolicy {
#[default]
#[serde(alias = "IfMissing")]
IfMissing,
#[serde(alias = "Always")]
Always,
#[serde(alias = "Never")]
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum StatVirtualization {
Strict,
Relaxed,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum HostPermissions {
Private,
Mirror,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum SecurityProfile {
#[default]
Default,
Restricted,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct MountOptions {
pub readonly: bool,
pub noexec: bool,
pub nosuid: bool,
pub nodev: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum VolumeKind {
#[serde(alias = "Directory")]
Directory,
#[serde(alias = "Disk")]
Disk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct VolumeSpec {
pub name: String,
pub kind: VolumeKind,
pub quota_mib: Option<u32>,
pub capacity_mib: Option<u32>,
pub labels: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum NamedVolumeMode {
#[serde(alias = "Existing")]
Existing,
#[serde(alias = "Create")]
Create,
#[serde(alias = "EnsureExists")]
EnsureExists,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct NamedVolumeCreate {
pub mode: NamedVolumeMode,
pub name: String,
pub kind: VolumeKind,
pub quota_mib: Option<u32>,
pub capacity_mib: Option<u32>,
pub labels: Vec<(String, String)>,
}
fn default_strict() -> StatVirtualization {
StatVirtualization::Strict
}
fn default_private() -> HostPermissions {
HostPermissions::Private
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum VolumeMount {
#[serde(alias = "Bind")]
Bind {
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
host: PathBuf,
guest: String,
#[serde(default)]
options: MountOptions,
#[serde(default = "default_strict")]
stat_virtualization: StatVirtualization,
#[serde(default = "default_private")]
host_permissions: HostPermissions,
#[serde(default)]
quota_mib: Option<u32>,
},
#[serde(alias = "Named")]
Named {
name: String,
guest: String,
#[serde(skip)]
create: Option<NamedVolumeCreate>,
#[serde(default)]
options: MountOptions,
#[serde(default = "default_strict")]
stat_virtualization: StatVirtualization,
#[serde(default = "default_private")]
host_permissions: HostPermissions,
},
#[serde(alias = "Tmpfs")]
Tmpfs {
guest: String,
#[serde(default)]
size_mib: Option<u32>,
#[serde(default)]
options: MountOptions,
},
#[serde(alias = "DiskImage")]
DiskImage {
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
host: PathBuf,
guest: String,
format: DiskImageFormat,
#[serde(default)]
fstype: Option<String>,
#[serde(default)]
options: MountOptions,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum Patch {
#[serde(alias = "Text")]
Text {
path: String,
content: String,
mode: Option<u32>,
replace: bool,
},
#[serde(alias = "File")]
File {
path: String,
content: Vec<u8>,
mode: Option<u32>,
replace: bool,
},
#[serde(alias = "CopyFile")]
CopyFile {
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
src: PathBuf,
dst: String,
mode: Option<u32>,
replace: bool,
},
#[serde(alias = "CopyDir")]
CopyDir {
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
src: PathBuf,
dst: String,
replace: bool,
},
#[serde(alias = "Symlink")]
Symlink {
target: String,
link: String,
replace: bool,
},
#[serde(alias = "Mkdir")]
Mkdir {
path: String,
mode: Option<u32>,
},
#[serde(alias = "Remove")]
Remove {
path: String,
},
#[serde(alias = "Append")]
Append {
path: String,
content: String,
},
}
pub const MAX_SECRET_PLACEHOLDER_BYTES: usize = 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SecretsConfig {
#[serde(default, alias = "secrets")]
pub entries: Vec<SecretEntry>,
#[serde(default)]
pub on_violation: ViolationAction,
}
#[derive(Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SecretEntry {
pub env_var: String,
#[serde(default = "empty_secret_value")]
#[cfg_attr(feature = "ts", ts(type = "string"))]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub value: Zeroizing<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<SecretSource>,
pub placeholder: String,
#[serde(default)]
pub allowed_hosts: Vec<HostPattern>,
#[serde(default)]
pub injection: SecretInjection,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_violation: Option<ViolationAction>,
#[serde(default = "default_true")]
pub require_tls_identity: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum HostPattern {
#[serde(alias = "Exact")]
Exact(String),
#[serde(alias = "Wildcard")]
Wildcard(String),
#[serde(alias = "Any")]
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SecretInjection {
#[serde(default = "default_true")]
pub headers: bool,
#[serde(default = "default_true")]
pub basic_auth: bool,
#[serde(default)]
pub query_params: bool,
#[serde(default)]
pub body: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum ViolationAction {
#[serde(alias = "Block")]
Block,
#[default]
#[serde(alias = "BlockAndLog", alias = "block-and-log")]
BlockAndLog,
#[serde(alias = "BlockAndTerminate", alias = "block-and-terminate")]
BlockAndTerminate,
#[serde(alias = "Passthrough")]
Passthrough(Vec<HostPattern>),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SecretConfigError {
#[error("secret #{secret_index}: env_var must not be empty")]
EmptyEnvVar {
secret_index: usize,
},
#[error("secret #{secret_index}: env_var must not contain `=`")]
EnvVarContainsEquals {
secret_index: usize,
},
#[error("secret #{secret_index}: env_var must not contain NUL")]
EnvVarContainsNul {
secret_index: usize,
},
#[error("secret #{secret_index}: at least one allowed host is required")]
MissingAllowedHosts {
secret_index: usize,
},
#[error("secret #{secret_index}: placeholder must not be empty")]
EmptyPlaceholder {
secret_index: usize,
},
#[error(
"secret #{secret_index}: placeholder must be at most {max_bytes} bytes, got {actual_bytes}"
)]
PlaceholderTooLong {
secret_index: usize,
actual_bytes: usize,
max_bytes: usize,
},
#[error("secret #{secret_index}: placeholder must not contain NUL")]
PlaceholderContainsNul {
secret_index: usize,
},
#[error("secret #{secret_index}: placeholder must not contain CR or LF")]
PlaceholderContainsLineBreak {
secret_index: usize,
},
}
impl SecretsConfig {
pub fn validate(&self) -> Result<(), SecretConfigError> {
for (index, secret) in self.entries.iter().enumerate() {
secret.validate(index)?;
}
Ok(())
}
}
impl SecretEntry {
pub fn validate(&self, secret_index: usize) -> Result<(), SecretConfigError> {
validate_env_var(&self.env_var, secret_index)?;
if self.allowed_hosts.is_empty() {
return Err(SecretConfigError::MissingAllowedHosts { secret_index });
}
validate_placeholder(&self.placeholder, secret_index)
}
}
impl fmt::Debug for SecretEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SecretEntry")
.field("env_var", &self.env_var)
.field("value", &"[REDACTED]")
.field("source", &self.source)
.field("placeholder", &self.placeholder)
.field("allowed_hosts", &self.allowed_hosts)
.field("injection", &self.injection)
.field("on_violation", &self.on_violation)
.field("require_tls_identity", &self.require_tls_identity)
.finish()
}
}
impl HostPattern {
pub fn parse(host: &str) -> Self {
if host == "*" {
HostPattern::Any
} else if host.starts_with("*.") {
HostPattern::Wildcard(host.to_string())
} else {
HostPattern::Exact(host.to_string())
}
}
pub fn matches(&self, hostname: &str) -> bool {
match self {
HostPattern::Exact(h) => hostname.eq_ignore_ascii_case(h),
HostPattern::Wildcard(pattern) => {
if let Some(suffix) = pattern.strip_prefix("*.") {
hostname.eq_ignore_ascii_case(suffix)
|| (hostname.len() > suffix.len() + 1
&& hostname.as_bytes()[hostname.len() - suffix.len() - 1] == b'.'
&& hostname[hostname.len() - suffix.len()..]
.eq_ignore_ascii_case(suffix))
} else {
hostname.eq_ignore_ascii_case(pattern)
}
}
HostPattern::Any => true,
}
}
}
impl Default for SecretInjection {
fn default() -> Self {
Self {
headers: true,
basic_auth: true,
query_params: false,
body: false,
}
}
}
fn default_true() -> bool {
true
}
fn validate_env_var(env_var: &str, secret_index: usize) -> Result<(), SecretConfigError> {
if env_var.is_empty() {
return Err(SecretConfigError::EmptyEnvVar { secret_index });
}
if env_var.contains('=') {
return Err(SecretConfigError::EnvVarContainsEquals { secret_index });
}
if env_var.contains('\0') {
return Err(SecretConfigError::EnvVarContainsNul { secret_index });
}
Ok(())
}
fn validate_placeholder(placeholder: &str, secret_index: usize) -> Result<(), SecretConfigError> {
if placeholder.is_empty() {
return Err(SecretConfigError::EmptyPlaceholder { secret_index });
}
let actual_bytes = placeholder.len();
if actual_bytes > MAX_SECRET_PLACEHOLDER_BYTES {
return Err(SecretConfigError::PlaceholderTooLong {
secret_index,
actual_bytes,
max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
});
}
if placeholder.contains('\0') {
return Err(SecretConfigError::PlaceholderContainsNul { secret_index });
}
if placeholder.contains('\r') || placeholder.contains('\n') {
return Err(SecretConfigError::PlaceholderContainsLineBreak { secret_index });
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct TlsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_intercepted_ports")]
pub intercepted_ports: Vec<u16>,
#[serde(default)]
pub bypass: Vec<String>,
#[serde(default = "default_true")]
pub verify_upstream: bool,
#[serde(default = "default_true")]
pub block_quic_on_intercept: bool,
#[serde(default)]
#[cfg_attr(feature = "utoipa", schema(value_type = Vec<String>))]
#[cfg_attr(feature = "ts", ts(type = "Array<string>"))]
pub upstream_ca_cert: Vec<PathBuf>,
#[serde(default, alias = "scoped_upstream_ca_certs")]
pub scoped_upstream_ca_cert: Vec<ScopedUpstreamCaCert>,
#[serde(default)]
pub scoped_verify_upstream: Vec<ScopedVerifyUpstream>,
#[serde(default, alias = "ca")]
pub intercept_ca: InterceptCaConfig,
#[serde(default)]
pub cache: CertCacheConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct InterceptCaConfig {
#[serde(default)]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub cert_path: Option<PathBuf>,
#[serde(default)]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub key_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct CertCacheConfig {
#[serde(default = "default_cache_capacity")]
pub capacity: usize,
#[serde(default = "default_cert_validity_hours")]
pub validity_hours: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct ScopedUpstreamCaCert {
pub pattern: String,
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
pub path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct ScopedVerifyUpstream {
pub pattern: String,
pub verify: bool,
}
impl Default for TlsConfig {
fn default() -> Self {
Self {
enabled: false,
intercepted_ports: default_intercepted_ports(),
bypass: Vec::new(),
verify_upstream: true,
block_quic_on_intercept: true,
upstream_ca_cert: Vec::new(),
scoped_upstream_ca_cert: Vec::new(),
scoped_verify_upstream: Vec::new(),
intercept_ca: InterceptCaConfig::default(),
cache: CertCacheConfig::default(),
}
}
}
impl Default for CertCacheConfig {
fn default() -> Self {
Self {
capacity: default_cache_capacity(),
validity_hours: default_cert_validity_hours(),
}
}
}
fn default_intercepted_ports() -> Vec<u16> {
vec![443]
}
fn default_cache_capacity() -> usize {
1000
}
fn default_cert_validity_hours() -> u64 {
24
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum Action {
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum Direction {
Egress,
Ingress,
Any,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "kebab-case")]
pub enum Protocol {
Tcp,
Udp,
Icmpv4,
Icmpv6,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum DestinationGroup {
Public,
Loopback,
Private,
#[serde(alias = "link-local")]
LinkLocal,
Metadata,
Multicast,
Host,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum Destination {
Any,
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
Cidr(#[cfg_attr(feature = "ts", ts(type = "string"))] IpNetwork),
Domain(String),
#[serde(alias = "domain-suffix")]
DomainSuffix(String),
Group(DestinationGroup),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct PortRange {
pub start: u16,
pub end: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Rule {
pub direction: Direction,
pub destination: Destination,
#[serde(default)]
pub protocols: Vec<Protocol>,
#[serde(default)]
pub ports: Vec<PortRange>,
pub action: Action,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct NetworkPolicy {
#[serde(default = "action_deny")]
pub default_egress: Action,
#[serde(default = "action_deny")]
pub default_ingress: Action,
#[serde(default)]
pub rules: Vec<Rule>,
}
fn action_deny() -> Action {
Action::Deny
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct DnsConfig {
pub rebind_protection: bool,
pub nameservers: Vec<String>,
pub query_timeout_ms: u64,
}
impl Default for DnsConfig {
fn default() -> Self {
Self {
rebind_protection: true,
nameservers: Vec::new(),
query_timeout_ms: 5000,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct InterfaceOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub mac: Option<[u8; 6]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub ipv4_address: Option<Ipv4Addr>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub ipv4_pool: Option<Ipv4Network>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub ipv6_address: Option<Ipv6Addr>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(value_type = Option<String>))]
#[cfg_attr(feature = "ts", ts(type = "string | null"))]
pub ipv6_pool: Option<Ipv6Network>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct NetworkSpec {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub interface: Option<InterfaceOverrides>,
pub ports: Vec<PublishedPortSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<NetworkPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns: Option<DnsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls: Option<TlsConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secrets: Option<SecretsConfig>,
pub max_connections: Option<usize>,
pub trust_host_cas: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct PublishedPortSpec {
pub host_port: u16,
pub guest_port: u16,
#[serde(default)]
pub protocol: PortProtocol,
pub host_bind: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub enum PortProtocol {
#[default]
#[serde(rename = "tcp")]
Tcp,
#[serde(rename = "udp")]
Udp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct HandoffInit {
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
#[cfg_attr(feature = "ts", ts(type = "string"))]
pub cmd: PathBuf,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<(String, String)>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SandboxPolicy {
#[serde(default)]
pub ephemeral: bool,
pub max_duration_secs: Option<u64>,
pub idle_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SnapshotDestination {
#[serde(alias = "Name")]
Name(String),
#[serde(alias = "Path")]
Path(
PathBuf,
),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotSpec {
pub source_sandbox: String,
pub destination: SnapshotDestination,
pub labels: Vec<(String, String)>,
pub force: bool,
pub record_integrity: bool,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct SandboxSpec {
pub name: String,
#[cfg_attr(feature = "utoipa", schema(value_type = Object))]
pub image: RootfsSource,
pub resources: SandboxResources,
pub runtime: SandboxRuntimeOptions,
pub env: Vec<EnvVar>,
pub labels: BTreeMap<String, String>,
pub rlimits: Vec<Rlimit>,
pub mounts: Vec<VolumeMount>,
pub patches: Vec<Patch>,
pub network: NetworkSpec,
pub init: Option<HandoffInit>,
pub pull_policy: PullPolicy,
pub security_profile: SecurityProfile,
pub lifecycle: SandboxPolicy,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct SandboxResources {
pub vcpus: u8,
pub memory_mib: u32,
pub max_vcpus: u8,
pub max_memory_mib: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(default)]
pub struct SandboxRuntimeOptions {
pub workdir: Option<String>,
pub shell: Option<String>,
pub scripts: BTreeMap<String, String>,
pub entrypoint: Option<Vec<String>>,
pub cmd: Option<Vec<String>>,
pub hostname: Option<String>,
pub user: Option<String>,
pub log_level: Option<SandboxLogLevel>,
pub metrics_sample_interval_ms: Option<u64>,
pub disable_metrics_sample: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct EnvVar {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum SandboxLogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[serde(rename_all = "lowercase")]
pub enum RlimitResource {
#[serde(alias = "Cpu")]
Cpu,
#[serde(alias = "Fsize")]
Fsize,
#[serde(alias = "Data")]
Data,
#[serde(alias = "Stack")]
Stack,
#[serde(alias = "Core")]
Core,
#[serde(alias = "Rss")]
Rss,
#[serde(alias = "Nproc")]
Nproc,
#[serde(alias = "Nofile")]
Nofile,
#[serde(alias = "Memlock")]
Memlock,
#[serde(alias = "As")]
As,
#[serde(alias = "Locks")]
Locks,
#[serde(alias = "Sigpending")]
Sigpending,
#[serde(alias = "Msgqueue")]
Msgqueue,
#[serde(alias = "Nice")]
Nice,
#[serde(alias = "Rtprio")]
Rtprio,
#[serde(alias = "Rttime")]
Rttime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
pub struct Rlimit {
pub resource: RlimitResource,
pub soft: u64,
pub hard: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum LogSource {
Stdout,
Stderr,
Output,
System,
}
impl DiskImageFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Qcow2 => "qcow2",
Self::Raw => "raw",
Self::Vmdk => "vmdk",
}
}
pub fn from_extension(ext: &str) -> Option<Self> {
match ext {
"qcow2" => Some(Self::Qcow2),
"raw" => Some(Self::Raw),
"vmdk" => Some(Self::Vmdk),
_ => None,
}
}
}
impl OciRootfsSource {
pub fn new(reference: impl Into<String>) -> Self {
Self {
reference: reference.into(),
upper_size_mib: None,
}
}
}
impl RootfsSource {
pub fn oci(reference: impl Into<String>) -> Self {
Self::Oci(OciRootfsSource::new(reference))
}
pub fn oci_reference(&self) -> Option<&str> {
match self {
Self::Oci(oci) => Some(&oci.reference),
_ => None,
}
}
pub fn oci_upper_size_mib(&self) -> Option<u32> {
match self {
Self::Oci(oci) => oci.upper_size_mib,
_ => None,
}
}
}
impl EnvVar {
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
pub fn as_pair(&self) -> (&str, &str) {
(&self.key, &self.value)
}
}
impl VolumeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Directory => "dir",
Self::Disk => "disk",
}
}
pub fn from_db_value(value: &str) -> Self {
match value {
"disk" => Self::Disk,
_ => Self::Directory,
}
}
}
impl VolumeSpec {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: VolumeKind::Directory,
quota_mib: None,
capacity_mib: None,
labels: Vec::new(),
}
}
}
impl NamedVolumeCreate {
pub fn mode(&self) -> NamedVolumeMode {
self.mode
}
pub fn name(&self) -> &str {
&self.name
}
pub fn kind(&self) -> VolumeKind {
self.kind
}
pub fn quota_mib(&self) -> Option<u32> {
self.quota_mib
}
pub fn capacity_mib(&self) -> Option<u32> {
self.capacity_mib
}
pub fn labels(&self) -> &[(String, String)] {
&self.labels
}
}
impl VolumeMount {
pub fn guest(&self) -> &str {
match self {
Self::Bind { guest, .. }
| Self::Named { guest, .. }
| Self::Tmpfs { guest, .. }
| Self::DiskImage { guest, .. } => guest,
}
}
pub fn named_create(&self) -> Option<&NamedVolumeCreate> {
match self {
Self::Named { create, .. } => create.as_ref(),
_ => None,
}
}
}
impl RlimitResource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Cpu => "cpu",
Self::Fsize => "fsize",
Self::Data => "data",
Self::Stack => "stack",
Self::Core => "core",
Self::Rss => "rss",
Self::Nproc => "nproc",
Self::Nofile => "nofile",
Self::Memlock => "memlock",
Self::As => "as",
Self::Locks => "locks",
Self::Sigpending => "sigpending",
Self::Msgqueue => "msgqueue",
Self::Nice => "nice",
Self::Rtprio => "rtprio",
Self::Rttime => "rttime",
}
}
}
impl LogSource {
pub fn effective(requested: &[Self]) -> Vec<Self> {
if requested.is_empty() {
vec![Self::Stdout, Self::Stderr, Self::Output]
} else {
let mut sources = requested.to_vec();
sources.sort_by_key(|src| match src {
Self::Stdout => 0,
Self::Stderr => 1,
Self::Output => 2,
Self::System => 3,
});
sources.dedup();
sources
}
}
}
impl SandboxLogLevel {
pub const fn as_str(self) -> &'static str {
match self {
Self::Error => "error",
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
Self::Trace => "trace",
}
}
}
impl std::fmt::Display for DiskImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for DiskImageFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"qcow2" => Ok(Self::Qcow2),
"raw" => Ok(Self::Raw),
"vmdk" => Ok(Self::Vmdk),
_ => Err(format!("unknown disk image format: {s}")),
}
}
}
impl Default for RootfsSource {
fn default() -> Self {
Self::oci(String::new())
}
}
impl Default for SandboxResources {
fn default() -> Self {
Self {
vcpus: DEFAULT_SANDBOX_VCPUS,
memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
max_vcpus: DEFAULT_SANDBOX_VCPUS,
max_memory_mib: DEFAULT_SANDBOX_MEMORY_MIB,
}
}
}
impl<'de> Deserialize<'de> for SandboxResources {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawResources {
#[serde(default = "default_sandbox_vcpus")]
vcpus: u8,
#[serde(default = "default_sandbox_memory_mib")]
memory_mib: u32,
max_vcpus: Option<u8>,
max_memory_mib: Option<u32>,
}
let raw = RawResources::deserialize(deserializer)?;
Ok(Self {
vcpus: raw.vcpus,
memory_mib: raw.memory_mib,
max_vcpus: raw.max_vcpus.unwrap_or(raw.vcpus),
max_memory_mib: raw.max_memory_mib.unwrap_or(raw.memory_mib),
})
}
}
impl Default for SandboxRuntimeOptions {
fn default() -> Self {
Self {
workdir: None,
shell: None,
scripts: BTreeMap::new(),
entrypoint: None,
cmd: None,
hostname: None,
user: None,
log_level: None,
metrics_sample_interval_ms: Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS),
disable_metrics_sample: false,
}
}
}
impl Default for NetworkSpec {
fn default() -> Self {
Self {
enabled: true,
interface: None,
ports: Vec::new(),
policy: None,
dns: None,
tls: None,
secrets: None,
max_connections: None,
trust_host_cas: false,
}
}
}
impl Default for PublishedPortSpec {
fn default() -> Self {
Self {
host_port: 0,
guest_port: 0,
protocol: PortProtocol::Tcp,
host_bind: "127.0.0.1".into(),
}
}
}
impl From<(String, String)> for EnvVar {
fn from((key, value): (String, String)) -> Self {
Self { key, value }
}
}
impl From<EnvVar> for (String, String) {
fn from(var: EnvVar) -> Self {
(var.key, var.value)
}
}
impl FromStr for SandboxLogLevel {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"error" => Ok(Self::Error),
"warn" => Ok(Self::Warn),
"info" => Ok(Self::Info),
"debug" => Ok(Self::Debug),
"trace" => Ok(Self::Trace),
_ => Err(format!("unknown sandbox log level: {s}")),
}
}
}
impl TryFrom<&str> for RlimitResource {
type Error = String;
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s.to_ascii_lowercase().as_str() {
"cpu" => Ok(Self::Cpu),
"fsize" => Ok(Self::Fsize),
"data" => Ok(Self::Data),
"stack" => Ok(Self::Stack),
"core" => Ok(Self::Core),
"rss" => Ok(Self::Rss),
"nproc" => Ok(Self::Nproc),
"nofile" => Ok(Self::Nofile),
"memlock" => Ok(Self::Memlock),
"as" => Ok(Self::As),
"locks" => Ok(Self::Locks),
"sigpending" => Ok(Self::Sigpending),
"msgqueue" => Ok(Self::Msgqueue),
"nice" => Ok(Self::Nice),
"rtprio" => Ok(Self::Rtprio),
"rttime" => Ok(Self::Rttime),
_ => Err(format!("unknown rlimit resource: {s}")),
}
}
}
fn default_sandbox_vcpus() -> u8 {
DEFAULT_SANDBOX_VCPUS
}
fn default_sandbox_memory_mib() -> u32 {
DEFAULT_SANDBOX_MEMORY_MIB
}
fn empty_secret_value() -> Zeroizing<String> {
Zeroizing::new(String::new())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn casing_is_canonical_with_legacy_aliases() {
assert_eq!(
serde_json::to_string(&RlimitResource::Nofile).unwrap(),
r#""nofile""#
);
assert_eq!(
serde_json::from_str::<RlimitResource>(r#""Nofile""#).unwrap(),
RlimitResource::Nofile
);
assert_eq!(
serde_json::to_string(&SnapshotDestination::Name("snap".into())).unwrap(),
r#"{"name":"snap"}"#
);
assert!(matches!(
serde_json::from_str::<SnapshotDestination>(r#"{"Name":"snap"}"#).unwrap(),
SnapshotDestination::Name(_)
));
assert!(matches!(
serde_json::from_str::<SnapshotDestination>(r#"{"path":"/tmp/x"}"#).unwrap(),
SnapshotDestination::Path(_)
));
}
#[test]
fn disk_image_format_from_extension() {
assert_eq!(
DiskImageFormat::from_extension("qcow2"),
Some(DiskImageFormat::Qcow2)
);
assert_eq!(
DiskImageFormat::from_extension("raw"),
Some(DiskImageFormat::Raw)
);
assert_eq!(
DiskImageFormat::from_extension("vmdk"),
Some(DiskImageFormat::Vmdk)
);
assert_eq!(DiskImageFormat::from_extension("ext4"), None);
assert_eq!(DiskImageFormat::from_extension(""), None);
}
#[test]
fn sandbox_resources_deserialize_legacy_capacity_from_effective_values() {
let resources: SandboxResources =
serde_json::from_str(r#"{"vcpus":4,"memory_mib":2048}"#).unwrap();
assert_eq!(resources.vcpus, 4);
assert_eq!(resources.max_vcpus, 4);
assert_eq!(resources.memory_mib, 2048);
assert_eq!(resources.max_memory_mib, 2048);
}
#[test]
fn disk_image_format_display_roundtrip() {
for format in [
DiskImageFormat::Qcow2,
DiskImageFormat::Raw,
DiskImageFormat::Vmdk,
] {
let rendered = format.to_string();
let parsed: DiskImageFormat = rendered.parse().unwrap();
assert_eq!(parsed, format);
}
}
#[test]
fn disk_image_format_from_str_unknown() {
assert!("ext4".parse::<DiskImageFormat>().is_err());
}
#[test]
fn log_source_effective_uses_default_user_program_sources() {
assert_eq!(
LogSource::effective(&[]),
vec![LogSource::Stdout, LogSource::Stderr, LogSource::Output]
);
}
#[test]
fn log_source_effective_sorts_and_deduplicates_requested_sources() {
assert_eq!(
LogSource::effective(&[LogSource::System, LogSource::Stdout, LogSource::System]),
vec![LogSource::Stdout, LogSource::System]
);
}
#[test]
fn rlimit_resource_parses_case_insensitively() {
assert_eq!(
RlimitResource::try_from("NOFILE").unwrap(),
RlimitResource::Nofile
);
assert!(RlimitResource::try_from("bogus").is_err());
}
#[test]
fn sandbox_policy_serde_roundtrip() {
let policy = SandboxPolicy {
ephemeral: true,
max_duration_secs: Some(3600),
idle_timeout_secs: Some(120),
};
let json = serde_json::to_string(&policy).unwrap();
let decoded: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert!(decoded.ephemeral);
assert_eq!(decoded.max_duration_secs, Some(3600));
assert_eq!(decoded.idle_timeout_secs, Some(120));
}
#[test]
fn sandbox_policy_defaults_to_persistent() {
assert!(!SandboxPolicy::default().ephemeral);
}
#[test]
fn sandbox_policy_deserializes_missing_ephemeral_as_persistent() {
let decoded: SandboxPolicy =
serde_json::from_str(r#"{"max_duration_secs":60,"idle_timeout_secs":null}"#).unwrap();
assert!(!decoded.ephemeral);
assert_eq!(decoded.max_duration_secs, Some(60));
}
#[test]
fn sandbox_spec_default_uses_static_resource_defaults() {
let spec = SandboxSpec::default();
assert_eq!(spec.resources.vcpus, DEFAULT_SANDBOX_VCPUS);
assert_eq!(spec.resources.memory_mib, DEFAULT_SANDBOX_MEMORY_MIB);
assert_eq!(
spec.runtime.metrics_sample_interval_ms,
Some(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
);
}
#[test]
fn sandbox_log_level_roundtrips_lowercase_values() {
for (input, expected) in [
("error", SandboxLogLevel::Error),
("warn", SandboxLogLevel::Warn),
("info", SandboxLogLevel::Info),
("debug", SandboxLogLevel::Debug),
("trace", SandboxLogLevel::Trace),
] {
let parsed: SandboxLogLevel = input.parse().unwrap();
assert_eq!(parsed, expected);
assert_eq!(parsed.as_str(), input);
}
}
}
#[cfg(test)]
mod secret_tests {
use super::*;
fn valid_secret() -> SecretEntry {
SecretEntry {
env_var: "API_KEY".into(),
value: "secret".to_string().into(),
source: None,
placeholder: "$MSB_API_KEY".into(),
allowed_hosts: vec![HostPattern::Exact("api.example.com".into())],
injection: SecretInjection::default(),
on_violation: None,
require_tls_identity: true,
}
}
#[test]
fn exact_host_match() {
let p = HostPattern::Exact("api.openai.com".into());
assert!(p.matches("api.openai.com"));
assert!(p.matches("API.OpenAI.com"));
assert!(!p.matches("evil.com"));
}
#[test]
fn wildcard_host_match() {
let p = HostPattern::Wildcard("*.openai.com".into());
assert!(p.matches("api.openai.com"));
assert!(p.matches("openai.com"));
assert!(!p.matches("evil.com"));
}
#[test]
fn any_host_match() {
assert!(HostPattern::Any.matches("anything.com"));
}
#[test]
fn default_injection_scopes() {
let inj = SecretInjection::default();
assert!(inj.headers);
assert!(inj.basic_auth);
assert!(!inj.query_params);
assert!(!inj.body);
}
#[test]
fn default_require_tls_identity_when_deserialized() {
let entry: SecretEntry = serde_json::from_str(
r#"{"env_var":"K","value":"v","placeholder":"$K","allowed_hosts":[{"exact":"h"}]}"#,
)
.unwrap();
assert!(entry.require_tls_identity);
}
#[test]
fn secret_validation_accepts_linux_environment_name_shape() {
let mut entry = valid_secret();
entry.env_var = "1TOKEN.with-dashes".into();
assert!(entry.validate(0).is_ok());
}
#[test]
fn secret_validation_rejects_invalid_env_var_names() {
let cases = [
("", SecretConfigError::EmptyEnvVar { secret_index: 0 }),
(
"API=KEY",
SecretConfigError::EnvVarContainsEquals { secret_index: 0 },
),
(
"API\0KEY",
SecretConfigError::EnvVarContainsNul { secret_index: 0 },
),
];
for (env_var, expected) in cases {
let mut entry = valid_secret();
entry.env_var = env_var.into();
assert_eq!(entry.validate(0), Err(expected));
}
}
#[test]
fn secret_validation_rejects_missing_allowed_hosts() {
let mut entry = valid_secret();
entry.allowed_hosts.clear();
assert_eq!(
entry.validate(0),
Err(SecretConfigError::MissingAllowedHosts { secret_index: 0 })
);
}
#[test]
fn secret_validation_rejects_invalid_placeholders() {
let too_long = "x".repeat(MAX_SECRET_PLACEHOLDER_BYTES + 1);
let cases = [
("", SecretConfigError::EmptyPlaceholder { secret_index: 0 }),
(
too_long.as_str(),
SecretConfigError::PlaceholderTooLong {
secret_index: 0,
actual_bytes: MAX_SECRET_PLACEHOLDER_BYTES + 1,
max_bytes: MAX_SECRET_PLACEHOLDER_BYTES,
},
),
(
"abc\0def",
SecretConfigError::PlaceholderContainsNul { secret_index: 0 },
),
(
"abc\rdef",
SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
),
(
"abc\ndef",
SecretConfigError::PlaceholderContainsLineBreak { secret_index: 0 },
),
];
for (placeholder, expected) in cases {
let mut entry = valid_secret();
entry.placeholder = placeholder.into();
assert_eq!(entry.validate(0), Err(expected));
}
}
#[test]
fn violation_action_serializes_with_sdk_casing() {
let action = ViolationAction::Passthrough(vec![
HostPattern::Exact("api.anthropic.com".into()),
HostPattern::Wildcard("*.anthropic.com".into()),
HostPattern::Any,
]);
assert_eq!(
serde_json::to_string(&action).unwrap(),
r#"{"passthrough":[{"exact":"api.anthropic.com"},{"wildcard":"*.anthropic.com"},"any"]}"#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndLog).unwrap(),
r#""block_and_log""#
);
assert_eq!(
serde_json::to_string(&ViolationAction::BlockAndTerminate).unwrap(),
r#""block_and_terminate""#
);
}
#[test]
fn violation_action_accepts_legacy_pascal_case() {
let action: ViolationAction =
serde_json::from_str(r#"{"Passthrough":[{"Exact":"api.anthropic.com"}]}"#).unwrap();
assert_eq!(
action,
ViolationAction::Passthrough(vec![HostPattern::Exact("api.anthropic.com".into())])
);
assert_eq!(
serde_json::from_str::<ViolationAction>(r#""BlockAndTerminate""#).unwrap(),
ViolationAction::BlockAndTerminate
);
}
#[test]
fn secret_entry_debug_redacts_value() {
let mut entry = valid_secret();
entry.value = "uniq-sensitive-12345".to_string().into();
let dbg = format!("{entry:?}");
assert!(dbg.contains("[REDACTED]"));
assert!(!dbg.contains("uniq-sensitive-12345"));
}
}
#[cfg(test)]
mod tls_tests {
use super::*;
#[test]
fn tls_config_defaults() {
let t = TlsConfig::default();
assert!(!t.enabled);
assert_eq!(t.intercepted_ports, vec![443]);
assert!(t.verify_upstream);
assert!(t.block_quic_on_intercept);
assert_eq!(t.cache.capacity, 1000);
assert_eq!(t.cache.validity_hours, 24);
}
#[test]
fn tls_config_round_trips_and_accepts_ca_alias() {
let cfg: TlsConfig = serde_json::from_str(
r#"{"enabled":true,"bypass":["*.internal"],"ca":{"cert_path":"/etc/ca.pem"}}"#,
)
.unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.bypass, vec!["*.internal".to_string()]);
assert_eq!(
cfg.intercept_ca.cert_path.as_deref(),
Some(std::path::Path::new("/etc/ca.pem"))
);
let back: TlsConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.bypass, cfg.bypass);
}
}