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::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use crate::disk::constants::qcow2::{DEFAULT_DISK_SIZE_GB, FSIZE_DISK_MULTIPLIER};
use crate::runtime::advanced_options::AdvancedBoxOptions;
use crate::runtime::types::Bytes;
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,
#[serde(default)]
pub inbound_network: NetworkSpec,
pub ports: Vec<PortSpec>,
#[deprecated(note = "use auto_delete instead")]
#[serde(default = "default_auto_remove")]
pub auto_remove: bool,
#[serde(default)]
pub auto_stop: Option<u32>,
#[serde(default)]
pub auto_delete: Option<u32>,
#[serde(default)]
pub auto_resume: Option<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 tty: bool,
#[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
}
#[allow(deprecated)]
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(),
inbound_network: NetworkSpec::default(),
ports: Vec::new(),
auto_remove: default_auto_remove(),
auto_stop: None,
auto_delete: None,
auto_resume: None,
detach: default_detach(),
advanced: AdvancedBoxOptions::default(),
entrypoint: None,
cmd: None,
user: None,
tty: false,
secrets: Vec::new(),
}
}
}
impl BoxOptions {
#[allow(deprecated)]
pub(crate) fn effective_auto_delete(&self) -> u32 {
self.auto_delete
.unwrap_or_else(|| u32::from(self.auto_remove))
}
pub(crate) fn removes_on_stop(&self) -> bool {
self.effective_auto_delete() > 0
}
pub(crate) fn sanitize_common(&self) -> BoxliteResult<()> {
if self.removes_on_stop() && self.detach {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"remove-on-stop is incompatible with detach=true. Detached boxes should use \
auto_delete=0 (or deprecated 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(),
));
}
self.advanced.validate_privileged_capability_conflict()?;
if let Some(capabilities) = self.advanced.capabilities() {
capabilities.validate()?;
}
if matches!(self.network, NetworkSpec::Disabled) && !self.ports.is_empty() {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"ports require network.outbound.mode=\"enabled\"".to_string(),
));
}
let rate_limit = &self.advanced.network_rate_limit;
rate_limit.validate()?;
if matches!(self.network, NetworkSpec::Disabled) && !rate_limit.is_unlimited() {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"advanced.network_rate_limit requires network.outbound.mode=\"enabled\" — \
there is no interface to shape when the network is disabled"
.to_string(),
));
}
if matches!(&self.inbound_network, NetworkSpec::Enabled { allow_net } if !allow_net.is_empty())
{
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"inbound.allow_net is not supported yet; remove it \
(inbound access is controlled by mode only)"
.to_string(),
));
}
if !self.advanced.security.network_enabled
&& matches!(self.network, NetworkSpec::Enabled { .. })
{
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"advanced.security.network_enabled=false does not disable guest networking — \
it only drops the host sandbox's network grants. Set network.mode=\"disabled\" \
to create a box with no network interface, or leave \
advanced.security.network_enabled at its default."
.to_string(),
));
}
for port in &self.ports {
port.validate_publishable()?;
}
for volume in &self.volumes {
volume.validate()?;
}
Ok(())
}
pub(crate) fn sanitize_persisted(&self) -> BoxliteResult<()> {
self.sanitize_common()?;
if let Some(kernel) = &self.advanced.kernel {
kernel.sanitize_persisted()?;
}
Ok(())
}
pub fn sanitize(&mut self) -> BoxliteResult<()> {
self.sanitize_common()?;
if let Some(kernel) = &self.advanced.kernel {
kernel.sanitize()?;
}
self.advanced.security.resource_limits.max_file_size = Some(
Bytes::from_gib(
self.disk_size_gb
.unwrap_or(DEFAULT_DISK_SIZE_GB)
.saturating_mul(FSIZE_DISK_MULTIPLIER),
)
.as_bytes(),
);
Ok(())
}
}
#[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 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_volume: Option<String>,
#[serde(default)]
pub host_path: String,
pub guest_path: String,
pub read_only: bool,
}
impl VolumeSpec {
pub fn bind_mount(host_path: impl Into<String>, guest_path: impl Into<String>) -> Self {
Self {
managed_volume: None,
host_path: host_path.into(),
guest_path: guest_path.into(),
read_only: false,
}
}
pub fn managed_volume(volume: impl Into<String>, guest_path: impl Into<String>) -> Self {
Self {
managed_volume: Some(volume.into()),
host_path: String::new(),
guest_path: guest_path.into(),
read_only: false,
}
}
pub fn validate(&self) -> BoxliteResult<()> {
let guest_path = &self.guest_path;
match &self.managed_volume {
Some(volume) if !self.host_path.is_empty() => Err(
boxlite_shared::errors::BoxliteError::InvalidArgument(format!(
"volume mount {guest_path:?} sets both managed_volume ({volume:?}) and \
host_path; use exactly one"
)),
),
Some(volume) if volume.trim().is_empty() => Err(
boxlite_shared::errors::BoxliteError::InvalidArgument(format!(
"volume mount {guest_path:?} has an empty managed_volume; pass a volume id \
or name"
)),
),
Some(_) => Ok(()),
None if self.host_path.is_empty() => Err(
boxlite_shared::errors::BoxliteError::InvalidArgument(format!(
"volume mount {guest_path:?} needs a managed_volume (volume id or name) or \
a host_path"
)),
),
None => Ok(()),
}
}
}
#[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::InvalidArgument(
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 {
#[serde(default)]
pub outbound: OutboundNetworkConfig,
#[serde(default)]
pub inbound: InboundNetworkConfig,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutboundNetworkConfig {
pub mode: NetworkMode,
#[serde(default)]
pub allow_net: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InboundNetworkConfig {
pub mode: NetworkMode,
#[serde(default)]
pub allow_net: Vec<String>,
}
impl TryFrom<OutboundNetworkConfig> for NetworkSpec {
type Error = boxlite_shared::errors::BoxliteError;
fn try_from(config: OutboundNetworkConfig) -> 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::InvalidArgument(
"network.outbound.mode=\"disabled\" is incompatible with allow_net. \
Remove allow_net or use mode=\"enabled\"."
.to_string(),
))
}
NetworkMode::Disabled => Ok(Self::Disabled),
}
}
}
impl TryFrom<InboundNetworkConfig> for NetworkSpec {
type Error = boxlite_shared::errors::BoxliteError;
fn try_from(config: InboundNetworkConfig) -> Result<Self, Self::Error> {
if !config.allow_net.is_empty() {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"inbound.allow_net is not supported yet; remove it \
(inbound access is controlled by mode only)"
.to_string(),
));
}
Ok(match config.mode {
NetworkMode::Enabled => Self::Enabled {
allow_net: Vec::new(),
},
NetworkMode::Disabled => Self::Disabled,
})
}
}
impl From<&NetworkSpec> for OutboundNetworkConfig {
fn from(spec: &NetworkSpec) -> Self {
let (mode, allow_net) = match spec {
NetworkSpec::Enabled { allow_net } => (NetworkMode::Enabled, allow_net.clone()),
NetworkSpec::Disabled => (NetworkMode::Disabled, Vec::new()),
};
Self { mode, allow_net }
}
}
impl From<&NetworkSpec> for InboundNetworkConfig {
fn from(spec: &NetworkSpec) -> Self {
let (mode, allow_net) = match spec {
NetworkSpec::Enabled { allow_net } => (NetworkMode::Enabled, allow_net.clone()),
NetworkSpec::Disabled => (NetworkMode::Disabled, Vec::new()),
};
Self { mode, allow_net }
}
}
impl NetworkConfig {
pub fn from_specs(outbound: &NetworkSpec, inbound: &NetworkSpec) -> Self {
Self {
outbound: outbound.into(),
inbound: inbound.into(),
}
}
}
#[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, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum PortProtocol {
#[default]
#[serde(rename = "tcp", alias = "Tcp")]
Tcp,
#[serde(rename = "udp", alias = "Udp")]
Udp,
}
fn default_protocol() -> PortProtocol {
PortProtocol::Tcp
}
#[derive(Clone, Debug, Default, PartialEq, Eq, 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>, }
impl PortSpec {
pub(crate) fn validate_publishable(&self) -> BoxliteResult<SocketAddr> {
if self.guest_port == 0 {
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"guest port must be in range 1-65535".to_string(),
));
}
if matches!(self.protocol, PortProtocol::Udp) {
return Err(boxlite_shared::errors::BoxliteError::Unsupported(
"UDP port forwarding is not implemented; use TCP".to_string(),
));
}
let host_ip = match self.host_ip.as_deref() {
None => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
Some(host_ip) => host_ip.parse::<IpAddr>().map_err(|_| {
boxlite_shared::errors::BoxliteError::InvalidArgument(format!(
"invalid port host_ip {host_ip:?}; expected an IPv4 or IPv6 address"
))
})?,
};
Ok(SocketAddr::new(host_ip, self.host_port.unwrap_or(0)))
}
}
pub(crate) fn normalize_legacy_ports(ports: &mut Vec<PortSpec>) -> usize {
let mut changed = 0;
let mut by_host_port = std::collections::BTreeMap::new();
for (index, mut port) in ports.drain(..).enumerate() {
let original = port.clone();
let host_port = port.host_port.unwrap_or(port.guest_port);
port.host_port = Some(host_port);
port.protocol = PortProtocol::Tcp;
port.host_ip = None;
if port != original {
changed += 1;
}
if by_host_port.insert(host_port, (index, port)).is_some() {
changed += 1;
}
}
let mut normalized: Vec<_> = by_host_port.into_values().collect();
normalized.sort_by_key(|(index, _)| *index);
*ports = normalized.into_iter().map(|(_, port)| port).collect();
changed
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ArchiveImportPolicy {
Trusted,
UntrustedRemote,
}
#[derive(Debug, Clone)]
pub struct BoxArchive {
path: PathBuf,
import_policy: ArchiveImportPolicy,
}
impl BoxArchive {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
import_policy: ArchiveImportPolicy::Trusted,
}
}
pub fn from_untrusted_upload(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
import_policy: ArchiveImportPolicy::UntrustedRemote,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub(crate) fn import_policy(&self) -> ArchiveImportPolicy {
self.import_policy
}
}
#[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::experimental::custom_kernel::{KernelFormat, KernelOptions};
use crate::runtime::advanced_options::{
ContainerCapabilities, NetworkRateLimit, SecurityOptions, SecurityOptionsBuilder,
};
use crate::runtime::types::Bytes;
#[test]
fn legacy_ports_keep_old_same_port_and_last_write_wins_semantics() {
let mut ports = vec![
PortSpec {
host_port: None,
guest_port: 3000,
protocol: PortProtocol::Tcp,
host_ip: Some("127.0.0.1".to_string()),
},
PortSpec {
host_port: Some(3000),
guest_port: 4000,
protocol: PortProtocol::Udp,
host_ip: None,
},
];
let changed = normalize_legacy_ports(&mut ports);
assert_eq!(
ports,
vec![PortSpec {
host_port: Some(3000),
guest_port: 4000,
protocol: PortProtocol::Tcp,
host_ip: None,
}]
);
assert_eq!(
changed, 3,
"two rewritten mappings plus one dropped duplicate"
);
}
#[test]
fn already_canonical_ports_are_left_alone() {
let mut ports = vec![PortSpec {
host_port: Some(18080),
guest_port: 80,
protocol: PortProtocol::Tcp,
host_ip: None,
}];
let canonical = ports.clone();
assert_eq!(normalize_legacy_ports(&mut ports), 0);
assert_eq!(ports, canonical);
}
#[test]
fn port_protocol_serializes_lowercase_and_reads_legacy_names() {
assert_eq!(
serde_json::to_string(&PortProtocol::Tcp).unwrap(),
r#""tcp""#
);
assert_eq!(
serde_json::from_str::<PortProtocol>(r#""Tcp""#).unwrap(),
PortProtocol::Tcp
);
assert_eq!(
serde_json::from_str::<PortProtocol>(r#""Udp""#).unwrap(),
PortProtocol::Udp
);
}
#[test]
#[allow(deprecated)]
fn test_box_options_defaults() {
let opts = BoxOptions::default();
assert!(opts.removes_on_stop());
assert!(
opts.auto_remove,
"auto_remove should keep its legacy default"
);
assert!(!opts.detach, "detach should default to false");
assert!(
opts.advanced.capabilities().is_none(),
"advanced capabilities should default to unspecified"
);
}
#[test]
fn box_options_capabilities_serde_roundtrip() {
let json = r#"{
"advanced": {
"capabilities": {
"add": ["SYS_ADMIN", "CAP_NET_ADMIN"],
"drop": ["NET_RAW"]
}
}
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
let capabilities = opts.advanced.capabilities().expect("capabilities set");
assert_eq!(capabilities.add, ["SYS_ADMIN", "CAP_NET_ADMIN"]);
assert_eq!(capabilities.drop, ["NET_RAW"]);
let serialized = serde_json::to_string(&opts).unwrap();
let roundtripped: BoxOptions = serde_json::from_str(&serialized).unwrap();
assert_eq!(
roundtripped.advanced.capabilities(),
opts.advanced.capabilities()
);
}
#[test]
fn box_options_omits_capabilities_key_when_unspecified() {
let json = serde_json::to_string(&BoxOptions::default()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let advanced = parsed.get("advanced").unwrap();
assert!(
!advanced.as_object().unwrap().contains_key("capabilities"),
"unspecified capabilities must omit the key, not serialize null: {advanced}"
);
}
fn rate_limited(tx_kbps: Option<u64>, rx_kbps: Option<u64>) -> AdvancedBoxOptions {
let mut advanced = AdvancedBoxOptions::default();
advanced.network_rate_limit = NetworkRateLimit { tx_kbps, rx_kbps };
advanced
}
#[test]
fn box_options_omits_network_rate_limit_key_when_unlimited() {
let json = serde_json::to_string(&BoxOptions::default()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let advanced = parsed.get("advanced").unwrap();
assert!(
!advanced
.as_object()
.unwrap()
.contains_key("network_rate_limit"),
"an unlimited rate limit must omit the key: {advanced}"
);
let capped = BoxOptions {
advanced: rate_limited(Some(10_000), None),
..Default::default()
};
let json = serde_json::to_string(&capped).unwrap();
let back: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(back.advanced.network_rate_limit.tx_kbps, Some(10_000));
assert_eq!(back.advanced.network_rate_limit.rx_kbps, None);
}
#[test]
fn box_options_sanitize_rejects_rate_limit_without_a_network() {
let mut opts = BoxOptions {
network: NetworkSpec::Disabled,
advanced: rate_limited(Some(10_000), None),
..Default::default()
};
let error = opts
.sanitize()
.expect_err("a rate limit with the network disabled must be rejected");
assert!(
error.to_string().contains("advanced.network_rate_limit"),
"error should name the offending option, got: {error}"
);
}
#[test]
fn box_options_sanitize_allows_zero_rate_limit_without_a_network() {
let mut opts = BoxOptions {
network: NetworkSpec::Disabled,
advanced: rate_limited(Some(0), Some(0)),
..Default::default()
};
opts.sanitize()
.expect("a zero cap is not a cap and must not conflict with a disabled network");
}
#[test]
fn box_options_sanitize_accepts_rate_limit_with_a_network() {
let mut opts = BoxOptions {
advanced: rate_limited(Some(10_000), Some(20_000)),
..Default::default()
};
opts.sanitize()
.expect("a cap with the default enabled network must be accepted");
}
#[test]
fn box_options_sanitize_accepts_rate_limit_up_to_bridge_maximum() {
for kbps in [None, Some(0), Some(NetworkRateLimit::MAX_KBPS)] {
let mut opts = BoxOptions {
advanced: rate_limited(kbps, kbps),
..Default::default()
};
opts.sanitize_common().unwrap();
opts.sanitize_persisted().unwrap();
opts.sanitize().unwrap();
}
}
#[test]
fn box_options_sanitize_rejects_rate_limit_above_bridge_maximum() {
for field in ["tx_kbps", "rx_kbps"] {
for kbps in [NetworkRateLimit::MAX_KBPS + 1, u64::MAX] {
let mut opts = BoxOptions {
advanced: rate_limited(
(field == "tx_kbps").then_some(kbps),
(field == "rx_kbps").then_some(kbps),
),
..Default::default()
};
for result in [
opts.sanitize_common(),
opts.sanitize_persisted(),
opts.sanitize(),
] {
let error = result.expect_err("oversized rate limit must fail before startup");
assert!(matches!(
error,
boxlite_shared::errors::BoxliteError::InvalidArgument(_)
));
let message = error.to_string();
assert!(message.contains(&format!("advanced.network_rate_limit.{field}")));
assert!(message.contains(&kbps.to_string()));
assert!(message.contains(&NetworkRateLimit::MAX_KBPS.to_string()));
}
}
}
}
#[test]
fn box_options_sanitize_accepts_valid_capability_names() {
let mut advanced = AdvancedBoxOptions::default();
advanced
.set_capabilities(Some(ContainerCapabilities {
add: vec!["sys_admin".into(), "CAP_NET_ADMIN".into()],
drop: vec!["NET_RAW".into()],
}))
.unwrap();
let mut opts = BoxOptions {
advanced,
..Default::default()
};
opts.sanitize()
.expect("Docker-style capability names should be accepted");
}
#[test]
fn box_options_sanitize_accepts_future_capability_names() {
let mut advanced = AdvancedBoxOptions::default();
advanced
.set_capabilities(Some(ContainerCapabilities {
add: vec!["FUTURE_KERNEL_FEATURE".into()],
..Default::default()
}))
.unwrap();
let mut opts = BoxOptions {
advanced,
..Default::default()
};
opts.sanitize()
.expect("the guest runtime, not the host SDK, owns the supported capability list");
}
#[test]
fn box_options_sanitize_rejects_malformed_capability_names() {
let malformed = [
ContainerCapabilities {
add: vec!["".into()],
..Default::default()
},
ContainerCapabilities {
drop: vec!["NET-ADMIN".into()],
..Default::default()
},
ContainerCapabilities {
add: vec!["123".into()],
..Default::default()
},
ContainerCapabilities {
add: vec!["ß".into()],
..Default::default()
},
];
for capabilities in malformed {
let mut advanced = AdvancedBoxOptions::default();
advanced.set_capabilities(Some(capabilities)).unwrap();
let mut opts = BoxOptions {
advanced,
..Default::default()
};
let err = opts
.sanitize()
.expect_err("malformed capability should be rejected");
assert_eq!(err.http().0, 400);
let err = err.to_string();
assert!(
err.contains("empty")
|| err.contains("NET-ADMIN")
|| err.contains("123")
|| err.contains("ß"),
"error should identify the malformed capability, got: {err}"
);
}
}
#[test]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn custom_kernel_configuration_roundtrips() {
let temp = tempfile::tempdir().unwrap();
let kernel = temp.path().join("vmlinux");
let initramfs = temp.path().join("initramfs.img");
#[cfg(target_arch = "x86_64")]
std::fs::write(&kernel, b"\x7fELFcustom kernel").unwrap();
#[cfg(target_arch = "aarch64")]
std::fs::write(&kernel, b"arm64-header\x1f\x8b\x08custom kernel").unwrap();
std::fs::write(&initramfs, b"custom initramfs").unwrap();
#[cfg(target_arch = "x86_64")]
let format = KernelFormat::Elf;
#[cfg(target_arch = "aarch64")]
let format = KernelFormat::PeGz;
let mut advanced = AdvancedBoxOptions::default();
advanced.kernel = Some(
KernelOptions::new(&kernel)
.with_format(format)
.with_initramfs(&initramfs)
.with_command_line("console=ttyS0 panic=-1"),
);
let mut opts = BoxOptions {
advanced,
..Default::default()
};
opts.sanitize().unwrap();
let json = serde_json::to_string(&opts).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value.get("kernel").is_none());
assert!(value["advanced"]["kernel"].is_object());
let restored: BoxOptions = serde_json::from_str(&json).unwrap();
assert_eq!(restored.advanced.kernel, opts.advanced.kernel);
}
#[test]
fn host_network_grants_off_with_guest_network_on_is_rejected() {
let mut advanced = AdvancedBoxOptions::default();
advanced.security = SecurityOptions {
network_enabled: false,
..SecurityOptions::default()
};
let opts = BoxOptions {
advanced,
..Default::default()
};
assert!(
matches!(opts.network, NetworkSpec::Enabled { .. }),
"guard assumes the default network mode is enabled"
);
let error = opts.sanitize_common().unwrap_err().to_string();
assert!(
error.contains("network.mode=\"disabled\""),
"error must name the option that disables guest networking: {error}"
);
assert!(
error.contains("advanced.security.network_enabled"),
"error must name the option the caller actually set: {error}"
);
}
#[test]
fn network_disabled_pairs_with_either_host_grant_setting() {
for network_enabled in [true, false] {
let mut advanced = AdvancedBoxOptions::default();
advanced.security = SecurityOptions {
network_enabled,
..SecurityOptions::default()
};
let opts = BoxOptions {
network: NetworkSpec::Disabled,
advanced,
..Default::default()
};
opts.sanitize_common().unwrap_or_else(|e| {
panic!("network.mode=disabled + network_enabled={network_enabled} rejected: {e}")
});
}
}
#[test]
fn custom_kernel_must_be_a_file() {
let mut advanced = AdvancedBoxOptions::default();
advanced.kernel = Some(KernelOptions::new("/definitely/missing/vmlinux"));
let mut opts = BoxOptions {
advanced,
..Default::default()
};
let error = opts.sanitize().unwrap_err().to_string();
assert!(error.contains("custom kernel"), "unexpected error: {error}");
assert!(error.contains("regular file"), "unexpected error: {error}");
}
#[test]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
fn persisted_custom_kernel_boot_assets_do_not_require_original_source() {
#[cfg(target_arch = "x86_64")]
let format = KernelFormat::Elf;
#[cfg(target_arch = "aarch64")]
let format = KernelFormat::PeGz;
let mut advanced = AdvancedBoxOptions::default();
advanced.kernel = Some(
KernelOptions::new("/source/removed/after-create")
.with_format(format)
.with_command_line("console=ttyS0"),
);
let mut opts = BoxOptions {
advanced,
..Default::default()
};
opts.sanitize_persisted().unwrap();
assert!(opts.sanitize().is_err());
}
#[test]
fn custom_initramfs_must_be_a_file() {
let temp = tempfile::tempdir().unwrap();
let kernel = temp.path().join("vmlinux");
std::fs::write(&kernel, b"\x7fELFcustom kernel").unwrap();
let mut advanced = AdvancedBoxOptions::default();
advanced.kernel = Some(
KernelOptions::new(&kernel)
.with_format(KernelFormat::Elf)
.with_initramfs(temp.path().join("missing-initramfs")),
);
let mut opts = BoxOptions {
advanced,
..Default::default()
};
let error = opts.sanitize().unwrap_err().to_string();
assert!(error.contains("initramfs"), "unexpected error: {error}");
assert!(error.contains("regular file"), "unexpected error: {error}");
}
#[test]
#[allow(deprecated)]
fn explicit_auto_delete_takes_precedence_over_auto_remove() {
let keep = BoxOptions {
auto_remove: true,
auto_delete: Some(0),
..Default::default()
};
assert!(!keep.removes_on_stop());
let remove = BoxOptions {
auto_remove: false,
auto_delete: Some(60),
..Default::default()
};
assert!(remove.removes_on_stop());
let legacy_keep = BoxOptions {
auto_remove: false,
auto_delete: None,
..Default::default()
};
assert!(!legacy_keep.removes_on_stop());
}
#[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_eq!(opts.auto_delete, None);
assert!(opts.removes_on_stop());
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_delete": 0,
"detach": true
}"#;
let opts: BoxOptions = serde_json::from_str(json).unwrap();
assert_eq!(opts.auto_delete, Some(0));
assert!(opts.detach, "explicit detach=true should be respected");
}
#[test]
fn nested_virtualization_option_roundtrips() {
let stored: BoxOptions =
serde_json::from_str(r#"{"advanced":{"nested_virtualization":true}}"#).unwrap();
assert!(stored.advanced.nested_virtualization);
assert_eq!(
serde_json::to_value(stored).unwrap()["advanced"]["nested_virtualization"],
serde_json::Value::Bool(true)
);
let legacy: BoxOptions = serde_json::from_str("{}").unwrap();
assert!(!legacy.advanced.nested_virtualization);
}
#[test]
fn privileged_option_roundtrips() {
let stored: BoxOptions =
serde_json::from_str(r#"{"advanced":{"privileged":true}}"#).unwrap();
assert!(stored.advanced.privileged);
assert_eq!(
serde_json::to_value(stored).unwrap()["advanced"]["privileged"],
serde_json::Value::Bool(true)
);
let legacy: BoxOptions = serde_json::from_str("{}").unwrap();
assert!(!legacy.advanced.privileged);
}
#[test]
fn privileged_rejects_explicit_capability_overrides() {
let options: BoxOptions = serde_json::from_str(
r#"{"advanced":{"privileged":true,"capabilities":{"add":["SYS_ADMIN"],"drop":["NET_RAW"]}}}"#,
)
.unwrap();
let error = options
.advanced
.validate_privileged_capability_conflict()
.expect_err("privileged capability overrides must be rejected");
assert!(error.to_string().contains("cannot be combined"));
}
#[test]
fn privileged_canonical_capability_shape_remains_accepted() {
let options: BoxOptions = serde_json::from_str(
r#"{"advanced":{"privileged":true,"capabilities":{"add":["ALL"],"drop":[]}}}"#,
)
.unwrap();
options
.advanced
.validate_privileged_capability_conflict()
.expect("canonical privileged shape should be accepted");
let capabilities = options.advanced.capabilities().expect("capabilities set");
assert_eq!(capabilities.add, ["ALL"]);
assert!(capabilities.drop.is_empty());
}
#[test]
fn privileged_security_is_resolved_before_guest_init() {
let mut advanced = crate::AdvancedBoxOptions::default();
advanced.privileged = true;
let options = BoxOptions {
advanced,
..Default::default()
};
let resolved = options
.advanced
.resolve_container_security()
.expect("privileged security should resolve");
assert!(resolved.linux.readonly_paths.is_empty());
assert!(!resolved.mount.options.contains(&"rro".to_string()));
assert_eq!(resolved.capabilities.add, ["ALL"]);
assert!(resolved.capabilities.drop.is_empty());
}
#[test]
fn test_box_options_roundtrip() {
let opts = BoxOptions {
auto_delete: Some(0),
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_delete, opts2.auto_delete);
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(OutboundNetworkConfig {
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_inbound_config_converts_to_internal_spec() {
let spec = NetworkSpec::try_from(InboundNetworkConfig {
mode: NetworkMode::Disabled,
allow_net: Vec::new(),
})
.unwrap();
assert!(matches!(spec, NetworkSpec::Disabled));
}
#[test]
fn test_network_config_rejects_unsupported_inbound_allow_net() {
let err = NetworkSpec::try_from(InboundNetworkConfig {
mode: NetworkMode::Enabled,
allow_net: vec!["10.0.0.0/8".to_string()],
})
.unwrap_err();
assert!(err.to_string().contains("not supported yet"));
assert!(
matches!(
err,
boxlite_shared::errors::BoxliteError::InvalidArgument(_)
),
"expected InvalidArgument (→ HTTP 400), got {err:?}"
);
}
#[test]
fn test_sanitize_rejects_unsupported_inbound_allow_net() {
let mut opts = BoxOptions {
inbound_network: NetworkSpec::Enabled {
allow_net: vec!["10.0.0.0/8".to_string()],
},
..Default::default()
};
let err = opts.sanitize().unwrap_err();
assert!(err.to_string().contains("not supported yet"));
assert!(
matches!(
err,
boxlite_shared::errors::BoxliteError::InvalidArgument(_)
),
"expected InvalidArgument (→ HTTP 400), got {err:?}"
);
}
#[test]
fn test_sanitize_rejects_ambiguous_volume_origin() {
let mut both = BoxOptions {
volumes: vec![VolumeSpec {
managed_volume: Some("my-data".into()),
host_path: "/tmp/data".into(),
guest_path: "/data".into(),
read_only: false,
}],
..Default::default()
};
let err = both.sanitize().unwrap_err().to_string();
assert!(err.contains("exactly one"), "{err}");
let mut neither = BoxOptions {
volumes: vec![VolumeSpec {
guest_path: "/data".into(),
..Default::default()
}],
..Default::default()
};
let err = neither.sanitize().unwrap_err().to_string();
assert!(err.contains("volume id or name"), "{err}");
let mut empty_reference = BoxOptions {
volumes: vec![VolumeSpec::managed_volume(" ", "/data")],
..Default::default()
};
let err = empty_reference.sanitize().unwrap_err().to_string();
assert!(err.contains("empty managed_volume"), "{err}");
}
#[test]
fn legacy_volume_json_without_managed_volume_still_loads() {
let mut opts: BoxOptions = serde_json::from_str(
r#"{"volumes":[{"host_path":"/tmp/data","guest_path":"/data","read_only":true}]}"#,
)
.expect("pre-managed_volume box config must still deserialize");
let volume = &opts.volumes[0];
assert_eq!(volume.managed_volume, None);
assert_eq!(volume.host_path, "/tmp/data");
assert!(volume.read_only);
opts.sanitize().expect("a legacy host bind is still valid");
}
#[test]
fn host_volume_omits_managed_volume_key() {
let opts = BoxOptions {
volumes: vec![VolumeSpec::bind_mount("/tmp/data", "/data")],
..Default::default()
};
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&opts).unwrap()).unwrap();
let volume = &json["volumes"][0];
assert!(
!volume.as_object().unwrap().contains_key("managed_volume"),
"a host bind must omit the key, not serialize null: {volume}"
);
}
#[test]
fn test_network_config_disabled_rejects_allow_net() {
let err = NetworkSpec::try_from(OutboundNetworkConfig {
mode: NetworkMode::Disabled,
allow_net: vec!["example.com".to_string()],
})
.unwrap_err()
.to_string();
assert!(err.contains("network.outbound.mode=\"disabled\""));
}
#[test]
fn test_network_spec_converts_to_public_network_config() {
let config = NetworkConfig::from_specs(
&NetworkSpec::Disabled,
&NetworkSpec::Enabled {
allow_net: Vec::new(),
},
);
assert_eq!(config.outbound.mode, NetworkMode::Disabled);
assert!(config.outbound.allow_net.is_empty());
assert_eq!(config.inbound.mode, NetworkMode::Enabled);
}
#[test]
fn test_sanitize_remove_on_stop_detach_incompatible() {
let mut opts = BoxOptions {
auto_delete: Some(1),
detach: true,
..Default::default()
};
let err_msg = opts.sanitize().unwrap_err().to_string();
assert!(err_msg.contains("incompatible"));
}
#[test]
fn test_sanitize_valid_combinations() {
let mut remove = BoxOptions {
auto_delete: Some(1),
..Default::default()
};
assert!(remove.sanitize().is_ok());
let mut keep_detached = BoxOptions {
auto_delete: Some(0),
detach: true,
..Default::default()
};
assert!(keep_detached.sanitize().is_ok());
let mut keep_attached = BoxOptions {
auto_delete: Some(0),
..Default::default()
};
assert!(keep_attached.sanitize().is_ok());
}
#[test]
fn test_sanitize_allows_duplicate_automatic_ports() {
let duplicate = PortSpec {
host_port: None,
guest_port: 3000,
protocol: PortProtocol::Tcp,
host_ip: None,
};
let mut opts = BoxOptions {
ports: vec![
duplicate.clone(),
PortSpec {
host_port: Some(0),
host_ip: Some("0.0.0.0".to_string()),
..duplicate
},
],
..Default::default()
};
assert!(opts.sanitize().is_ok());
}
#[test]
fn test_security_builder_new() {
let opts = SecurityOptionsBuilder::new().build();
#[cfg(any(target_os = "linux", target_os = "macos"))]
assert!(opts.jailer_enabled);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
assert!(!opts.jailer_enabled);
#[cfg(target_os = "linux")]
assert!(opts.seccomp_enabled);
#[cfg(not(target_os = "linux"))]
assert!(!opts.seccomp_enabled);
}
#[test]
fn test_security_builder_presets() {
let off = SecurityOptionsBuilder::disabled().build();
assert!(!off.jailer_enabled);
assert!(!off.close_fds);
assert!(!off.sanitize_env);
assert!(off.uid.is_none());
let on = SecurityOptionsBuilder::enabled().build();
assert!(on.jailer_enabled);
assert!(on.close_fds);
assert!(on.sanitize_env);
assert_eq!(on, SecurityOptions::default(), "enabled is the default");
}
#[test]
fn deserializing_empty_equals_default() {
let from_json: SecurityOptions = serde_json::from_str("{}").unwrap();
assert_eq!(from_json, SecurityOptions::default());
}
#[test]
fn security_from_preset_canonical_names() {
use crate::runtime::advanced_options::SecurityOptions;
assert_eq!(
SecurityOptions::from_preset("enable").unwrap(),
SecurityOptions::enabled()
);
assert_eq!(
SecurityOptions::from_preset("disable").unwrap(),
SecurityOptions::disabled()
);
}
#[test]
fn security_from_preset_case_insensitive_and_synonyms() {
use crate::runtime::advanced_options::SecurityOptions;
assert_eq!(
SecurityOptions::from_preset(" ENABLE ").unwrap(),
SecurityOptions::enabled()
);
assert_eq!(
SecurityOptions::from_preset("enabled").unwrap(),
SecurityOptions::enabled()
);
assert_eq!(
SecurityOptions::from_preset("on").unwrap(),
SecurityOptions::enabled()
);
assert_eq!(
SecurityOptions::from_preset("disabled").unwrap(),
SecurityOptions::disabled()
);
assert_eq!(
SecurityOptions::from_preset("off").unwrap(),
SecurityOptions::disabled()
);
}
#[test]
fn security_from_preset_unknown_surfaces_invalid_argument() {
use crate::runtime::advanced_options::SecurityOptions;
let err = SecurityOptions::from_preset("maximum").expect_err("old preset must reject");
let msg = err.to_string();
assert!(
msg.contains("maximum"),
"rejection must echo the offending value; got {msg}"
);
assert!(
msg.contains("enable") && msg.contains("disable"),
"rejection must list the supported settings; got {msg}"
);
}
#[test]
fn security_default_is_enabled() {
use crate::runtime::advanced_options::SecurityOptions;
let direct = SecurityOptions::default();
let via_box = BoxOptions::default().advanced.security;
assert_eq!(direct, SecurityOptions::enabled());
assert_eq!(via_box, SecurityOptions::enabled());
assert!(direct.jailer_enabled);
assert!(direct.close_fds);
assert!(direct.sanitize_env);
assert_eq!(direct.uid, Some(65534));
#[cfg(target_os = "linux")]
{
assert!(direct.seccomp_enabled);
assert!(direct.new_pid_ns);
assert!(direct.chroot_enabled);
}
}
#[test]
fn test_security_builder_chaining() {
let opts = SecurityOptionsBuilder::enabled()
.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::disabled();
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));
}
#[test]
fn sanitize_scales_fsize_with_the_requested_disk() {
let mut options = BoxOptions {
disk_size_gb: Some(20),
..BoxOptions::default()
};
options.sanitize().unwrap();
assert_eq!(
options.advanced.security.resource_limits.max_file_size,
Some(Bytes::from_gib(40).as_bytes()),
"a 20 GiB box gets a 40 GiB ceiling"
);
}
#[test]
fn sanitize_falls_back_to_the_default_disk_size() {
let mut options = BoxOptions::default();
options.sanitize().unwrap();
assert_eq!(
options.advanced.security.resource_limits.max_file_size,
Some(Bytes::from_gib(20).as_bytes()),
"the default 10 GiB disk gets a 20 GiB ceiling"
);
}
}