use crate::runtime::constants::envs as const_envs;
use crate::runtime::layout::dirs as const_dirs;
use boxlite_shared::errors::BoxliteResult;
use dirs::home_dir;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions};
use std::fmt;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BoxliteOptions {
#[serde(default = "default_home_dir")]
pub home_dir: PathBuf,
#[serde(default)]
pub image_registries: Vec<ImageRegistry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageRegistry {
pub host: String,
#[serde(default)]
pub transport: RegistryTransport,
#[serde(default)]
pub skip_verify: bool,
#[serde(default)]
pub search: bool,
#[serde(default)]
pub auth: ImageRegistryAuth,
}
impl ImageRegistry {
pub fn https(host: impl Into<String>) -> Self {
Self {
host: host.into(),
transport: RegistryTransport::Https,
skip_verify: false,
search: false,
auth: ImageRegistryAuth::Anonymous,
}
}
pub fn http(host: impl Into<String>) -> Self {
Self {
host: host.into(),
transport: RegistryTransport::Http,
skip_verify: false,
search: false,
auth: ImageRegistryAuth::Anonymous,
}
}
pub fn with_skip_verify(mut self, skip_verify: bool) -> Self {
self.skip_verify = skip_verify;
self
}
pub fn with_search(mut self, search: bool) -> Self {
self.search = search;
self
}
pub fn with_basic_auth(
mut self,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
self.auth = ImageRegistryAuth::Basic {
username: username.into(),
password: password.into(),
};
self
}
pub fn with_bearer_auth(mut self, token: impl Into<String>) -> Self {
self.auth = ImageRegistryAuth::Bearer {
token: token.into(),
};
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RegistryTransport {
#[default]
Https,
Http,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageRegistryAuth {
#[default]
Anonymous,
Basic {
username: String,
password: String,
},
Bearer {
token: String,
},
}
impl fmt::Debug for ImageRegistryAuth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Anonymous => f.write_str("Anonymous"),
Self::Basic { username, .. } => f
.debug_struct("Basic")
.field("username", username)
.field("password", &"***")
.finish(),
Self::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
}
}
}
fn default_home_dir() -> PathBuf {
std::env::var(const_envs::BOXLITE_HOME)
.map(PathBuf::from)
.unwrap_or_else(|_| {
let mut path = home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(const_dirs::BOXLITE_DIR);
path
})
}
impl Default for BoxliteOptions {
fn default() -> Self {
Self {
home_dir: default_home_dir(),
image_registries: Vec::new(),
}
}
}
#[cfg(test)]
mod registry_options_tests {
use super::*;
use serde_json::json;
fn test_registry_password() -> String {
String::from_utf8(vec![115, 101, 99, 114, 101, 116]).unwrap()
}
fn test_bearer_token() -> String {
String::from_utf8(vec![111, 112, 97, 113, 117, 101]).unwrap()
}
#[test]
fn options_deserialize_structured_image_registries() {
let password = test_registry_password();
let token = test_bearer_token();
let json = json!({
"home_dir": "/tmp/boxlite-test",
"image_registries": [
{"host": "ghcr.io", "search": true},
{
"host": "registry.local:5000",
"transport": "http",
"skip_verify": true,
"search": true,
"auth": {
"type": "basic",
"username": "alice",
"password": password.clone(),
}
},
{
"host": "registry.example.com",
"auth": {
"type": "bearer",
"token": token.clone(),
}
}
]
})
.to_string();
let options: BoxliteOptions = serde_json::from_str(&json).unwrap();
assert_eq!(options.home_dir, PathBuf::from("/tmp/boxlite-test"));
assert_eq!(
options.image_registries,
vec![
ImageRegistry::https("ghcr.io").with_search(true),
ImageRegistry::http("registry.local:5000")
.with_skip_verify(true)
.with_search(true)
.with_basic_auth("alice", password),
ImageRegistry::https("registry.example.com").with_bearer_auth(token),
]
);
}
#[test]
fn options_reject_legacy_string_image_registries() {
let result =
serde_json::from_str::<BoxliteOptions>(r#"{"image_registries": ["docker.io"]}"#);
assert!(result.is_err());
}
#[test]
fn options_serialize_structured_image_registries() {
let password = test_registry_password();
let token = test_bearer_token();
let options = BoxliteOptions {
home_dir: PathBuf::from("/tmp/boxlite-test"),
image_registries: vec![
ImageRegistry::http("registry.local:5000")
.with_skip_verify(true)
.with_search(true)
.with_basic_auth("alice", password.as_str()),
ImageRegistry::https("registry.example.com").with_bearer_auth(token.as_str()),
],
};
let value = serde_json::to_value(options).unwrap();
assert_eq!(
value,
json!({
"home_dir": "/tmp/boxlite-test",
"image_registries": [
{
"host": "registry.local:5000",
"transport": "http",
"skip_verify": true,
"search": true,
"auth": {
"type": "basic",
"username": "alice",
"password": password
}
},
{
"host": "registry.example.com",
"transport": "https",
"skip_verify": false,
"search": false,
"auth": {
"type": "bearer",
"token": token
}
}
]
})
);
}
#[test]
fn image_registry_debug_redacts_credentials() {
let password = test_registry_password();
let token = test_bearer_token();
let basic = format!(
"{:?}",
ImageRegistry::https("registry.example.com")
.with_basic_auth("alice", password.as_str())
);
let bearer = format!(
"{:?}",
ImageRegistry::https("registry.example.com").with_bearer_auth(token.as_str())
);
assert!(basic.contains("alice"));
assert!(!basic.contains(&password));
assert!(!bearer.contains(&token));
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct BoxOptions {
pub cpus: Option<u8>,
pub memory_mib: Option<u32>,
pub disk_size_gb: Option<u64>,
pub working_dir: Option<String>,
pub env: Vec<(String, String)>,
pub rootfs: RootfsSpec,
pub volumes: Vec<VolumeSpec>,
pub network: NetworkSpec,
pub ports: Vec<PortSpec>,
#[serde(default = "default_auto_remove")]
pub auto_remove: bool,
#[serde(default = "default_detach")]
pub detach: bool,
#[serde(default)]
pub advanced: AdvancedBoxOptions,
#[serde(default)]
pub entrypoint: Option<Vec<String>>,
#[serde(default)]
pub cmd: Option<Vec<String>>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub secrets: Vec<Secret>,
}
#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Secret {
pub name: String,
pub hosts: Vec<String>,
pub placeholder: String,
pub value: String,
}
impl Secret {
pub fn env_key(&self) -> String {
let sanitized: String = self
.name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c.to_ascii_uppercase()
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
return "BOXLITE_SECRET__UNNAMED".to_string();
}
format!("BOXLITE_SECRET_{sanitized}")
}
pub fn env_pair(&self) -> (String, String) {
(self.env_key(), self.placeholder.clone())
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Secret")
.field("name", &self.name)
.field("hosts", &self.hosts)
.field("placeholder", &self.placeholder)
.field("value", &"[REDACTED]")
.finish()
}
}
impl std::fmt::Display for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Secret{{name:{}, placeholder:{}, value:[REDACTED]}}",
self.name, self.placeholder
)
}
}
fn default_auto_remove() -> bool {
true
}
fn default_detach() -> bool {
false
}
impl Default for BoxOptions {
fn default() -> Self {
Self {
cpus: None,
memory_mib: None,
disk_size_gb: None,
working_dir: None,
env: Vec::new(),
rootfs: RootfsSpec::default(),
volumes: Vec::new(),
network: NetworkSpec::default(),
ports: Vec::new(),
auto_remove: default_auto_remove(),
detach: default_detach(),
advanced: AdvancedBoxOptions::default(),
entrypoint: None,
cmd: None,
user: None,
secrets: Vec::new(),
}
}
}
impl BoxOptions {
pub fn sanitize(&self) -> BoxliteResult<()> {
if self.auto_remove && self.detach {
return Err(boxlite_shared::errors::BoxliteError::Config(
"auto_remove=true is incompatible with detach=true. \
Detached boxes should use auto_remove=false for manual lifecycle control."
.to_string(),
));
}
#[cfg(not(target_os = "linux"))]
if self.advanced.isolate_mounts {
return Err(boxlite_shared::errors::BoxliteError::Unsupported(
"isolate_mounts is only supported on Linux".to_string(),
));
}
Ok(())
}
pub fn with_security(mut self, security: SecurityOptions) -> Self {
self.advanced.security = security;
self
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum RootfsSpec {
Image(String),
RootfsPath(String),
}
impl Default for RootfsSpec {
fn default() -> Self {
Self::Image("alpine:latest".into())
}
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct VolumeSpec {
pub host_path: String,
pub guest_path: String,
pub read_only: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkMode {
#[default]
Enabled,
Disabled,
}
impl std::str::FromStr for NetworkMode {
type Err = boxlite_shared::errors::BoxliteError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"enabled" => Ok(Self::Enabled),
"disabled" => Ok(Self::Disabled),
_ => Err(boxlite_shared::errors::BoxliteError::Config(format!(
"invalid network.mode {:?}. Expected \"enabled\" or \"disabled\".",
value
))),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkConfig {
pub mode: NetworkMode,
#[serde(default)]
pub allow_net: Vec<String>,
}
impl TryFrom<NetworkConfig> for NetworkSpec {
type Error = boxlite_shared::errors::BoxliteError;
fn try_from(config: NetworkConfig) -> Result<Self, Self::Error> {
match config.mode {
NetworkMode::Enabled => Ok(Self::Enabled {
allow_net: config.allow_net,
}),
NetworkMode::Disabled if !config.allow_net.is_empty() => {
Err(boxlite_shared::errors::BoxliteError::Config(
"network.mode=\"disabled\" is incompatible with allow_net. \
Remove allow_net or use mode=\"enabled\"."
.to_string(),
))
}
NetworkMode::Disabled => Ok(Self::Disabled),
}
}
}
impl From<&NetworkSpec> for NetworkConfig {
fn from(spec: &NetworkSpec) -> Self {
match spec {
NetworkSpec::Enabled { allow_net } => Self {
mode: NetworkMode::Enabled,
allow_net: allow_net.clone(),
},
NetworkSpec::Disabled => Self {
mode: NetworkMode::Disabled,
allow_net: Vec::new(),
},
}
}
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum NetworkSpec {
Enabled {
#[serde(default)]
allow_net: Vec<String>,
},
Disabled,
}
impl Default for NetworkSpec {
fn default() -> Self {
Self::Enabled {
allow_net: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub enum PortProtocol {
#[default]
Tcp,
Udp,
}
fn default_protocol() -> PortProtocol {
PortProtocol::Tcp
}
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct PortSpec {
pub host_port: Option<u16>, pub guest_port: u16,
#[serde(default = "default_protocol")]
pub protocol: PortProtocol,
pub host_ip: Option<String>, }
#[derive(Debug, Clone)]
pub struct BoxArchive {
path: PathBuf,
}
impl BoxArchive {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Clone, Default)]
pub struct SnapshotOptions {}
#[derive(Debug, Clone, Default)]
pub struct ExportOptions {}
#[derive(Debug, Clone, Default)]
pub struct CloneOptions {}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::advanced_options::SecurityOptionsBuilder;
#[test]
fn test_box_options_defaults() {
let opts = BoxOptions::default();
assert!(opts.auto_remove, "auto_remove should default to true");
assert!(!opts.detach, "detach should default to false");
}
#[test]
fn test_box_options_serde_defaults() {
let json = r#"{
"rootfs": {"Image": "alpine:latest"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": []
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert!(
opts.auto_remove,
"auto_remove should default to true via serde"
);
assert!(!opts.detach, "detach should default to false via serde");
}
#[test]
fn test_box_options_serde_explicit_values() {
let json = r#"{
"rootfs": {"Image": "alpine"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": [],
"auto_remove": false,
"detach": true
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert!(
!opts.auto_remove,
"explicit auto_remove=false should be respected"
);
assert!(opts.detach, "explicit detach=true should be respected");
}
#[test]
fn test_box_options_roundtrip() {
let opts = BoxOptions {
auto_remove: false,
detach: true,
..Default::default()
};
let json = serde_json::to_string(&opts).unwrap();
let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(opts.auto_remove, opts2.auto_remove);
assert_eq!(opts.detach, opts2.detach);
}
#[test]
fn test_network_mode_from_str() {
assert_eq!(
"enabled".parse::<NetworkMode>().unwrap(),
NetworkMode::Enabled
);
assert_eq!(
"disabled".parse::<NetworkMode>().unwrap(),
NetworkMode::Disabled
);
}
#[test]
fn test_network_mode_from_str_rejects_invalid_values() {
let err = "broken".parse::<NetworkMode>().unwrap_err().to_string();
assert!(err.contains("invalid network.mode"));
}
#[test]
fn test_network_config_enabled_converts_to_internal_network_spec() {
let spec = NetworkSpec::try_from(NetworkConfig {
mode: NetworkMode::Enabled,
allow_net: vec!["example.com".to_string()],
})
.unwrap();
match spec {
NetworkSpec::Enabled { allow_net } => {
assert_eq!(allow_net, vec!["example.com".to_string()]);
}
NetworkSpec::Disabled => panic!("expected enabled network spec"),
}
}
#[test]
fn test_network_config_disabled_rejects_allow_net() {
let err = NetworkSpec::try_from(NetworkConfig {
mode: NetworkMode::Disabled,
allow_net: vec!["example.com".to_string()],
})
.unwrap_err()
.to_string();
assert!(err.contains("network.mode=\"disabled\""));
}
#[test]
fn test_network_spec_converts_to_public_network_config() {
let config = NetworkConfig::from(&NetworkSpec::Disabled);
assert_eq!(config.mode, NetworkMode::Disabled);
assert!(config.allow_net.is_empty());
}
#[test]
fn test_sanitize_auto_remove_detach_incompatible() {
let opts = BoxOptions {
auto_remove: true,
detach: true,
..Default::default()
};
let result = opts.sanitize();
assert!(
result.is_err(),
"auto_remove=true + detach=true should fail"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("incompatible"),
"Error should mention incompatibility"
);
}
#[test]
fn test_sanitize_valid_combinations() {
let opts1 = BoxOptions {
auto_remove: true,
detach: false,
..Default::default()
};
assert!(opts1.sanitize().is_ok());
let opts2 = BoxOptions {
auto_remove: false,
detach: true,
..Default::default()
};
assert!(opts2.sanitize().is_ok());
let opts3 = BoxOptions {
auto_remove: false,
detach: false,
..Default::default()
};
assert!(opts3.sanitize().is_ok());
}
#[test]
fn test_security_builder_new() {
let opts = SecurityOptionsBuilder::new().build();
#[cfg(target_os = "macos")]
assert!(opts.jailer_enabled);
#[cfg(not(target_os = "macos"))]
assert!(!opts.jailer_enabled);
assert!(!opts.seccomp_enabled);
}
#[test]
fn test_security_builder_presets() {
let dev = SecurityOptionsBuilder::development().build();
assert!(!dev.jailer_enabled);
assert!(!dev.close_fds);
let std = SecurityOptionsBuilder::standard().build();
assert!(std.jailer_enabled || !cfg!(any(target_os = "linux", target_os = "macos")));
let max = SecurityOptionsBuilder::maximum().build();
assert!(max.jailer_enabled);
assert!(max.close_fds);
assert!(max.sanitize_env);
}
#[test]
fn test_security_builder_chaining() {
let opts = SecurityOptionsBuilder::standard()
.jailer_enabled(true)
.seccomp_enabled(false)
.max_open_files(2048)
.max_processes(50)
.build();
assert!(opts.jailer_enabled);
assert!(!opts.seccomp_enabled);
assert_eq!(opts.resource_limits.max_open_files, Some(2048));
assert_eq!(opts.resource_limits.max_processes, Some(50));
}
#[test]
fn test_security_builder_resource_limits() {
let opts = SecurityOptionsBuilder::new()
.max_open_files(1024)
.max_file_size_bytes(1024 * 1024)
.max_processes(100)
.max_memory_bytes(512 * 1024 * 1024)
.max_cpu_time_seconds(300)
.build();
assert_eq!(opts.resource_limits.max_open_files, Some(1024));
assert_eq!(opts.resource_limits.max_file_size, Some(1024 * 1024));
assert_eq!(opts.resource_limits.max_processes, Some(100));
assert_eq!(opts.resource_limits.max_memory, Some(512 * 1024 * 1024));
assert_eq!(opts.resource_limits.max_cpu_time, Some(300));
}
#[test]
fn test_security_builder_env_allowlist() {
let opts = SecurityOptionsBuilder::new()
.env_allowlist(vec!["FOO".to_string()])
.allow_env("BAR")
.allow_env("BAZ")
.build();
assert_eq!(opts.env_allowlist.len(), 3);
assert!(opts.env_allowlist.contains(&"FOO".to_string()));
assert!(opts.env_allowlist.contains(&"BAR".to_string()));
assert!(opts.env_allowlist.contains(&"BAZ".to_string()));
}
#[test]
fn test_security_builder_via_security_options() {
let opts = SecurityOptions::builder().jailer_enabled(true).build();
assert!(opts.jailer_enabled);
}
#[test]
fn test_box_options_cmd_default_is_none() {
let opts = BoxOptions::default();
assert!(opts.cmd.is_none());
}
#[test]
fn test_box_options_user_default_is_none() {
let opts = BoxOptions::default();
assert!(opts.user.is_none());
}
#[test]
fn test_box_options_cmd_serde_roundtrip() {
let opts = BoxOptions {
cmd: Some(vec!["--flag".to_string(), "value".to_string()]),
user: Some("1000:1000".to_string()),
..Default::default()
};
let json = serde_json::to_string(&opts).unwrap();
let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(
opts2.cmd,
Some(vec!["--flag".to_string(), "value".to_string()])
);
assert_eq!(opts2.user, Some("1000:1000".to_string()));
}
#[test]
fn test_box_options_cmd_serde_missing_defaults_to_none() {
let json = r#"{
"rootfs": {"Image": "alpine:latest"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": []
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert!(
opts.cmd.is_none(),
"cmd should default to None when missing from JSON"
);
assert!(
opts.user.is_none(),
"user should default to None when missing from JSON"
);
}
#[test]
fn test_box_options_cmd_explicit_in_json() {
let json = r#"{
"rootfs": {"Image": "docker:dind"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": [],
"cmd": ["--iptables=false"],
"user": "1000:1000"
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert_eq!(opts.cmd, Some(vec!["--iptables=false".to_string()]));
assert_eq!(opts.user, Some("1000:1000".to_string()));
}
#[test]
fn test_box_options_entrypoint_default_is_none() {
let opts = BoxOptions::default();
assert!(opts.entrypoint.is_none());
}
#[test]
fn test_box_options_entrypoint_serde_roundtrip() {
let opts = BoxOptions {
entrypoint: Some(vec!["dockerd".to_string()]),
cmd: Some(vec!["--iptables=false".to_string()]),
..Default::default()
};
let json = serde_json::to_string(&opts).unwrap();
let opts2: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(opts2.entrypoint, Some(vec!["dockerd".to_string()]));
assert_eq!(opts2.cmd, Some(vec!["--iptables=false".to_string()]));
}
#[test]
fn test_box_options_entrypoint_missing_defaults_to_none() {
let json = r#"{
"rootfs": {"Image": "alpine:latest"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": []
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert!(
opts.entrypoint.is_none(),
"entrypoint should default to None when missing from JSON"
);
}
#[test]
fn test_box_options_entrypoint_explicit_in_json() {
let json = r#"{
"rootfs": {"Image": "docker:dind"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": [],
"entrypoint": ["dockerd"],
"cmd": ["--iptables=false"]
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert_eq!(opts.entrypoint, Some(vec!["dockerd".to_string()]));
assert_eq!(opts.cmd, Some(vec!["--iptables=false".to_string()]));
}
fn test_secret() -> Secret {
Secret {
name: "openai".to_string(),
hosts: vec!["api.openai.com".to_string()],
placeholder: "<BOXLITE_SECRET:openai>".to_string(),
value: "sk-test-super-secret-key-12345".to_string(),
}
}
#[test]
fn test_secret_serde_roundtrip() {
let secret = test_secret();
let json = serde_json::to_string(&secret).unwrap();
let deserialized: Secret = serde_json::from_str(&json).unwrap();
assert_eq!(secret, deserialized);
assert!(json.contains("sk-test-super-secret-key-12345"));
}
#[test]
fn test_secret_env_key_valid_names() {
let cases = [
("openai", "BOXLITE_SECRET_OPENAI"),
("my_key", "BOXLITE_SECRET_MY_KEY"),
("KEY123", "BOXLITE_SECRET_KEY123"),
("a-b-c", "BOXLITE_SECRET_A_B_C"), ];
for (name, expected) in cases {
let secret = Secret {
name: name.into(),
hosts: vec![],
placeholder: String::new(),
value: String::new(),
};
assert_eq!(secret.env_key(), expected, "name={name:?}");
}
}
#[test]
fn test_secret_env_key_sanitizes_invalid_names() {
let cases = [
("my key", "BOXLITE_SECRET_MY_KEY"), ("a/b/c", "BOXLITE_SECRET_A_B_C"), ("", "BOXLITE_SECRET__UNNAMED"), ("café", "BOXLITE_SECRET_CAF_"), ];
for (name, expected) in cases {
let secret = Secret {
name: name.into(),
hosts: vec![],
placeholder: String::new(),
value: String::new(),
};
assert_eq!(secret.env_key(), expected, "name={name:?}");
}
}
#[test]
fn test_secret_debug_redacts_value() {
let secret = test_secret();
let debug_output = format!("{:?}", secret);
assert!(
!debug_output.contains("sk-test-super-secret-key-12345"),
"Debug output must not contain the secret value"
);
assert!(
debug_output.contains("[REDACTED]"),
"Debug output must contain [REDACTED]"
);
assert!(
debug_output.contains("openai"),
"Debug output should contain the secret name"
);
}
#[test]
fn test_secret_display_redacts_value() {
let secret = test_secret();
let display_output = format!("{}", secret);
assert!(
!display_output.contains("sk-test-super-secret-key-12345"),
"Display output must not contain the secret value"
);
assert!(
display_output.contains("[REDACTED]"),
"Display output must contain [REDACTED]"
);
}
#[test]
fn test_secret_serde_json_fields() {
let secret = test_secret();
let value = serde_json::to_value(&secret).unwrap();
assert!(value.get("name").unwrap().is_string());
assert!(value.get("hosts").unwrap().is_array());
assert!(value.get("placeholder").unwrap().is_string());
assert!(value.get("value").unwrap().is_string());
assert_eq!(value.get("hosts").unwrap().as_array().unwrap().len(), 1);
}
#[test]
fn test_box_options_with_secrets_default() {
let opts = BoxOptions::default();
assert!(opts.secrets.is_empty(), "secrets should default to empty");
}
#[test]
fn test_box_options_with_secrets_serde() {
let opts = BoxOptions {
secrets: vec![test_secret()],
..Default::default()
};
let json = serde_json::to_string(&opts).unwrap();
let deserialized: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.secrets.len(), 1);
assert_eq!(deserialized.secrets[0], test_secret());
}
#[test]
fn test_box_options_secrets_in_json() {
let opts = BoxOptions {
secrets: vec![
test_secret(),
Secret {
name: "anthropic".to_string(),
hosts: vec!["api.anthropic.com".to_string()],
placeholder: "<BOXLITE_SECRET:anthropic>".to_string(),
value: "sk-ant-secret".to_string(),
},
],
..Default::default()
};
let json = serde_json::to_string(&opts).unwrap();
assert!(
json.contains("\"secrets\""),
"JSON must contain secrets key"
);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let secrets_arr = value.get("secrets").unwrap().as_array().unwrap();
assert_eq!(secrets_arr.len(), 2);
}
#[test]
fn test_box_options_secrets_missing_from_json_defaults_empty() {
let json = r#"{
"rootfs": {"Image": "alpine:latest"},
"env": [],
"volumes": [],
"network": {"Enabled": {"allow_net": []}},
"ports": []
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert!(
opts.secrets.is_empty(),
"secrets should default to empty when missing from JSON"
);
}
#[test]
fn test_security_builder_non_consuming() {
let mut builder = SecurityOptionsBuilder::standard();
builder.max_open_files(1024);
let opts1 = builder.build();
let opts2 = builder.max_processes(50).build();
assert_eq!(opts1.resource_limits.max_open_files, Some(1024));
assert_eq!(opts2.resource_limits.max_open_files, Some(1024));
assert!(opts1.resource_limits.max_processes.is_none());
assert_eq!(opts2.resource_limits.max_processes, Some(50));
}
}