pub mod api_client;
pub mod pool;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
#[cfg(target_os = "linux")]
use std::collections::HashMap;
use async_trait::async_trait;
#[cfg(target_os = "linux")]
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
#[cfg(target_os = "linux")]
use base64::Engine;
#[cfg(target_os = "linux")]
use tokio::io::AsyncReadExt;
#[cfg(target_os = "linux")]
use tokio::io::AsyncWriteExt;
#[cfg(target_os = "linux")]
use tokio::net::UnixListener;
#[cfg(target_os = "linux")]
use tokio::process::Child;
#[cfg(target_os = "linux")]
use tokio::sync::Mutex;
#[cfg(target_os = "linux")]
use tracing::instrument;
#[cfg(target_os = "linux")]
use uuid::Uuid;
use cellos_core::ports::{CellBackend, CellHandle, TeardownReport};
#[cfg(target_os = "linux")]
use cellos_core::EgressRule;
use cellos_core::{CellosError, ExecutionCellDocument};
#[cfg(target_os = "linux")]
use api_client::{
BootSource, Drive, FirecrackerApiClient, InstanceAction, InstanceActionType, MachineConfig,
NetworkInterface, VsockDevice,
};
#[cfg(target_os = "linux")]
const SOCKET_READY_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(target_os = "linux")]
const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(target_os = "linux")]
fn resolve_graceful_shutdown_timeout(spec: &cellos_core::ExecutionCellSpec) -> Duration {
spec.run
.as_ref()
.and_then(|r| r.limits.as_ref())
.and_then(|l| l.graceful_shutdown_seconds)
.map(Duration::from_secs)
.unwrap_or(GRACEFUL_SHUTDOWN_TIMEOUT)
}
#[cfg(target_os = "linux")]
const DEFAULT_VCPU_COUNT: u32 = 1;
#[cfg(target_os = "linux")]
const DEFAULT_MEM_SIZE_MIB: u32 = 128;
#[cfg(target_os = "linux")]
fn derive_vcpu_count(spec: &cellos_core::ExecutionCellSpec) -> u32 {
let Some(cpu_max) = spec
.run
.as_ref()
.and_then(|r| r.limits.as_ref())
.and_then(|l| l.cpu_max.as_ref())
else {
return DEFAULT_VCPU_COUNT;
};
let period = cpu_max.period_micros.unwrap_or(100_000).max(1);
let vcpus = cpu_max.quota_micros.div_ceil(period) as u32;
vcpus.clamp(1, 32)
}
pub const VSOCK_EXIT_PORT: u32 = 9000;
pub(crate) const EXIT_HMAC_KEY_LEN: usize = 32;
pub(crate) const EXIT_HMAC_TAG_LEN: usize = 32;
#[cfg(target_os = "linux")]
const EXIT_AUTHED_FRAME_LEN: usize = 4 + EXIT_HMAC_TAG_LEN;
#[cfg(target_os = "linux")]
fn generate_exit_hmac_key() -> Result<[u8; EXIT_HMAC_KEY_LEN], CellosError> {
use std::io::Read;
let mut key = [0u8; EXIT_HMAC_KEY_LEN];
let mut f = std::fs::File::open("/dev/urandom")
.map_err(|e| CellosError::Host(format!("open /dev/urandom: {e}")))?;
f.read_exact(&mut key)
.map_err(|e| CellosError::Host(format!("read /dev/urandom: {e}")))?;
Ok(key)
}
pub(crate) fn verify_exit_hmac(
key: &[u8],
exit_code_bytes: &[u8; 4],
cell_id: &str,
received_tag: &[u8],
) -> bool {
use hmac::{digest::KeyInit, Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
if received_tag.len() != EXIT_HMAC_TAG_LEN {
return false;
}
let mut mac = match HmacSha256::new_from_slice(key) {
Ok(m) => m,
Err(_) => return false,
};
mac.update(exit_code_bytes);
mac.update(cell_id.as_bytes());
mac.verify_slice(received_tag).is_ok()
}
#[cfg(target_os = "linux")]
const VSOCK_GUEST_CID: u32 = 3;
#[cfg(target_os = "linux")]
const GUEST_NIC_MAC: &str = "AA:FC:00:00:00:01";
#[cfg(target_os = "linux")]
const TAP_NAME_PREFIX: &str = "cfc-";
#[cfg(target_os = "linux")]
const NETWORK_DEFAULT_ENABLED: bool = true;
#[cfg(not(target_os = "linux"))]
const NETWORK_DEFAULT_ENABLED: bool = false;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirecrackerConfig {
pub binary_path: PathBuf,
pub kernel_image_path: PathBuf,
pub rootfs_image_path: PathBuf,
pub jailer_binary_path: Option<PathBuf>,
pub chroot_base_dir: PathBuf,
pub socket_dir: PathBuf,
pub jailer_uid: u32,
pub jailer_gid: u32,
pub scratch_dir: Option<PathBuf>,
pub manifest_path: Option<PathBuf>,
pub require_jailer: bool,
pub allow_no_manifest: bool,
pub enable_network: bool,
pub allow_no_vsock: bool,
pub no_vsock_timeout: Duration,
pub no_seccomp: bool,
}
impl FirecrackerConfig {
pub fn from_env() -> Result<Self, CellosError> {
Self::from_lookup(|key| std::env::var(key).ok())
}
pub(crate) fn from_lookup<F>(lookup: F) -> Result<Self, CellosError>
where
F: Fn(&str) -> Option<String>,
{
let cfg = Self {
binary_path: required_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_BINARY",
"firecracker VMM binary",
)?,
kernel_image_path: required_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_KERNEL_IMAGE",
"Firecracker kernel image",
)?,
rootfs_image_path: required_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_ROOTFS_IMAGE",
"Firecracker rootfs image",
)?,
jailer_binary_path: optional_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_JAILER_BINARY",
"Firecracker jailer binary",
)?,
chroot_base_dir: optional_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_CHROOT_BASE",
"Firecracker chroot base directory",
)?
.unwrap_or_else(|| PathBuf::from("/var/lib/cellos/firecracker")),
socket_dir: optional_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_SOCKET_DIR",
"Firecracker socket directory",
)?
.unwrap_or_else(|| PathBuf::from("/tmp")),
jailer_uid: lookup("CELLOS_FIRECRACKER_JAILER_UID")
.and_then(|v| v.parse().ok())
.unwrap_or(10002),
jailer_gid: lookup("CELLOS_FIRECRACKER_JAILER_GID")
.and_then(|v| v.parse().ok())
.unwrap_or(10002),
scratch_dir: optional_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_SCRATCH_DIR",
"Firecracker scratch image directory",
)?,
manifest_path: optional_absolute_path(
&lookup,
"CELLOS_FIRECRACKER_MANIFEST",
"Firecracker artifact manifest file",
)?,
require_jailer: {
let allow_no_jailer = lookup("CELLOS_FIRECRACKER_ALLOW_NO_JAILER")
.map(|v| v.trim() == "1")
.unwrap_or(false);
if allow_no_jailer {
tracing::warn!(
"CELLOS_FIRECRACKER_ALLOW_NO_JAILER=1 is set — running Firecracker WITHOUT the jailer. \
This is unsafe for production and should only be used for local development."
);
false
} else {
lookup("CELLOS_FIRECRACKER_REQUIRE_JAILER")
.map(|v| {
let t = v.trim();
!matches!(t, "0" | "false" | "FALSE" | "no" | "NO")
})
.unwrap_or(true)
}
},
allow_no_manifest: {
let primary = lookup("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST")
.map(|v| v.trim() == "1")
.unwrap_or(false);
let secondary = lookup("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY")
.map(|v| v.trim() == "1")
.unwrap_or(false);
if primary && !secondary {
return Err(CellosError::Host(
"firecracker init: CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 \
requires the paired escape-hatch flag \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 to take \
effect. Without both flags set, manifest verification \
remains mandatory (production posture). The two-flag \
handshake exists so a dev `.env` cannot accidentally \
disable digest verification in production — set both \
on the same line, on purpose, or set neither and \
provide CELLOS_FIRECRACKER_MANIFEST instead."
.into(),
));
}
if secondary && !primary {
return Err(CellosError::Host(
"firecracker init: CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 \
is set but CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 is not. \
The escape-hatch is two-flag by design: set both to opt \
out of pre-boot artifact digest verification (development \
only), or unset both for the production posture."
.into(),
));
}
primary && secondary
},
enable_network: parse_enable_network(&lookup),
allow_no_vsock: lookup("CELLOS_FIRECRACKER_ALLOW_NO_VSOCK")
.map(|v| v.trim() == "1")
.unwrap_or(false),
no_vsock_timeout: lookup("CELLOS_FIRECRACKER_NO_VSOCK_TIMEOUT_SECS")
.and_then(|v| v.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(5)),
no_seccomp: lookup("CELLOS_FIRECRACKER_NO_SECCOMP")
.map(|v| v.trim() == "1")
.unwrap_or(false),
};
if cfg.jailer_uid == 0 {
return Err(CellosError::Host(
"FirecrackerConfig: jailer_uid must be non-zero (running jailer as root defeats the privilege boundary) [FC-41]"
.into(),
));
}
if cfg.jailer_gid == 0 {
return Err(CellosError::Host(
"FirecrackerConfig: jailer_gid must be non-zero (running jailer in root group defeats the privilege boundary) [FC-41]"
.into(),
));
}
if cfg.allow_no_vsock {
tracing::warn!(
timeout_secs = cfg.no_vsock_timeout.as_secs(),
"CELLOS_FIRECRACKER_ALLOW_NO_VSOCK=1 is set — vsock exit-code wait \
will time out after the configured budget instead of blocking. \
Cell terminal state will be `forced` (no authenticated in-VM exit). \
This is intended for development against kernels without vsock \
support; production deployments MUST keep this off."
);
}
if cfg.no_seccomp {
tracing::warn!(
"CELLOS_FIRECRACKER_NO_SECCOMP=1 is set — Firecracker will start \
with --no-seccomp. Seccomp syscall filtering is DISABLED. This is \
only safe for emulated development environments (e.g. arm64 Rosetta) \
where the x86-64 BPF filters are rejected by the host kernel. \
NEVER set this in production."
);
}
match (cfg.manifest_path.is_some(), cfg.allow_no_manifest) {
(true, true) => {
return Err(CellosError::Host(
"firecracker init: CELLOS_FIRECRACKER_MANIFEST is set AND \
the two-flag opt-out (CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 \
plus CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1) is also \
set — these are mutually exclusive. Unset both opt-out flags \
to perform manifest verification, or unset \
CELLOS_FIRECRACKER_MANIFEST to run in dev mode without it."
.into(),
));
}
(false, false) => {
return Err(CellosError::Host(
"firecracker init: CELLOS_FIRECRACKER_MANIFEST is not set \
— pre-boot artifact digest verification is mandatory by \
default. Set CELLOS_FIRECRACKER_MANIFEST to a v1 \
manifest path, or set BOTH \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 AND \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 to opt \
out (development only — the second flag is a deliberate \
speed-bump to keep dev opt-outs from leaking into prod)."
.into(),
));
}
(false, true) => {
tracing::warn!(
"MANIFEST VERIFICATION DISABLED — CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 \
and CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 are both set. \
Booting Firecracker cells WITHOUT pre-boot artifact digest verification. \
This is unsafe for production and should only be used for local development."
);
}
(true, false) => {
}
}
Ok(cfg)
}
}
fn parse_enable_network<F>(lookup: &F) -> bool
where
F: Fn(&str) -> Option<String>,
{
let raw = lookup("CELLOS_FIRECRACKER_ENABLE_NETWORK");
let parsed = raw
.as_deref()
.and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" | "" => Some(false),
_ => None,
});
let enabled = parsed.unwrap_or(NETWORK_DEFAULT_ENABLED);
#[cfg(not(target_os = "linux"))]
{
if enabled {
tracing::warn!(
"CELLOS_FIRECRACKER_ENABLE_NETWORK requested on non-Linux host — \
TAP and nftables enforcement are Linux-only and will fail at runtime"
);
} else if raw.is_none() {
tracing::warn!(
"firecracker network enforcement disabled by default on non-Linux host \
(set CELLOS_FIRECRACKER_ENABLE_NETWORK=1 to override; runtime calls will error)"
);
}
}
enabled
}
#[cfg(target_os = "linux")]
struct VmRecord {
socket_path: PathBuf,
vsock_uds_path: PathBuf,
child: Child,
exit_rx: tokio::sync::watch::Receiver<Option<i32>>,
chroot_cell_dir: Option<PathBuf>,
scratch_image_path: Option<PathBuf>,
tap_iface: Option<String>,
graceful_shutdown_timeout: Duration,
}
pub struct FirecrackerCellBackend {
config: FirecrackerConfig,
#[cfg(target_os = "linux")]
running_vms: Arc<Mutex<HashMap<String, VmRecord>>>,
#[cfg(target_os = "linux")]
pool: Arc<Mutex<pool::FirecrackerPool>>,
event_sink: Option<Arc<dyn cellos_core::ports::EventSink>>,
}
impl FirecrackerCellBackend {
#[cfg(target_os = "linux")]
pub fn new(config: FirecrackerConfig) -> Self {
let pool_size = pool::pool_size_from_env();
Self {
config,
running_vms: Arc::new(Mutex::new(HashMap::new())),
pool: Arc::new(Mutex::new(pool::FirecrackerPool::new(pool_size))),
event_sink: None,
}
}
#[cfg(not(target_os = "linux"))]
pub fn new(config: FirecrackerConfig) -> Self {
Self {
config,
event_sink: None,
}
}
pub fn from_env() -> Result<Self, CellosError> {
Ok(Self::new(FirecrackerConfig::from_env()?))
}
pub fn with_event_sink(mut self, event_sink: Arc<dyn cellos_core::ports::EventSink>) -> Self {
self.event_sink = Some(event_sink);
self
}
pub fn config(&self) -> &FirecrackerConfig {
&self.config
}
#[cfg(target_os = "linux")]
pub async fn pool_size(&self) -> usize {
self.pool.lock().await.size()
}
#[cfg(not(target_os = "linux"))]
pub async fn pool_size(&self) -> usize {
0
}
#[cfg(target_os = "linux")]
pub async fn pool_available(&self) -> usize {
self.pool.lock().await.available()
}
#[cfg(not(target_os = "linux"))]
pub async fn pool_available(&self) -> usize {
0
}
#[cfg(target_os = "linux")]
pub async fn fill_pool(&self) {
let binary = self.config.binary_path.to_string_lossy().into_owned();
let kernel = self.config.kernel_image_path.to_string_lossy().into_owned();
let rootfs = self.config.rootfs_image_path.to_string_lossy().into_owned();
let mut pool = self.pool.lock().await;
pool.fill(&binary, &kernel, &rootfs).await;
}
#[cfg(not(target_os = "linux"))]
pub async fn fill_pool(&self) {
tracing::debug!("FirecrackerCellBackend::fill_pool no-op: target_os != linux");
}
#[cfg(target_os = "linux")]
pub async fn tracked_vm_count(&self) -> usize {
self.running_vms.lock().await.len()
}
#[cfg(not(target_os = "linux"))]
pub async fn tracked_vm_count(&self) -> usize {
0
}
#[cfg(target_os = "linux")]
pub async fn wait_for_command_exit(&self, cell_id: &str) -> Result<i32, CellosError> {
let mut exit_rx = {
let vms = self.running_vms.lock().await;
let record = vms.get(cell_id).ok_or_else(|| {
CellosError::Host(format!(
"wait_for_command_exit: no VM tracked for cell {cell_id}"
))
})?;
record.exit_rx.clone()
};
let wait_loop = async {
loop {
if let Some(code) = *exit_rx.borrow() {
return Ok::<i32, CellosError>(code);
}
exit_rx.changed().await.map_err(|_| {
CellosError::Host(format!(
"vsock exit channel for cell {cell_id} closed without exit code"
))
})?;
}
};
if self.config.allow_no_vsock {
match tokio::time::timeout(self.config.no_vsock_timeout, wait_loop).await {
Ok(result) => result,
Err(_) => Err(CellosError::Host(format!(
"vsock exit-code wait timed out after {}s for cell {} \
(CELLOS_FIRECRACKER_ALLOW_NO_VSOCK=1). Most likely cause: \
guest kernel has no virtio-vsock support — verify \
CONFIG_VIRTIO_VSOCKETS=y (NOT VIRTIO_VSOCK — that's a \
non-existent symbol) in scripts/firecracker/kernel.config \
and rebuild. Set CELLOS_FIRECRACKER_ALLOW_NO_VSOCK=0 to \
wait indefinitely.",
self.config.no_vsock_timeout.as_secs(),
cell_id,
))),
}
} else {
wait_loop.await
}
}
}
#[cfg(target_os = "linux")]
#[async_trait]
impl CellBackend for FirecrackerCellBackend {
#[instrument(skip(self, spec), fields(cell_id = %spec.spec.id))]
async fn create(&self, spec: &ExecutionCellDocument) -> Result<CellHandle, CellosError> {
if spec.spec.id.is_empty() {
return Err(CellosError::InvalidSpec("spec.id must be non-empty".into()));
}
if self.config.require_jailer && self.config.jailer_binary_path.is_none() {
return Err(CellosError::Host(
"jailer is required for production use (set CELLOS_FIRECRACKER_JAILER_BINARY or CELLOS_FIRECRACKER_ALLOW_NO_JAILER=1 to opt out)"
.into(),
));
}
let declared_egress: &[EgressRule] = spec
.spec
.authority
.egress_rules
.as_deref()
.unwrap_or_default();
if !self.config.enable_network && !declared_egress.is_empty() {
return Err(CellosError::Host(
"spec declares egress_rules but network enforcement is disabled \
(set CELLOS_FIRECRACKER_ENABLE_NETWORK=1)"
.into(),
));
}
let run_token = Uuid::new_v4();
let socket_path = resolve_socket_path(&self.config, &spec.spec.id, &run_token);
if let Some(env) = &spec.spec.environment {
tracing::info!(
image_reference = %env.image_reference,
image_digest = env.image_digest.as_deref().unwrap_or("(not pinned)"),
template_id = env.template_id.as_deref().unwrap_or("(none)"),
"cell environment declared"
);
}
let mut cmd = if let Some(jailer_bin) = &self.config.jailer_binary_path {
let exec_file_str = self.config.binary_path.to_string_lossy().into_owned();
let uid_str = self.config.jailer_uid.to_string();
let gid_str = self.config.jailer_gid.to_string();
let chroot_str = self.config.chroot_base_dir.to_string_lossy().into_owned();
let mut c = tokio::process::Command::new(jailer_bin);
let argv = build_jailer_argv(
spec.spec.id.as_str(),
exec_file_str.as_str(),
uid_str.as_str(),
gid_str.as_str(),
chroot_str.as_str(),
self.config.no_seccomp,
);
c.args(&argv);
c
} else {
let socket_str = socket_path.to_string_lossy().into_owned();
let mut c = tokio::process::Command::new(&self.config.binary_path);
let argv = build_direct_argv(socket_str.as_str(), self.config.no_seccomp);
c.args(&argv);
c
};
cmd.kill_on_drop(true);
let child = cmd.spawn().map_err(|e| {
let bin = if let Some(j) = &self.config.jailer_binary_path {
j.display().to_string()
} else {
self.config.binary_path.display().to_string()
};
let label = if self.config.jailer_binary_path.is_some() {
"jailer"
} else {
"firecracker"
};
CellosError::Host(format!("spawn {label} ({bin}): {e}"))
})?;
tracing::info!(
cell_id = %spec.spec.id,
socket = %socket_path.display(),
"firecracker process spawned"
);
let client = FirecrackerApiClient::new(&socket_path);
wait_for_socket_ready(&socket_path, SOCKET_READY_TIMEOUT).await?;
let vsock_uds_path = self.config.socket_dir.join(format!(
"cellos-vsock-{}-{}.socket",
spec.spec.id, run_token
));
let exit_hmac_key = generate_exit_hmac_key()?;
let (exit_watch_tx, exit_watch_rx) = tokio::sync::watch::channel::<Option<i32>>(None);
let exit_socket_path = PathBuf::from(format!("{}_9000", vsock_uds_path.display()));
let exit_socket_path_bg = exit_socket_path.clone();
let listener_key = exit_hmac_key;
let listener_cell_id = spec.spec.id.clone();
tokio::spawn(async move {
match listen_for_exit_code(&exit_socket_path_bg, &listener_key, &listener_cell_id).await
{
Ok(code) => {
let _ = exit_watch_tx.send(Some(code));
}
Err(e) => {
tracing::warn!(error = %e, "vsock exit-code listener failed");
}
}
let _ = std::fs::remove_file(&exit_socket_path_bg);
});
let scratch_image_path = if let Some(scratch_dir) = &self.config.scratch_dir {
std::fs::create_dir_all(scratch_dir).map_err(|e| {
CellosError::Host(format!("create scratch_dir {}: {e}", scratch_dir.display()))
})?;
let scratch_path = scratch_dir.join(format!(
"cellos-scratch-{}-{}.ext4",
spec.spec.id, run_token
));
let scratch_mib = spec
.spec
.run
.as_ref()
.and_then(|r| r.limits.as_ref())
.and_then(|l| l.memory_max_bytes)
.map(|b| ((b / (1024 * 1024)) as u32).clamp(64, 2048))
.unwrap_or(512);
create_scratch_image(&scratch_path, scratch_mib).await?;
Some(scratch_path)
} else {
None
};
let cell_short = cell_id_short(&spec.spec.id);
let tap_iface = if self.config.enable_network {
let name = create_tap_device(&cell_short, self.config.jailer_uid).await?;
if let Err(e) = apply_network_policy(&cell_short, &name, declared_egress).await {
let _ = delete_tap_device(&name).await;
return Err(e);
}
Some(name)
} else {
None
};
let (pool_snapshot, pre_checkout_available): (Option<(PathBuf, PathBuf)>, usize) = {
let mut pool = self.pool.lock().await;
let pre_available = pool.available();
let snap = pool.checkout(&spec.spec.id).await.map(|snap_path| {
let mem_path = snap_path.with_extension("mem");
(snap_path, mem_path)
});
(snap, pre_available)
};
if let Some(ref event_sink) = self.event_sink {
let event = cellos_core::events::cloud_event_v1_firecracker_pool_checkout(
"cellos-host-firecracker",
&chrono::Utc::now().to_rfc3339(),
&spec.spec.id,
pool_snapshot.is_some(),
pre_checkout_available,
);
if let Err(e) = event_sink.emit(&event).await {
tracing::warn!(
target: "cellos.host.firecracker",
cell_id = %spec.spec.id,
error = %e,
"pool_checkout CloudEvent emit failed (best-effort)"
);
}
}
let boot_result: Result<VerifiedDigests, CellosError> = async {
if let Some((snap_path, mem_path)) = pool_snapshot.as_ref() {
tracing::info!(
cell_id = %spec.spec.id,
snapshot = %snap_path.display(),
mem = %mem_path.display(),
"warm-pool fast path: attempting PUT /snapshot/load"
);
let verified = verify_artifacts(&self.config).await?;
pool::restore_into(&client, snap_path, mem_path).await?;
return Ok(verified);
}
configure_vm(
&client,
&self.config,
spec,
&vsock_uds_path,
scratch_image_path.as_deref(),
tap_iface.as_deref(),
&exit_hmac_key,
)
.await?;
let verified = verify_artifacts(&self.config).await?;
let status = client
.put(
"/actions",
&InstanceAction {
action_type: InstanceActionType::InstanceStart,
},
)
.await?;
if !status.is_success() {
return Err(CellosError::Host(format!(
"firecracker InstanceStart returned HTTP {status}"
)));
}
Ok(verified)
}
.await;
let verified_digests = match boot_result {
Ok(v) => v,
Err(e) => {
if let Some(ref tap) = tap_iface {
let _ = delete_tap_device(tap).await;
let _ = remove_network_policy(&cell_short).await;
}
if pool_snapshot.is_some() {
let _ = self.pool.lock().await.checkin(&spec.spec.id).await;
}
return Err(e);
}
};
tracing::info!(cell_id = %spec.spec.id, "firecracker VM booted");
let chroot_cell_dir = self.config.jailer_binary_path.as_ref().map(|_| {
let fc_name = self
.config
.binary_path
.file_name()
.expect("firecracker binary path must have a filename")
.to_string_lossy()
.into_owned();
self.config
.chroot_base_dir
.join(fc_name)
.join(&spec.spec.id)
});
let nft_rules_applied = Some(tap_iface.is_some());
let graceful_shutdown_timeout = resolve_graceful_shutdown_timeout(&spec.spec);
self.running_vms.lock().await.insert(
spec.spec.id.clone(),
VmRecord {
socket_path,
vsock_uds_path,
child,
exit_rx: exit_watch_rx,
chroot_cell_dir,
scratch_image_path,
tap_iface,
graceful_shutdown_timeout,
},
);
Ok(CellHandle {
cell_id: spec.spec.id.clone(),
cgroup_path: None,
nft_rules_applied,
kernel_digest_sha256: verified_digests.kernel,
rootfs_digest_sha256: verified_digests.rootfs,
firecracker_digest_sha256: verified_digests.firecracker,
})
}
async fn wait_for_in_vm_exit(&self, cell_id: &str) -> Option<Result<i32, CellosError>> {
Some(self.wait_for_command_exit(cell_id).await)
}
#[instrument(skip(self, handle), fields(cell_id = %handle.cell_id))]
async fn destroy(&self, handle: &CellHandle) -> Result<TeardownReport, CellosError> {
let mut vms = self.running_vms.lock().await;
let Some(mut record) = vms.remove(&handle.cell_id) else {
tracing::warn!(cell_id = %handle.cell_id, "destroy called on unknown cell");
return Ok(TeardownReport {
cell_id: handle.cell_id.clone(),
destroyed: false,
peers_tracked_after: vms.len(),
});
};
let client = FirecrackerApiClient::new(&record.socket_path);
let graceful = client
.put(
"/actions",
&InstanceAction {
action_type: InstanceActionType::SendCtrlAltDel,
},
)
.await;
if let Err(e) = graceful {
tracing::debug!(error = %e, "graceful shutdown request failed — will SIGKILL");
}
let exited = tokio::time::timeout(record.graceful_shutdown_timeout, record.child.wait())
.await
.ok();
if exited.is_none() {
tracing::warn!(cell_id = %handle.cell_id, "VM did not exit gracefully — sending SIGKILL");
let _ = record.child.kill().await;
let _ = record.child.wait().await;
}
if record.socket_path.exists() {
let _ = std::fs::remove_file(&record.socket_path);
}
if let Some(chroot_dir) = record.chroot_cell_dir {
let _ = std::fs::remove_dir_all(&chroot_dir);
}
let _ = std::fs::remove_file(&record.vsock_uds_path);
let vsock_exit_socket = PathBuf::from(format!(
"{}_{VSOCK_EXIT_PORT}",
record.vsock_uds_path.display()
));
let _ = std::fs::remove_file(&vsock_exit_socket);
if let Some(scratch) = record.scratch_image_path {
let _ = std::fs::remove_file(&scratch);
}
if let Some(tap) = record.tap_iface.as_deref() {
if let Err(e) = delete_tap_device(tap).await {
tracing::warn!(error = %e, tap = %tap, "delete TAP device failed");
}
}
let cell_short = cell_id_short(&handle.cell_id);
if let Err(e) = remove_network_policy(&cell_short).await {
tracing::warn!(error = %e, cell_short = %cell_short, "remove nftables policy failed");
}
tracing::info!(cell_id = %handle.cell_id, "firecracker VM destroyed");
let peers_after = vms.len();
Ok(TeardownReport {
cell_id: handle.cell_id.clone(),
destroyed: true,
peers_tracked_after: peers_after,
})
}
}
#[cfg(not(target_os = "linux"))]
#[async_trait]
impl CellBackend for FirecrackerCellBackend {
async fn create(&self, _spec: &ExecutionCellDocument) -> Result<CellHandle, CellosError> {
Err(CellosError::Host(
"FirecrackerCellBackend is only supported on Linux \
(Firecracker requires Linux/KVM); compiled as a stub on this host"
.into(),
))
}
async fn destroy(&self, _handle: &CellHandle) -> Result<TeardownReport, CellosError> {
Err(CellosError::Host(
"FirecrackerCellBackend is only supported on Linux \
(Firecracker requires Linux/KVM); compiled as a stub on this host"
.into(),
))
}
}
#[cfg(target_os = "linux")]
async fn wait_for_socket_ready(
socket_path: &Path,
connect_timeout: Duration,
) -> Result<(), CellosError> {
let deadline = tokio::time::Instant::now() + connect_timeout;
loop {
if socket_path.exists() && tokio::net::UnixStream::connect(socket_path).await.is_ok() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(CellosError::Host(format!(
"timed out waiting for Firecracker socket at {} ({}s)",
socket_path.display(),
connect_timeout.as_secs()
)));
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(target_os = "linux")]
fn resolve_socket_path(config: &FirecrackerConfig, cell_id: &str, run_token: &Uuid) -> PathBuf {
if config.jailer_binary_path.is_some() {
let fc_name = config
.binary_path
.file_name()
.expect("firecracker binary path must have a filename")
.to_string_lossy()
.into_owned();
config
.chroot_base_dir
.join(fc_name)
.join(cell_id)
.join("root/run/firecracker.socket")
} else {
config
.socket_dir
.join(format!("cellos-fc-{cell_id}-{run_token}.socket"))
}
}
#[cfg(target_os = "linux")]
async fn create_scratch_image(path: &Path, size_mib: u32) -> Result<(), CellosError> {
let dd = tokio::process::Command::new("dd")
.args([
"if=/dev/zero",
&format!("of={}", path.display()),
"bs=1M",
"count=0",
&format!("seek={size_mib}"),
])
.output()
.await
.map_err(|e| CellosError::Host(format!("dd for scratch image: {e}")))?;
if !dd.status.success() {
return Err(CellosError::Host(format!(
"dd failed creating scratch image at {}: exit {:?}",
path.display(),
dd.status.code()
)));
}
let mkfs = tokio::process::Command::new("mkfs.ext4")
.args(["-F", &path.to_string_lossy()])
.output()
.await
.map_err(|e| CellosError::Host(format!("mkfs.ext4 for scratch image: {e}")))?;
if !mkfs.status.success() {
return Err(CellosError::Host(format!(
"mkfs.ext4 failed on {}: exit {:?}",
path.display(),
mkfs.status.code()
)));
}
Ok(())
}
#[cfg(target_os = "linux")]
async fn configure_vm(
client: &FirecrackerApiClient,
config: &FirecrackerConfig,
spec: &ExecutionCellDocument,
vsock_uds_path: &Path,
scratch_image_path: Option<&Path>,
tap_iface: Option<&str>,
exit_hmac_key: &[u8],
) -> Result<(), CellosError> {
validate_jailer_security_config(config)?;
let mem_mib = derive_mem_size_mib(&spec.spec, DEFAULT_MEM_SIZE_MIB);
let machine_status = client
.put(
"/machine-config",
&MachineConfig {
vcpu_count: derive_vcpu_count(&spec.spec),
mem_size_mib: mem_mib,
track_dirty_pages: false,
},
)
.await?;
if !machine_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /machine-config returned HTTP {machine_status}"
)));
}
let boot_args = build_boot_args(spec, Some(exit_hmac_key));
let boot_status = client
.put(
"/boot-source",
&BootSource {
kernel_image_path: config.kernel_image_path.to_string_lossy().into_owned(),
boot_args: Some(boot_args),
},
)
.await?;
if !boot_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /boot-source returned HTTP {boot_status}"
)));
}
if let Some(env) = spec.spec.environment.as_ref() {
if let Some(expected) = env.image_digest.as_ref() {
let rootfs_owned = config.rootfs_image_path.clone();
let expected_owned = expected.clone();
tokio::task::spawn_blocking(move || {
verify_rootfs_digest(&rootfs_owned, &expected_owned)
})
.await
.map_err(|e| {
CellosError::Host(format!(
"rootfs digest verification task panicked or was cancelled: {e}"
))
})??;
tracing::info!(
rootfs = %config.rootfs_image_path.display(),
expected_digest = %expected,
"rootfs content-addressing verified (L2-06-1)"
);
}
}
let drive_status = client
.put(
"/drives/rootfs",
&Drive {
drive_id: "rootfs".into(),
path_on_host: config.rootfs_image_path.to_string_lossy().into_owned(),
is_root_device: true,
is_read_only: scratch_image_path.is_some(),
},
)
.await?;
if !drive_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /drives/rootfs returned HTTP {drive_status}"
)));
}
if let Some(scratch) = scratch_image_path {
let scratch_status = client
.put(
"/drives/scratch",
&Drive {
drive_id: "scratch".into(),
path_on_host: scratch.to_string_lossy().into_owned(),
is_root_device: false,
is_read_only: false,
},
)
.await?;
if !scratch_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /drives/scratch returned HTTP {scratch_status}"
)));
}
}
if let Some(tap) = tap_iface {
let net_status = client
.put(
"/network-interfaces/eth0",
&NetworkInterface {
iface_id: "eth0".into(),
guest_mac: GUEST_NIC_MAC.into(),
host_dev_name: tap.to_owned(),
},
)
.await?;
if !net_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /network-interfaces/eth0 returned HTTP {net_status}"
)));
}
}
let vsock_status = client
.put(
"/vsock",
&VsockDevice {
guest_cid: VSOCK_GUEST_CID,
uds_path: vsock_uds_path.to_string_lossy().into_owned(),
},
)
.await?;
if !vsock_status.is_success() {
return Err(CellosError::Host(format!(
"firecracker PUT /vsock returned HTTP {vsock_status}"
)));
}
Ok(())
}
#[cfg(target_os = "linux")]
pub(crate) fn build_boot_args(
spec: &ExecutionCellDocument,
exit_hmac_key: Option<&[u8]>,
) -> String {
let base = "console=ttyS0 reboot=k panic=1 pci=off ipv6.disable=1 root=/dev/vda rw";
let cell_id = &spec.spec.id;
let mut args = format!("{base} cellos.cell_id={cell_id} cellos.vsock_port={VSOCK_EXIT_PORT}");
if let Some(argv) = spec
.spec
.run
.as_ref()
.map(|r| &r.argv)
.filter(|a| !a.is_empty())
{
if let Ok(json) = serde_json::to_string(argv) {
let b64 = BASE64_STANDARD.encode(json.as_bytes());
args.push_str(&format!(" cellos.argv={b64}"));
}
}
if let Some(key) = exit_hmac_key {
let b64 = BASE64_STANDARD.encode(key);
args.push_str(&format!(" cellos.exit_hmac_key={b64}"));
}
args
}
#[cfg(target_os = "linux")]
pub(crate) fn build_jailer_argv<'a>(
spec_id: &'a str,
exec_file: &'a str,
uid: &'a str,
gid: &'a str,
chroot: &'a str,
no_seccomp: bool,
) -> Vec<&'a str> {
let mut argv = vec![
"--id",
spec_id,
"--exec-file",
exec_file,
"--uid",
uid,
"--gid",
gid,
"--chroot-base-dir",
chroot,
"--",
"--api-sock",
"/run/firecracker.socket",
"--level",
"Error",
];
if no_seccomp {
argv.push("--no-seccomp");
}
argv
}
#[cfg(target_os = "linux")]
pub(crate) fn build_direct_argv(socket_path: &str, no_seccomp: bool) -> Vec<&str> {
let mut argv = vec!["--api-sock", socket_path, "--level", "Error"];
if no_seccomp {
argv.push("--no-seccomp");
}
argv
}
fn required_absolute_path<F>(
lookup: &F,
key: &str,
description: &str,
) -> Result<PathBuf, CellosError>
where
F: Fn(&str) -> Option<String>,
{
let value = lookup(key)
.ok_or_else(|| missing_env_error(key, description))?
.trim()
.to_owned();
if value.is_empty() {
return Err(missing_env_error(key, description));
}
parse_absolute_path(key, description, &value)
}
fn optional_absolute_path<F>(
lookup: &F,
key: &str,
description: &str,
) -> Result<Option<PathBuf>, CellosError>
where
F: Fn(&str) -> Option<String>,
{
let Some(value) = lookup(key) else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
Ok(Some(parse_absolute_path(key, description, value)?))
}
fn parse_absolute_path(key: &str, description: &str, raw: &str) -> Result<PathBuf, CellosError> {
let path = Path::new(raw);
if !path.is_absolute() {
return Err(CellosError::Host(format!(
"{key} must be an absolute path to the {description} when CELLOS_CELL_BACKEND=firecracker"
)));
}
Ok(path.to_path_buf())
}
fn missing_env_error(key: &str, description: &str) -> CellosError {
CellosError::Host(format!(
"{key} must be set to an absolute path to the {description} when CELLOS_CELL_BACKEND=firecracker"
))
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, PartialEq, Eq)]
struct ManifestEntry {
sha256_hex: String,
role: String,
path: PathBuf,
}
#[cfg(target_os = "linux")]
fn parse_manifest(text: &str) -> Result<Vec<ManifestEntry>, CellosError> {
let mut out = Vec::new();
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let digest = parts.next();
let role = parts.next();
let path = parts.next();
let extra = parts.next();
let (digest, role, path) = match (digest, role, path) {
(Some(d), Some(r), Some(p)) => (d, r, p),
_ => {
return Err(CellosError::Host(format!(
"manifest line {}: expected `sha256:<hex> <role> <path>`, got: {raw:?}",
lineno + 1
)));
}
};
if extra.is_some() {
return Err(CellosError::Host(format!(
"manifest line {}: unexpected trailing field after path",
lineno + 1
)));
}
let Some(hex) = digest.strip_prefix("sha256:") else {
return Err(CellosError::Host(format!(
"manifest line {}: digest field must start with `sha256:`, got: {digest:?}",
lineno + 1
)));
};
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(CellosError::Host(format!(
"manifest line {}: sha256 digest must be 64 hex chars, got: {hex:?}",
lineno + 1
)));
}
out.push(ManifestEntry {
sha256_hex: hex.to_ascii_lowercase(),
role: role.to_string(),
path: PathBuf::from(path),
});
}
Ok(out)
}
#[cfg(target_os = "linux")]
fn sha256_file(path: &Path) -> Result<String, CellosError> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = std::fs::File::open(path).map_err(|e| {
CellosError::Host(format!(
"open artifact for hashing at {}: {e}",
path.display()
))
})?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file.read(&mut buf).map_err(|e| {
CellosError::Host(format!(
"read artifact at {} for hashing: {e}",
path.display()
))
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(64);
for byte in digest {
hex.push_str(&format!("{byte:02x}"));
}
Ok(hex)
}
#[cfg(target_os = "linux")]
fn verify_rootfs_digest(path: &Path, expected_sha256: &str) -> Result<String, CellosError> {
let expected_hex = expected_sha256
.trim()
.strip_prefix("sha256:")
.unwrap_or(expected_sha256.trim())
.to_ascii_lowercase();
if expected_hex.len() != 64 || !expected_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(CellosError::Host(format!(
"verify_rootfs_digest: expected_sha256 must be 64 hex chars (with optional `sha256:` prefix); got {expected_sha256:?}"
)));
}
let actual_hex = sha256_file(path)?;
if actual_hex != expected_hex {
return Err(CellosError::Host(format!(
"rootfs digest mismatch at {}: spec.environment.imageDigest declared sha256:{expected_hex}, on-disk image hashes to sha256:{actual_hex} \
— refusing to boot a cell against an unverified rootfs (L2-06-1)",
path.display()
)));
}
Ok(actual_hex)
}
#[cfg(target_os = "linux")]
fn derive_mem_size_mib(spec: &cellos_core::ExecutionCellSpec, env_default: u32) -> u32 {
spec.run
.as_ref()
.and_then(|r| r.limits.as_ref())
.and_then(|l| l.memory_max_bytes)
.map(|bytes| ((bytes / (1024 * 1024)) as u32).max(64))
.unwrap_or(env_default)
}
#[cfg(target_os = "linux")]
fn validate_jailer_security_config(config: &FirecrackerConfig) -> Result<(), CellosError> {
if config.jailer_binary_path.is_none() {
return Ok(());
}
if config.jailer_uid == 0 {
return Err(CellosError::Host(
"validate_jailer_security_config: jailer_uid=0 — running the jailer as root \
defeats the privilege boundary that isolates the VMM from the host. \
[L2-06-4]"
.into(),
));
}
if config.jailer_gid == 0 {
return Err(CellosError::Host(
"validate_jailer_security_config: jailer_gid=0 — running the jailer in the root \
group defeats the privilege boundary that isolates the VMM from the host. \
[L2-06-4]"
.into(),
));
}
let chroot = &config.chroot_base_dir;
if !chroot.is_absolute() {
return Err(CellosError::Host(format!(
"validate_jailer_security_config: chroot_base_dir must be an absolute path; got {} [L2-06-4]",
chroot.display()
)));
}
if chroot == Path::new("/") {
return Err(CellosError::Host(
"validate_jailer_security_config: chroot_base_dir=`/` — chroot to filesystem root \
is functionally no chroot at all. Configure CELLOS_FIRECRACKER_CHROOT_BASE to a \
dedicated directory like /var/lib/cellos/firecracker. [L2-06-4]"
.into(),
));
}
Ok(())
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Default)]
struct VerifiedDigests {
kernel: Option<String>,
rootfs: Option<String>,
firecracker: Option<String>,
}
#[cfg(target_os = "linux")]
async fn verify_artifacts(config: &FirecrackerConfig) -> Result<VerifiedDigests, CellosError> {
let Some(manifest_path) = &config.manifest_path else {
if config.allow_no_manifest {
tracing::warn!(
"MANIFEST VERIFICATION DISABLED — pre-boot artifact digest verification is being skipped \
because both CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 and \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 are set. \
This is unsafe for production and should only be used for local development."
);
return Ok(VerifiedDigests::default());
}
return Err(CellosError::Host(
"firecracker init: CELLOS_FIRECRACKER_MANIFEST is not set \
— pre-boot artifact digest verification is mandatory by default. \
Set CELLOS_FIRECRACKER_MANIFEST to a v1 manifest path, or set BOTH \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST=1 AND \
CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY=1 to opt out \
(development only — the second flag is a deliberate speed-bump)."
.into(),
));
};
let text = std::fs::read_to_string(manifest_path).map_err(|e| {
CellosError::Host(format!(
"read artifact manifest at {}: {e}",
manifest_path.display()
))
})?;
let entries = parse_manifest(&text)?;
let kernel_entry = entries.iter().find(|e| e.role == "kernel").ok_or_else(|| {
CellosError::Host(format!(
"manifest at {} is missing a `kernel` role entry",
manifest_path.display()
))
})?;
let rootfs_entry = entries.iter().find(|e| e.role == "rootfs").ok_or_else(|| {
CellosError::Host(format!(
"manifest at {} is missing a `rootfs` role entry",
manifest_path.display()
))
})?;
let firecracker_entry = entries.iter().find(|e| e.role == "firecracker");
let plan: Vec<(&str, &Path, &str, &Path)> = {
let mut v: Vec<(&str, &Path, &str, &Path)> = vec![
(
"kernel",
config.kernel_image_path.as_path(),
kernel_entry.sha256_hex.as_str(),
kernel_entry.path.as_path(),
),
(
"rootfs",
config.rootfs_image_path.as_path(),
rootfs_entry.sha256_hex.as_str(),
rootfs_entry.path.as_path(),
),
];
if let Some(fc) = firecracker_entry {
v.push((
"firecracker",
config.binary_path.as_path(),
fc.sha256_hex.as_str(),
fc.path.as_path(),
));
}
v
};
let mut verified = VerifiedDigests::default();
for (role, configured_path, expected_hex, manifest_decl_path) in plan {
if configured_path != manifest_decl_path {
tracing::warn!(
role,
configured = %configured_path.display(),
manifest = %manifest_decl_path.display(),
"configured artifact path differs from manifest declaration; verifying configured path"
);
}
let owned_path = configured_path.to_path_buf();
let actual_hex = tokio::task::spawn_blocking(move || sha256_file(&owned_path))
.await
.map_err(|e| {
CellosError::Host(format!(
"sha256 hashing task for role {role} panicked or was cancelled: {e}"
))
})??;
if actual_hex != expected_hex {
push_manifest_failed_pending(
role,
expected_hex,
actual_hex.as_str(),
manifest_path.to_string_lossy().as_ref(),
);
return Err(CellosError::Host(format!(
"artifact digest mismatch for role `{role}` at {}: expected sha256:{expected_hex}, got sha256:{actual_hex}",
configured_path.display()
)));
}
tracing::info!(
role,
path = %configured_path.display(),
sha256 = %actual_hex,
"artifact digest verified"
);
match role {
"kernel" => verified.kernel = Some(actual_hex),
"rootfs" => verified.rootfs = Some(actual_hex),
"firecracker" => verified.firecracker = Some(actual_hex),
_ => {
}
}
}
Ok(verified)
}
static MANIFEST_FAILED_PENDING: std::sync::OnceLock<
std::sync::Mutex<Vec<cellos_core::CloudEventV1>>,
> = std::sync::OnceLock::new();
pub fn push_manifest_failed_pending_for_test(
role: &str,
expected_sha256: &str,
actual_sha256: &str,
manifest_path: &str,
) {
push_manifest_failed_pending(role, expected_sha256, actual_sha256, manifest_path);
}
fn push_manifest_failed_pending(role: &str, expected: &str, actual: &str, manifest_path: &str) {
let data = match cellos_core::manifest_failed_data_v1(role, expected, actual, manifest_path) {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, role, "manifest_failed_data_v1 failed");
return;
}
};
let ev = cellos_core::CloudEventV1 {
specversion: "1.0".into(),
id: uuid::Uuid::new_v4().to_string(),
source: "cellos-host-firecracker".into(),
ty: cellos_core::LIFECYCLE_MANIFEST_FAILED_TYPE.into(),
datacontenttype: Some("application/json".into()),
data: Some(data),
time: None,
traceparent: None,
};
let buf = MANIFEST_FAILED_PENDING.get_or_init(|| std::sync::Mutex::new(Vec::new()));
if let Ok(mut g) = buf.lock() {
g.push(ev);
}
}
pub fn drain_pending_manifest_failed_events() -> Vec<cellos_core::CloudEventV1> {
let buf = MANIFEST_FAILED_PENDING.get_or_init(|| std::sync::Mutex::new(Vec::new()));
let mut g = buf.lock().unwrap_or_else(|p| p.into_inner());
std::mem::take(&mut *g)
}
#[cfg(target_os = "linux")]
#[cfg(target_os = "linux")]
async fn listen_for_exit_code(
socket_path: &Path,
hmac_key: &[u8],
cell_id: &str,
) -> Result<i32, CellosError> {
use tokio::io::AsyncWriteExt;
let listener = UnixListener::bind(socket_path).map_err(|e| {
CellosError::Host(format!(
"bind vsock exit listener at {}: {e}",
socket_path.display()
))
})?;
let (mut stream, _) = listener.accept().await.map_err(|e| {
CellosError::Host(format!(
"accept vsock exit connection at {}: {e}",
socket_path.display()
))
})?;
let mut frame = [0u8; EXIT_AUTHED_FRAME_LEN];
stream.read_exact(&mut frame).await.map_err(|e| {
CellosError::Host(format!(
"read vsock exit frame from {}: {e}",
socket_path.display()
))
})?;
let mut code_bytes = [0u8; 4];
code_bytes.copy_from_slice(&frame[..4]);
let received_tag = &frame[4..];
if !verify_exit_hmac(hmac_key, &code_bytes, cell_id, received_tag) {
tracing::warn!(
cell_id = %cell_id,
socket = %socket_path.display(),
event = "vsock_exit_auth_rejected",
"FC-18: rejecting unauthenticated vsock exit frame (HMAC mismatch)"
);
return Err(CellosError::Host(format!(
"vsock_exit_auth_rejected: HMAC mismatch on exit frame for cell {cell_id}"
)));
}
if let Err(e) = stream.write_all(&[0u8]).await {
tracing::debug!(
error = %e,
socket = %socket_path.display(),
"vsock ACK write failed (exit code already captured)"
);
}
Ok(i32::from_le_bytes(code_bytes))
}
#[cfg(target_os = "linux")]
fn cell_id_short(cell_id: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(cell_id.as_bytes());
format!(
"{:08x}",
u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
)
}
#[cfg(target_os = "linux")]
fn tap_name_for(cell_short: &str) -> String {
format!("{TAP_NAME_PREFIX}{cell_short}")
}
#[cfg(target_os = "linux")]
fn nft_table_name(cell_short: &str) -> String {
format!("cellos-{cell_short}")
}
#[cfg(target_os = "linux")]
async fn create_tap_device(cell_short: &str, uid: u32) -> Result<String, CellosError> {
#[cfg(not(target_os = "linux"))]
{
let _ = (cell_short, uid);
Err(CellosError::Host(
"TAP device creation is only supported on Linux \
(set CELLOS_FIRECRACKER_ENABLE_NETWORK=0 on this host)"
.into(),
))
}
#[cfg(target_os = "linux")]
{
let name = tap_name_for(cell_short);
if name.len() > 15 {
return Err(CellosError::Host(format!(
"computed TAP name {name:?} exceeds IFNAMSIZ (15)"
)));
}
let uid_str = uid.to_string();
let add = tokio::process::Command::new("ip")
.arg("tuntap")
.arg("add")
.arg("dev")
.arg(&name)
.arg("mode")
.arg("tap")
.arg("user")
.arg(&uid_str)
.output()
.await
.map_err(|e| CellosError::Host(format!("spawn `ip tuntap add` for {name}: {e}")))?;
if !add.status.success() {
return Err(CellosError::Host(format!(
"`ip tuntap add dev {name}` failed: exit {:?} stderr={}",
add.status.code(),
String::from_utf8_lossy(&add.stderr).trim()
)));
}
let up = tokio::process::Command::new("ip")
.arg("link")
.arg("set")
.arg("dev")
.arg(&name)
.arg("up")
.output()
.await
.map_err(|e| CellosError::Host(format!("spawn `ip link set up` for {name}: {e}")))?;
if !up.status.success() {
let _ = delete_tap_device(&name).await;
return Err(CellosError::Host(format!(
"`ip link set dev {name} up` failed: exit {:?} stderr={}",
up.status.code(),
String::from_utf8_lossy(&up.stderr).trim()
)));
}
Ok(name)
}
}
#[cfg(target_os = "linux")]
async fn delete_tap_device(name: &str) -> Result<(), CellosError> {
#[cfg(not(target_os = "linux"))]
{
let _ = name;
Ok(())
}
#[cfg(target_os = "linux")]
{
let out = tokio::process::Command::new("ip")
.arg("link")
.arg("delete")
.arg(name)
.output()
.await
.map_err(|e| CellosError::Host(format!("spawn `ip link delete` for {name}: {e}")))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("Cannot find device") || stderr.contains("does not exist") {
return Ok(());
}
Err(CellosError::Host(format!(
"`ip link delete {name}` failed: exit {:?} stderr={}",
out.status.code(),
stderr.trim()
)))
}
}
#[cfg(target_os = "linux")]
#[doc(hidden)]
pub mod __fc32 {
pub fn cell_id_short(id: &str) -> String {
super::cell_id_short(id)
}
pub fn tap_name_for(s: &str) -> String {
super::tap_name_for(s)
}
pub async fn create_tap_device(s: &str, uid: u32) -> Result<String, super::CellosError> {
super::create_tap_device(s, uid).await
}
pub async fn delete_tap_device(name: &str) -> Result<(), super::CellosError> {
super::delete_tap_device(name).await
}
}
#[doc(hidden)]
pub mod __fc18 {
pub const EXIT_HMAC_KEY_LEN: usize = super::EXIT_HMAC_KEY_LEN;
pub const EXIT_HMAC_TAG_LEN: usize = super::EXIT_HMAC_TAG_LEN;
pub fn verify_exit_hmac(
key: &[u8],
exit_code_bytes: &[u8; 4],
cell_id: &str,
received_tag: &[u8],
) -> bool {
super::verify_exit_hmac(key, exit_code_bytes, cell_id, received_tag)
}
}
#[cfg(target_os = "linux")]
fn build_nftables_ruleset(
cell_short: &str,
tap_iface: &str,
egress_rules: &[EgressRule],
) -> String {
use std::fmt::Write as _;
let table = nft_table_name(cell_short);
let mut s = String::new();
let _ = writeln!(s, "table ip {table} {{");
let _ = writeln!(s, " chain egress {{");
let _ = writeln!(
s,
" type filter hook forward priority filter; policy drop;"
);
let _ = writeln!(s, " ct state established,related accept");
for rule in egress_rules {
let l4 = match rule.protocol.as_deref().map(|p| p.to_ascii_lowercase()) {
Some(ref p) if p == "udp" => "udp",
Some(ref p) if p == "dns-acknowledged" => "udp",
_ => "tcp",
};
match rule.host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(ip)) => {
let _ = writeln!(
s,
" iifname \"{tap_iface}\" ip daddr {ip} {l4} dport {port} accept",
port = rule.port
);
}
Ok(std::net::IpAddr::V6(_)) => {
let _ = writeln!(
s,
" # skipped IPv6 {host:?} port {port} {l4} — table ip is IPv4-only",
host = rule.host,
port = rule.port
);
}
Err(_) => {
let _ = writeln!(
s,
" # unresolved host {host:?} port {port} {l4} — no accept rule",
host = rule.host,
port = rule.port
);
}
}
}
let _ = writeln!(s, " iifname \"{tap_iface}\" drop");
let _ = writeln!(s, " }}");
let _ = writeln!(s, "}}");
let _ = writeln!(s, "table ip6 {table} {{");
let _ = writeln!(s, " chain egress {{");
let _ = writeln!(
s,
" type filter hook forward priority filter; policy drop;"
);
let _ = writeln!(s, " ct state established,related accept");
for rule in egress_rules {
let l4 = match rule.protocol.as_deref().map(|p| p.to_ascii_lowercase()) {
Some(ref p) if p == "udp" => "udp",
Some(ref p) if p == "dns-acknowledged" => "udp",
_ => "tcp",
};
if let Ok(std::net::IpAddr::V6(ip)) = rule.host.parse::<std::net::IpAddr>() {
let _ = writeln!(
s,
" iifname \"{tap_iface}\" ip6 daddr {ip} {l4} dport {port} accept",
port = rule.port
);
}
}
let _ = writeln!(s, " iifname \"{tap_iface}\" drop");
let _ = writeln!(s, " }}");
let _ = writeln!(s, "}}");
s
}
#[cfg(target_os = "linux")]
async fn resolve_egress_targets(egress_rules: &[EgressRule]) -> Vec<EgressRule> {
let mut resolved = Vec::with_capacity(egress_rules.len());
for rule in egress_rules {
if rule.host.parse::<std::net::IpAddr>().is_ok() {
resolved.push(rule.clone());
continue;
}
match tokio::net::lookup_host((rule.host.as_str(), rule.port)).await {
Ok(addrs) => {
let mut any = false;
for sa in addrs {
any = true;
resolved.push(EgressRule {
host: sa.ip().to_string(),
port: rule.port,
protocol: rule.protocol.clone(),
dns_egress_justification: rule.dns_egress_justification.clone(),
});
}
if !any {
tracing::warn!(host = %rule.host, "DNS returned no addresses; egress rule skipped");
}
}
Err(e) => {
tracing::warn!(error = %e, host = %rule.host, "DNS resolution failed; egress rule skipped");
}
}
}
resolved
}
#[cfg(target_os = "linux")]
async fn apply_network_policy(
cell_short: &str,
tap_iface: &str,
egress_rules: &[EgressRule],
) -> Result<(), CellosError> {
#[cfg(not(target_os = "linux"))]
{
let _ = (cell_short, tap_iface, egress_rules);
Err(CellosError::Host(
"nftables policy enforcement is only supported on Linux".into(),
))
}
#[cfg(target_os = "linux")]
{
let _ = remove_network_policy(cell_short).await;
let resolved = resolve_egress_targets(egress_rules).await;
let ruleset = build_nftables_ruleset(cell_short, tap_iface, &resolved);
let mut child = tokio::process::Command::new("nft")
.arg("-f")
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| CellosError::Host(format!("spawn nft: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(ruleset.as_bytes()).await.map_err(|e| {
CellosError::Host(format!("write nftables ruleset to nft stdin: {e}"))
})?;
stdin
.shutdown()
.await
.map_err(|e| CellosError::Host(format!("close nft stdin: {e}")))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| CellosError::Host(format!("wait for nft: {e}")))?;
if !output.status.success() {
return Err(CellosError::Host(format!(
"nft -f - rejected ruleset for {cell_short}: exit {:?} stderr={}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
}
#[cfg(target_os = "linux")]
async fn remove_network_policy(cell_short: &str) -> Result<(), CellosError> {
#[cfg(not(target_os = "linux"))]
{
let _ = cell_short;
Ok(())
}
#[cfg(target_os = "linux")]
{
let table = nft_table_name(cell_short);
let out = tokio::process::Command::new("nft")
.arg("delete")
.arg("table")
.arg("ip")
.arg(&table)
.output()
.await
.map_err(|e| CellosError::Host(format!("spawn `nft delete table {table}`: {e}")))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("No such file or directory")
|| stderr.contains("does not exist")
|| stderr.contains("Could not process rule")
{
return Ok(());
}
Err(CellosError::Host(format!(
"`nft delete table ip {table}` failed: exit {:?} stderr={}",
out.status.code(),
stderr.trim()
)))
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
#[test]
fn config_parses_required_paths() {
let config = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/firecracker/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/firecracker/vmlinux.bin".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/firecracker/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_JAILER_BINARY" => Some("/opt/firecracker/jailer".into()),
"CELLOS_FIRECRACKER_CHROOT_BASE" => Some("/var/lib/cellos/firecracker".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(
config.binary_path,
PathBuf::from("/opt/firecracker/firecracker")
);
assert_eq!(
config.kernel_image_path,
PathBuf::from("/opt/firecracker/vmlinux.bin")
);
assert_eq!(
config.rootfs_image_path,
PathBuf::from("/opt/firecracker/rootfs.ext4")
);
assert_eq!(
config.jailer_binary_path,
Some(PathBuf::from("/opt/firecracker/jailer"))
);
assert_eq!(
config.chroot_base_dir,
PathBuf::from("/var/lib/cellos/firecracker")
);
}
#[test]
fn config_socket_dir_defaults_to_tmp() {
let config = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/firecracker/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/firecracker/vmlinux.bin".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/firecracker/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(config.socket_dir, PathBuf::from("/tmp"));
}
#[test]
fn config_requires_absolute_paths() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/firecracker/vmlinux.bin".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/firecracker/rootfs.ext4".into()),
_ => None,
})
.unwrap_err();
assert!(err
.to_string()
.contains("CELLOS_FIRECRACKER_BINARY must be an absolute path"));
}
#[test]
fn config_requires_binary_path() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/firecracker/vmlinux.bin".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/firecracker/rootfs.ext4".into()),
_ => None,
})
.unwrap_err();
assert!(err
.to_string()
.contains("CELLOS_FIRECRACKER_BINARY must be set"));
}
#[test]
fn build_boot_args_includes_cell_id_and_vsock_port() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "my-cell-001",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let args = build_boot_args(&doc, None);
assert!(args.contains("console=ttyS0"));
assert!(args.contains("cellos.cell_id=my-cell-001"));
assert!(
args.contains("cellos.vsock_port=9000"),
"vsock port must be encoded"
);
}
#[test]
fn build_boot_args_encodes_argv_when_run_present() {
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "argv-cell",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 },
"run": { "argv": ["echo", "hello world"] }
}
}))
.unwrap();
let args = build_boot_args(&doc, None);
let b64 = args
.split_ascii_whitespace()
.find(|t| t.starts_with("cellos.argv="))
.expect("cellos.argv not found in boot args")
.strip_prefix("cellos.argv=")
.unwrap();
let json = BASE64_STANDARD.decode(b64).expect("base64 decode");
let decoded: Vec<String> = serde_json::from_slice(&json).expect("json decode");
assert_eq!(decoded, vec!["echo", "hello world"]);
}
#[test]
fn build_boot_args_includes_root_dev_vda_rw() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "root-arg-cell",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let args = build_boot_args(&doc, None);
assert!(
args.contains("root=/dev/vda"),
"boot args MUST set root=/dev/vda — without it kernel panics at root mount. args={args:?}"
);
assert!(
args.contains(" rw"),
"rootfs must be mounted read-write (cellos-init writes to /proc, /sys mounts). args={args:?}"
);
assert!(
args.contains("console=ttyS0"),
"console=ttyS0 required for supervisor to read kernel stdout. args={args:?}"
);
}
#[test]
fn firecracker_argv_uses_level_not_log_level() {
let direct = build_direct_argv("/tmp/fc.sock", false);
assert!(
direct.contains(&"--level"),
"direct argv must contain --level: {direct:?}"
);
assert!(
!direct.contains(&"--log-level"),
"direct argv must NOT contain --log-level (Firecracker rejects it): {direct:?}"
);
let jailer = build_jailer_argv(
"cell-1",
"/usr/bin/firecracker",
"1000",
"1000",
"/tmp",
false,
);
assert!(
jailer.contains(&"--level"),
"jailer argv must contain --level: {jailer:?}"
);
assert!(
!jailer.contains(&"--log-level"),
"jailer argv must NOT contain --log-level (Firecracker rejects it): {jailer:?}"
);
}
#[test]
fn build_jailer_argv_has_required_positionals_and_separator() {
let argv = build_jailer_argv(
"my-cell",
"/usr/bin/firecracker",
"1000",
"1000",
"/srv/fc",
false,
);
assert_eq!(argv[0], "--id");
assert_eq!(argv[1], "my-cell");
assert_eq!(argv[2], "--exec-file");
assert_eq!(argv[3], "/usr/bin/firecracker");
let dash_dash = argv.iter().position(|a| *a == "--").expect("missing --");
assert_eq!(argv[dash_dash + 1], "--api-sock");
assert_eq!(
argv[dash_dash + 2],
"/run/firecracker.socket",
"in-jail socket path must be /run/firecracker.socket"
);
}
#[test]
fn build_boot_args_omits_argv_when_run_absent() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "no-run-cell",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let args = build_boot_args(&doc, None);
assert!(
!args.contains("cellos.argv="),
"cellos.argv must be absent when spec.run is missing"
);
}
#[test]
fn build_boot_args_includes_exit_hmac_key_when_provided() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "fc18-cell",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let key = [0x9Au8; 32];
let args = build_boot_args(&doc, Some(&key));
let token = args
.split_ascii_whitespace()
.find(|t| t.starts_with("cellos.exit_hmac_key="))
.expect("FC-18 hmac key token missing");
let b64 = token.strip_prefix("cellos.exit_hmac_key=").unwrap();
let decoded = BASE64_STANDARD.decode(b64).expect("base64 decode");
assert_eq!(decoded, key.to_vec());
}
#[test]
fn build_boot_args_omits_exit_hmac_key_when_absent() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "no-fc18-cell",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let args = build_boot_args(&doc, None);
assert!(
!args.contains("cellos.exit_hmac_key="),
"cellos.exit_hmac_key must be absent when no key is provided"
);
}
#[test]
fn verify_exit_hmac_rejects_wrong_key() {
let real_key = [0x01u8; 32];
let attacker_key = [0x02u8; 32];
let cell_id = "cell-x";
let code: i32 = 0;
let attacker_tag = fc18_compute_tag(&attacker_key, code, cell_id);
assert!(!verify_exit_hmac(
&real_key,
&code.to_le_bytes(),
cell_id,
&attacker_tag
));
}
#[test]
fn verify_exit_hmac_rejects_wrong_cell_id() {
let key = [0x01u8; 32];
let code: i32 = 0;
let other_tag = fc18_compute_tag(&key, code, "other-cell");
assert!(!verify_exit_hmac(
&key,
&code.to_le_bytes(),
"this-cell",
&other_tag
));
}
#[test]
fn verify_exit_hmac_accepts_legitimate_tag() {
let key = [0x77u8; 32];
let cell_id = "the-real-cell";
let code: i32 = 137;
let tag = fc18_compute_tag(&key, code, cell_id);
assert!(verify_exit_hmac(&key, &code.to_le_bytes(), cell_id, &tag));
}
#[test]
fn verify_exit_hmac_rejects_wrong_length() {
let key = [0x01u8; 32];
let code: i32 = 0;
let bogus = [0u8; 16];
assert!(!verify_exit_hmac(
&key,
&code.to_le_bytes(),
"any-cell",
&bogus
));
}
fn fc18_compute_tag(key: &[u8], code: i32, cell_id: &str) -> [u8; 32] {
use hmac::{digest::KeyInit, Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(key).expect("any key length");
mac.update(&code.to_le_bytes());
mac.update(cell_id.as_bytes());
let tag = mac.finalize().into_bytes();
let mut out = [0u8; 32];
out.copy_from_slice(&tag);
out
}
#[tokio::test]
async fn listen_for_exit_code_round_trip() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let dir = tempfile::tempdir().expect("tmpdir");
let socket_path = dir.path().join("test_exit.socket");
let key = [0xAAu8; 32];
let cell_id = "test-cell-rt";
let path_clone = socket_path.clone();
let key_clone = key;
let cell_id_clone = cell_id.to_string();
let handle = tokio::spawn(async move {
listen_for_exit_code(&path_clone, &key_clone, &cell_id_clone).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
let mut stream = UnixStream::connect(&socket_path)
.await
.expect("connect to listener");
let code = 42i32;
let tag = fc18_compute_tag(&key, code, cell_id);
stream
.write_all(&code.to_le_bytes())
.await
.expect("write exit code");
stream.write_all(&tag).await.expect("write hmac tag");
let mut ack = [0u8; 1];
stream.read_exact(&mut ack).await.expect("read ACK");
assert_eq!(ack[0], 0x00, "host writes 0x00 ACK after verifying frame");
let received = handle.await.expect("join").expect("listen_for_exit_code");
assert_eq!(received, 42);
}
#[tokio::test]
async fn listen_for_exit_code_negative_exit_code() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let dir = tempfile::tempdir().expect("tmpdir");
let socket_path = dir.path().join("test_exit_neg.socket");
let key = [0x55u8; 32];
let cell_id = "test-cell-neg";
let path_clone = socket_path.clone();
let key_clone = key;
let cell_id_clone = cell_id.to_string();
let handle = tokio::spawn(async move {
listen_for_exit_code(&path_clone, &key_clone, &cell_id_clone).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
let mut stream = UnixStream::connect(&socket_path).await.expect("connect");
let code = -1i32;
let tag = fc18_compute_tag(&key, code, cell_id);
stream.write_all(&code.to_le_bytes()).await.expect("write");
stream.write_all(&tag).await.expect("write hmac tag");
let mut ack = [0u8; 1];
stream.read_exact(&mut ack).await.expect("read ACK");
assert_eq!(ack[0], 0x00);
let received = handle.await.expect("join").expect("listen");
assert_eq!(received, -1);
}
#[tokio::test]
async fn listen_for_exit_code_writes_exactly_one_ack_byte() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
let dir = tempfile::tempdir().expect("tmpdir");
let socket_path = dir.path().join("test_exit_ack.socket");
let key = [0x33u8; 32];
let cell_id = "test-cell-ack";
let path_clone = socket_path.clone();
let key_clone = key;
let cell_id_clone = cell_id.to_string();
let handle = tokio::spawn(async move {
listen_for_exit_code(&path_clone, &key_clone, &cell_id_clone).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
let mut stream = UnixStream::connect(&socket_path).await.expect("connect");
let code = 7i32;
let tag = fc18_compute_tag(&key, code, cell_id);
stream
.write_all(&code.to_le_bytes())
.await
.expect("write exit code");
stream.write_all(&tag).await.expect("write hmac tag");
let mut sink = Vec::new();
stream.read_to_end(&mut sink).await.expect("drain ack");
assert_eq!(
sink,
vec![0x00],
"listener must write exactly one 0x00 ACK byte then close"
);
let code_received = handle.await.expect("join").expect("listen");
assert_eq!(code_received, 7);
}
#[tokio::test]
async fn listen_for_exit_code_rejects_forged_hmac() {
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
let dir = tempfile::tempdir().expect("tmpdir");
let socket_path = dir.path().join("test_exit_forged.socket");
let real_key = [0x01u8; 32];
let attacker_key = [0x02u8; 32];
let cell_id = "test-cell-forged";
let path_clone = socket_path.clone();
let key_clone = real_key;
let cell_id_clone = cell_id.to_string();
let handle = tokio::spawn(async move {
listen_for_exit_code(&path_clone, &key_clone, &cell_id_clone).await
});
tokio::time::sleep(Duration::from_millis(10)).await;
let mut stream = UnixStream::connect(&socket_path).await.expect("connect");
let attacker_tag = fc18_compute_tag(&attacker_key, 0, cell_id);
stream
.write_all(&0i32.to_le_bytes())
.await
.expect("write code");
stream.write_all(&attacker_tag).await.expect("write tag");
let result = handle.await.expect("join");
match result {
Err(CellosError::Host(msg)) => {
assert!(
msg.contains("vsock_exit_auth_rejected"),
"expected FC-18 rejection marker in error, got: {msg}"
);
}
other => panic!("expected Err(Host(vsock_exit_auth_rejected)), got {other:?}"),
}
}
#[test]
fn config_parses_jailer_uid_and_gid() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_JAILER_UID" => Some("10100".into()),
"CELLOS_FIRECRACKER_JAILER_GID" => Some("10200".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(cfg.jailer_uid, 10100);
assert_eq!(cfg.jailer_gid, 10200);
}
#[test]
fn config_jailer_uid_gid_default_to_10002() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(cfg.jailer_uid, 10002);
assert_eq!(cfg.jailer_gid, 10002);
}
#[test]
fn resolve_socket_path_without_jailer_uses_socket_dir() {
use uuid::Uuid;
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
let token = Uuid::nil();
let path = resolve_socket_path(&cfg, "test-cell", &token);
assert!(
path.starts_with(&cfg.socket_dir),
"expected socket in socket_dir, got {path:?}"
);
assert!(path.to_string_lossy().contains("test-cell"));
}
#[test]
fn resolve_socket_path_with_jailer_uses_chroot_base() {
use uuid::Uuid;
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_JAILER_BINARY" => Some("/opt/fc/jailer".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
let token = Uuid::nil();
let path = resolve_socket_path(&cfg, "test-cell", &token);
let expected = cfg
.chroot_base_dir
.join("firecracker")
.join("test-cell")
.join("root/run/firecracker.socket");
assert_eq!(path, expected, "got: {path:?}");
}
#[tokio::test]
async fn create_fails_with_spawn_error_when_binary_missing() {
let backend = FirecrackerCellBackend::new(FirecrackerConfig {
binary_path: PathBuf::from("/nonexistent/firecracker"),
kernel_image_path: PathBuf::from("/opt/firecracker/vmlinux.bin"),
rootfs_image_path: PathBuf::from("/opt/firecracker/rootfs.ext4"),
jailer_binary_path: None,
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: false,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
});
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "spawn-err-test",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let err = backend.create(&doc).await.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("not implemented"),
"expected spawn error, got old scaffold message: {msg}"
);
assert!(
msg.contains("spawn") || msg.contains("nonexistent") || msg.contains("No such file"),
"expected spawn error message, got: {msg}"
);
}
fn make_spec_with_cpu(
quota_micros: u64,
period_micros: Option<u64>,
) -> cellos_core::ExecutionCellSpec {
let mut doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "vcpu-test",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 },
"run": { "argv": [] }
}
}))
.unwrap();
let run = doc.spec.run.as_mut().expect("run present");
run.limits = Some(cellos_core::RunLimits {
memory_max_bytes: None,
cpu_max: Some(cellos_core::RunCpuMax {
quota_micros,
period_micros,
}),
graceful_shutdown_seconds: None,
});
doc.spec
}
#[test]
fn derive_vcpu_count_no_limits_returns_default() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "no-limits",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
assert_eq!(derive_vcpu_count(&doc.spec), DEFAULT_VCPU_COUNT);
}
#[test]
fn derive_vcpu_count_one_full_core() {
let spec = make_spec_with_cpu(100_000, Some(100_000));
assert_eq!(derive_vcpu_count(&spec), 1);
}
#[test]
fn derive_vcpu_count_fractional_rounds_up() {
let spec = make_spec_with_cpu(50_000, Some(100_000));
assert_eq!(derive_vcpu_count(&spec), 1);
}
#[test]
fn derive_vcpu_count_two_cores() {
let spec = make_spec_with_cpu(200_000, Some(100_000));
assert_eq!(derive_vcpu_count(&spec), 2);
}
#[test]
fn derive_vcpu_count_clamped_at_32() {
let spec = make_spec_with_cpu(10_000_000, Some(100_000));
assert_eq!(derive_vcpu_count(&spec), 32);
}
#[test]
fn derive_vcpu_count_default_period() {
let spec = make_spec_with_cpu(150_000, None);
assert_eq!(derive_vcpu_count(&spec), 2);
}
#[test]
fn config_parses_scratch_dir() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_SCRATCH_DIR" => Some("/var/lib/cellos/scratch".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(
cfg.scratch_dir,
Some(PathBuf::from("/var/lib/cellos/scratch"))
);
}
#[test]
fn config_scratch_dir_absent_when_not_set() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(cfg.scratch_dir, None);
}
fn cfg_for_verify(
kernel: Option<PathBuf>,
rootfs: Option<PathBuf>,
manifest: Option<PathBuf>,
) -> FirecrackerConfig {
FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: kernel.unwrap_or_else(|| PathBuf::from("/opt/fc/vmlinux")),
rootfs_image_path: rootfs.unwrap_or_else(|| PathBuf::from("/opt/fc/rootfs.ext4")),
jailer_binary_path: None,
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: manifest,
require_jailer: false,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
}
}
#[tokio::test]
async fn verify_artifacts_skips_when_no_manifest_and_opt_out() {
let cfg = cfg_for_verify(None, None, None);
verify_artifacts(&cfg).await.expect(
"verify_artifacts should succeed when manifest_path is None and allow_no_manifest=true",
);
}
#[tokio::test]
async fn verify_artifacts_errors_when_no_manifest_and_no_opt_out() {
let mut cfg = cfg_for_verify(None, None, None);
cfg.allow_no_manifest = false;
let err = verify_artifacts(&cfg)
.await
.expect_err("verify_artifacts must reject missing manifest by default");
let msg = err.to_string();
assert!(
msg.contains("CELLOS_FIRECRACKER_MANIFEST is not set"),
"expected manifest-missing error, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST"),
"error must mention the dev opt-out hint, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY"),
"error must name the paired escape-hatch flag (FC-05 hardening), got: {msg}"
);
}
#[tokio::test]
async fn verify_artifacts_fails_on_wrong_hash() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let kernel_path = dir.path().join("vmlinux");
let rootfs_path = dir.path().join("rootfs.ext4");
std::fs::File::create(&kernel_path)
.expect("kernel")
.write_all(b"hello kernel")
.expect("write kernel");
std::fs::File::create(&rootfs_path)
.expect("rootfs")
.write_all(b"hello rootfs")
.expect("write rootfs");
let manifest_path = dir.path().join("manifest.txt");
let manifest = format!(
"# CellOS Firecracker artifact manifest v1\n\
sha256:{wrong_kernel} kernel {kernel}\n\
sha256:{wrong_rootfs} rootfs {rootfs}\n",
wrong_kernel = "0".repeat(64),
wrong_rootfs = "0".repeat(64),
kernel = kernel_path.display(),
rootfs = rootfs_path.display(),
);
std::fs::write(&manifest_path, manifest).expect("write manifest");
let cfg = cfg_for_verify(
Some(kernel_path.clone()),
Some(rootfs_path),
Some(manifest_path),
);
let err = verify_artifacts(&cfg)
.await
.expect_err("expected digest mismatch error");
let msg = err.to_string();
assert!(
msg.contains("digest mismatch"),
"expected digest mismatch error, got: {msg}"
);
assert!(
msg.contains("kernel"),
"expected the failing role to be reported, got: {msg}"
);
}
#[tokio::test]
async fn verify_artifacts_succeeds_on_correct_hash() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let kernel_path = dir.path().join("vmlinux");
let rootfs_path = dir.path().join("rootfs.ext4");
let kernel_bytes: &[u8] = b"hello kernel";
let rootfs_bytes: &[u8] = b"hello rootfs";
std::fs::File::create(&kernel_path)
.expect("kernel")
.write_all(kernel_bytes)
.expect("write kernel");
std::fs::File::create(&rootfs_path)
.expect("rootfs")
.write_all(rootfs_bytes)
.expect("write rootfs");
let kernel_hex = sha256_file(&kernel_path).expect("hash kernel");
let rootfs_hex = sha256_file(&rootfs_path).expect("hash rootfs");
let manifest_path = dir.path().join("manifest.txt");
let manifest = format!(
"# good manifest\n\
sha256:{kernel_hex} kernel {kernel}\n\
sha256:{rootfs_hex} rootfs {rootfs}\n",
kernel = kernel_path.display(),
rootfs = rootfs_path.display(),
);
std::fs::write(&manifest_path, manifest).expect("write manifest");
let cfg = cfg_for_verify(Some(kernel_path), Some(rootfs_path), Some(manifest_path));
verify_artifacts(&cfg)
.await
.expect("verify_artifacts should succeed when digests match");
}
#[tokio::test]
async fn verify_artifacts_fc01_digest_mismatch_names_role_and_expected_digest() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let kernel_path = dir.path().join("vmlinux");
let rootfs_path = dir.path().join("rootfs.ext4");
std::fs::File::create(&kernel_path)
.expect("kernel")
.write_all(b"on-disk kernel bytes")
.expect("write kernel");
std::fs::File::create(&rootfs_path)
.expect("rootfs")
.write_all(b"on-disk rootfs bytes")
.expect("write rootfs");
let real_rootfs_hex = sha256_file(&rootfs_path).expect("hash rootfs");
let wrong_kernel_hex = "f".repeat(64);
let manifest_path = dir.path().join("manifest.txt");
let manifest = format!(
"# FC-01 fail-closed fixture\n\
sha256:{wrong_kernel_hex} kernel {kernel}\n\
sha256:{real_rootfs_hex} rootfs {rootfs}\n",
kernel = kernel_path.display(),
rootfs = rootfs_path.display(),
);
std::fs::write(&manifest_path, manifest).expect("write manifest");
let cfg = cfg_for_verify(
Some(kernel_path.clone()),
Some(rootfs_path),
Some(manifest_path),
);
let err = verify_artifacts(&cfg)
.await
.expect_err("digest mismatch must fail closed");
let msg = err.to_string();
assert!(
msg.contains("kernel"),
"error must name the failing role; got: {msg}"
);
assert!(
msg.contains(&wrong_kernel_hex),
"error must echo the manifest-declared (expected) digest; got: {msg}"
);
assert!(
msg.contains("digest mismatch"),
"error must use the canonical `digest mismatch` phrase \
(runbook + log-grep contract); got: {msg}"
);
}
#[tokio::test]
async fn verify_artifacts_fc01_digest_mismatch_emits_manifest_failed_event() {
use std::io::Write;
let _pre = drain_pending_manifest_failed_events();
let dir = tempfile::tempdir().expect("tmpdir");
let kernel_path = dir.path().join("vmlinux");
let rootfs_path = dir.path().join("rootfs.ext4");
std::fs::File::create(&kernel_path)
.expect("kernel")
.write_all(b"on-disk kernel bytes")
.expect("write kernel");
std::fs::File::create(&rootfs_path)
.expect("rootfs")
.write_all(b"on-disk rootfs bytes")
.expect("write rootfs");
let real_rootfs_hex = sha256_file(&rootfs_path).expect("hash rootfs");
let wrong_kernel_hex = "e".repeat(64);
let manifest_path = dir.path().join("manifest.txt");
let manifest = format!(
"# FC-51 emission fixture\n\
sha256:{wrong_kernel_hex} kernel {kernel}\n\
sha256:{real_rootfs_hex} rootfs {rootfs}\n",
kernel = kernel_path.display(),
rootfs = rootfs_path.display(),
);
std::fs::write(&manifest_path, &manifest).expect("write manifest");
let cfg = cfg_for_verify(
Some(kernel_path),
Some(rootfs_path),
Some(manifest_path.clone()),
);
let _err = verify_artifacts(&cfg)
.await
.expect_err("digest mismatch must fail closed");
let drained = drain_pending_manifest_failed_events();
assert!(
!drained.is_empty(),
"verify_artifacts must emit a manifest_failed CloudEvent on \
digest mismatch (FC-51 wiring); pending buffer was empty"
);
let ev = drained
.iter()
.find(|e| e.ty == cellos_core::LIFECYCLE_MANIFEST_FAILED_TYPE)
.expect("at least one event must use LIFECYCLE_MANIFEST_FAILED_TYPE");
assert_eq!(ev.source, "cellos-host-firecracker");
let data_str = ev
.data
.as_ref()
.and_then(|d| serde_json::to_string(d).ok())
.unwrap_or_default();
assert!(
data_str.contains("kernel"),
"event data must name the failing role; got: {data_str}"
);
assert!(
data_str.contains(&wrong_kernel_hex),
"event data must echo the manifest-declared (expected) digest; got: {data_str}"
);
}
#[tokio::test]
async fn verify_artifacts_fc01_missing_kernel_role_fails_closed() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let rootfs_path = dir.path().join("rootfs.ext4");
std::fs::File::create(&rootfs_path)
.expect("rootfs")
.write_all(b"rootfs bytes")
.expect("write rootfs");
let real_rootfs_hex = sha256_file(&rootfs_path).expect("hash rootfs");
let manifest_path = dir.path().join("manifest.txt");
let manifest = format!(
"# missing kernel role\n\
sha256:{real_rootfs_hex} rootfs {rootfs}\n",
rootfs = rootfs_path.display(),
);
std::fs::write(&manifest_path, manifest).expect("write manifest");
let cfg = cfg_for_verify(None, Some(rootfs_path), Some(manifest_path));
let err = verify_artifacts(&cfg)
.await
.expect_err("manifest without `kernel` role must fail closed");
assert!(
err.to_string().contains("kernel"),
"error must name the missing role; got: {err}"
);
}
#[test]
fn parse_manifest_rejects_short_digest() {
let text = "sha256:deadbeef kernel /opt/fc/vmlinux\n";
let err = parse_manifest(text).expect_err("expected parse error");
assert!(err.to_string().contains("64 hex chars"), "got: {err}");
}
#[test]
fn parse_manifest_rejects_missing_prefix() {
let text = "deadbeef kernel /opt/fc/vmlinux\n";
let err = parse_manifest(text).expect_err("expected parse error");
assert!(err.to_string().contains("sha256:"), "got: {err}");
}
#[test]
fn parse_manifest_skips_comments_and_blanks() {
let hex = "a".repeat(64);
let text = format!(
"# header\n\
\n\
sha256:{hex} kernel /opt/fc/vmlinux\n\
\n\
# trailing comment\n"
);
let entries = parse_manifest(&text).expect("parse");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].role, "kernel");
assert_eq!(entries[0].sha256_hex, hex);
assert_eq!(entries[0].path, PathBuf::from("/opt/fc/vmlinux"));
}
#[tokio::test]
async fn create_fails_when_jailer_required_but_not_configured() {
let backend = FirecrackerCellBackend::new(FirecrackerConfig {
binary_path: PathBuf::from("/nonexistent/firecracker"),
kernel_image_path: PathBuf::from("/opt/firecracker/vmlinux.bin"),
rootfs_image_path: PathBuf::from("/opt/firecracker/rootfs.ext4"),
jailer_binary_path: None,
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: true,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
});
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "jailer-required-test",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let err = backend.create(&doc).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("jailer is required"),
"expected jailer-required error, got: {msg}"
);
assert!(
!msg.contains("spawn"),
"create() should reject before spawning, got: {msg}"
);
}
#[tokio::test]
async fn create_allows_no_jailer_when_opt_out() {
let backend = FirecrackerCellBackend::new(FirecrackerConfig {
binary_path: PathBuf::from("/nonexistent/firecracker"),
kernel_image_path: PathBuf::from("/opt/firecracker/vmlinux.bin"),
rootfs_image_path: PathBuf::from("/opt/firecracker/rootfs.ext4"),
jailer_binary_path: None,
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: false,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
});
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "jailer-optout-test",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let err = backend.create(&doc).await.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("jailer is required"),
"should have bypassed jailer guard with require_jailer=false, got: {msg}"
);
}
#[test]
fn cell_handle_nft_signal_when_network_disabled() {
let tap_iface: Option<String> = None;
let handle = CellHandle {
cell_id: "doc-contract".to_string(),
cgroup_path: None,
nft_rules_applied: Some(tap_iface.is_some()),
kernel_digest_sha256: None,
rootfs_digest_sha256: None,
firecracker_digest_sha256: None,
};
assert_eq!(
handle.nft_rules_applied,
Some(false),
"enable_network=false must surface Some(false) for parity with the \
host-subprocess path so network_enforcement is still observable"
);
}
#[test]
fn cell_handle_nft_signal_when_network_enabled_and_tap_provisioned() {
let tap_iface: Option<String> = Some("tap-doc-contract".to_string());
let handle = CellHandle {
cell_id: "doc-contract".to_string(),
cgroup_path: None,
nft_rules_applied: Some(tap_iface.is_some()),
kernel_digest_sha256: None,
rootfs_digest_sha256: None,
firecracker_digest_sha256: None,
};
assert_eq!(handle.nft_rules_applied, Some(true));
}
#[test]
fn config_require_jailer_defaults_to_true() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert!(cfg.require_jailer, "require_jailer must default to true");
}
#[test]
fn config_require_jailer_flips_off_when_allow_no_jailer() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_JAILER" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert!(
!cfg.require_jailer,
"ALLOW_NO_JAILER=1 must flip require_jailer to false"
);
}
#[test]
fn config_manifest_path_parsed_when_set() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
_ => None,
})
.unwrap();
assert_eq!(
cfg.manifest_path,
Some(PathBuf::from("/etc/cellos/manifest.txt"))
);
}
#[test]
fn config_manifest_path_absent_when_not_set() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(cfg.manifest_path, None);
}
#[test]
fn config_allow_no_vsock_default_off() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
_ => None,
})
.expect("base config must build");
assert!(
!cfg.allow_no_vsock,
"allow_no_vsock must default to false (production posture: wait \
for authenticated in-VM exit code, no timeout)"
);
assert_eq!(cfg.no_vsock_timeout, std::time::Duration::from_secs(5));
}
#[test]
fn config_allow_no_vsock_set_with_default_timeout() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_VSOCK" => Some("1".into()),
_ => None,
})
.expect("opt-out must build");
assert!(cfg.allow_no_vsock);
assert_eq!(cfg.no_vsock_timeout, std::time::Duration::from_secs(5));
}
#[test]
fn config_allow_no_vsock_with_custom_timeout() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_VSOCK" => Some("1".into()),
"CELLOS_FIRECRACKER_NO_VSOCK_TIMEOUT_SECS" => Some("30".into()),
_ => None,
})
.expect("custom timeout must build");
assert_eq!(cfg.no_vsock_timeout, std::time::Duration::from_secs(30));
}
#[test]
fn config_no_vsock_timeout_falls_back_on_garbage() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
"CELLOS_FIRECRACKER_NO_VSOCK_TIMEOUT_SECS" => Some("not-a-number".into()),
_ => None,
})
.expect("garbage timeout falls back, doesn't error");
assert_eq!(cfg.no_vsock_timeout, std::time::Duration::from_secs(5));
}
#[test]
fn config_manifest_set_opt_out_unset_is_ok() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
_ => None,
})
.expect("manifest set + opt-out unset must succeed");
assert_eq!(
cfg.manifest_path,
Some(PathBuf::from("/etc/cellos/manifest.txt"))
);
assert!(
!cfg.allow_no_manifest,
"allow_no_manifest must default to false"
);
}
#[test]
fn config_manifest_unset_opt_out_unset_is_error() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
_ => None,
})
.expect_err("missing manifest must be rejected by default");
let msg = err.to_string();
assert!(
msg.contains("CELLOS_FIRECRACKER_MANIFEST is not set"),
"expected manifest-missing error, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST"),
"error must mention dev opt-out hint, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY"),
"error must mention the paired second escape-hatch flag, got: {msg}"
);
assert!(
msg.contains("firecracker init"),
"error must include `firecracker init` prefix, got: {msg}"
);
}
#[test]
fn config_manifest_unset_opt_out_set_is_ok() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.expect("manifest unset + opt-out set must succeed (dev mode)");
assert_eq!(cfg.manifest_path, None);
assert!(
cfg.allow_no_manifest,
"ALLOW_NO_MANIFEST=1 must flip allow_no_manifest to true"
);
}
#[test]
fn config_manifest_set_opt_out_set_is_inconsistent_error() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.expect_err("conflicting manifest + opt-out config must be rejected");
let msg = err.to_string();
assert!(
msg.contains("mutually exclusive"),
"error must explain conflict, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_MANIFEST")
&& msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST"),
"error must name both env vars, got: {msg}"
);
assert!(
msg.contains("firecracker init"),
"error must include `firecracker init` prefix, got: {msg}"
);
}
#[test]
fn config_first_flag_alone_without_second_is_error() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
_ => None,
})
.expect_err("first flag alone must NOT be accepted");
let msg = err.to_string();
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY"),
"error must name the paired flag, got: {msg}"
);
assert!(
msg.contains("firecracker init"),
"error must include `firecracker init` prefix, got: {msg}"
);
}
#[test]
fn config_second_flag_alone_without_first_is_error() {
let err = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.expect_err("second flag alone must NOT be accepted");
let msg = err.to_string();
assert!(
msg.contains("two-flag"),
"error must explain the two-flag handshake, got: {msg}"
);
assert!(
msg.contains("CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST"),
"error must name the primary flag, got: {msg}"
);
assert!(
msg.contains("firecracker init"),
"error must include `firecracker init` prefix, got: {msg}"
);
}
#[test]
fn config_both_flags_set_flips_allow_no_manifest_to_true() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.expect("both flags set + no manifest must succeed (dev mode)");
assert!(
cfg.allow_no_manifest,
"both flags set must flip allow_no_manifest to true"
);
}
#[test]
fn config_neither_flag_with_manifest_is_production_posture() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_MANIFEST" => Some("/etc/cellos/manifest.txt".into()),
_ => None,
})
.expect("manifest set + neither opt-out flag must succeed");
assert!(
!cfg.allow_no_manifest,
"production posture must keep allow_no_manifest=false"
);
}
#[test]
fn tap_name_stays_within_ifnamsiz() {
let short = cell_id_short("0123456789abcdef-extra-tail-noise");
assert_eq!(short.len(), 8, "slug must be exactly 8 chars");
let name = tap_name_for(&short);
assert!(
name.len() <= 15,
"TAP name {name:?} exceeds IFNAMSIZ (15): len={}",
name.len()
);
assert!(name.starts_with("cfc-"));
}
#[test]
fn cell_id_short_no_collision_on_short_input() {
let s = cell_id_short("ab");
assert_eq!(s, "fb8e20fc");
assert_eq!(s.len(), 8);
assert_ne!(s, cell_id_short("ab000000"));
}
#[test]
fn cell_id_short_stable_for_non_alphanumeric_input() {
let s = cell_id_short("MY-Cell/01!@#");
assert_eq!(s, "485deb36");
assert_eq!(s.len(), 8);
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
}
#[tokio::test]
async fn create_fails_when_network_disabled_but_egress_declared() {
let backend = FirecrackerCellBackend::new(FirecrackerConfig {
binary_path: PathBuf::from("/nonexistent/firecracker"),
kernel_image_path: PathBuf::from("/opt/firecracker/vmlinux.bin"),
rootfs_image_path: PathBuf::from("/opt/firecracker/rootfs.ext4"),
jailer_binary_path: None,
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: false,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
});
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "egress-disabled-test",
"authority": {
"secretRefs": [],
"egressRules": [
{ "host": "api.example.com", "port": 443, "protocol": "https" }
]
},
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
let err = backend.create(&doc).await.expect_err("create must fail");
let msg = err.to_string();
assert!(
msg.contains("egress_rules"),
"error must mention egress_rules; got: {msg}"
);
assert!(
!msg.contains("/nonexistent/firecracker"),
"guard must short-circuit before spawn; got: {msg}"
);
}
#[test]
fn nftables_ruleset_format() {
let rules = vec![
EgressRule {
host: "10.0.0.1".into(),
port: 443,
protocol: Some("https".into()),
dns_egress_justification: None,
},
EgressRule {
host: "192.168.5.5".into(),
port: 53,
protocol: Some("dns-acknowledged".into()),
dns_egress_justification: Some("operator-approved DNS".into()),
},
EgressRule {
host: "203.0.113.7".into(),
port: 22,
protocol: Some("tcp".into()),
dns_egress_justification: None,
},
];
let ruleset = build_nftables_ruleset("abcd1234", "cfc-abcd1234", &rules);
assert!(
ruleset.contains("table ip cellos-abcd1234"),
"missing per-cell table; got:\n{ruleset}"
);
assert!(
ruleset.contains("type filter hook forward priority filter; policy drop;"),
"missing default-drop policy; got:\n{ruleset}"
);
assert!(
ruleset.contains("ct state established,related accept"),
"missing conntrack accept; got:\n{ruleset}"
);
assert!(
ruleset.contains("iifname \"cfc-abcd1234\" ip daddr 10.0.0.1 tcp dport 443 accept"),
"missing https accept; got:\n{ruleset}"
);
assert!(
ruleset.contains("iifname \"cfc-abcd1234\" ip daddr 192.168.5.5 udp dport 53 accept"),
"missing dns-acknowledged accept (must map to udp); got:\n{ruleset}"
);
assert!(
ruleset.contains("iifname \"cfc-abcd1234\" ip daddr 203.0.113.7 tcp dport 22 accept"),
"missing tcp accept; got:\n{ruleset}"
);
assert!(
ruleset.contains("iifname \"cfc-abcd1234\" drop"),
"missing per-iface drop; got:\n{ruleset}"
);
}
#[test]
fn nftables_ruleset_emits_comment_for_unresolved_hostname() {
let rules = vec![EgressRule {
host: "api.example.com".into(),
port: 443,
protocol: Some("https".into()),
dns_egress_justification: None,
}];
let ruleset = build_nftables_ruleset("xyz12345", "cfc-xyz12345", &rules);
assert!(
ruleset.contains("# unresolved host \"api.example.com\""),
"missing unresolved-host comment; got:\n{ruleset}"
);
assert!(
!ruleset.contains("daddr api.example.com"),
"must not emit hostname as IP literal; got:\n{ruleset}"
);
}
#[test]
fn config_enable_network_default_matches_platform() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert_eq!(cfg.enable_network, NETWORK_DEFAULT_ENABLED);
}
#[test]
fn config_enable_network_env_off_overrides_default() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ENABLE_NETWORK" => Some("0".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert!(!cfg.enable_network);
}
#[test]
fn config_enable_network_env_on_overrides_default() {
let cfg = FirecrackerConfig::from_lookup(|key| match key {
"CELLOS_FIRECRACKER_BINARY" => Some("/opt/fc/firecracker".into()),
"CELLOS_FIRECRACKER_KERNEL_IMAGE" => Some("/opt/fc/vmlinux".into()),
"CELLOS_FIRECRACKER_ROOTFS_IMAGE" => Some("/opt/fc/rootfs.ext4".into()),
"CELLOS_FIRECRACKER_ENABLE_NETWORK" => Some("true".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST" => Some("1".into()),
"CELLOS_FIRECRACKER_ALLOW_NO_MANIFEST_REALLY" => Some("1".into()),
_ => None,
})
.unwrap();
assert!(cfg.enable_network);
}
#[test]
fn verify_rootfs_digest_accepts_matching_hash() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("rootfs.ext4");
let bytes: &[u8] = b"deterministic rootfs bytes for L2-06-1 test";
std::fs::File::create(&path)
.expect("create rootfs")
.write_all(bytes)
.expect("write rootfs");
let expected = sha256_file(&path).expect("hash rootfs");
verify_rootfs_digest(&path, &expected).expect("bare-hex digest must verify");
let prefixed = format!("sha256:{expected}");
verify_rootfs_digest(&path, &prefixed).expect("sha256:-prefixed digest must verify");
let upper = expected.to_ascii_uppercase();
verify_rootfs_digest(&path, &upper)
.expect("uppercase digest must verify (case-insensitive)");
}
#[test]
fn verify_rootfs_digest_rejects_mismatched_hash() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("rootfs.ext4");
std::fs::File::create(&path)
.expect("create rootfs")
.write_all(b"the on-disk bytes")
.expect("write rootfs");
let wrong = "f".repeat(64);
let err =
verify_rootfs_digest(&path, &wrong).expect_err("digest mismatch must fail closed");
let msg = err.to_string();
assert!(
msg.contains("rootfs digest mismatch"),
"error must use canonical phrasing for log-grep contract; got: {msg}"
);
assert!(
msg.contains("L2-06-1"),
"error must carry the L2-06-1 audit tag; got: {msg}"
);
assert!(
msg.contains(&wrong),
"error must echo the declared (expected) digest; got: {msg}"
);
}
#[test]
fn verify_rootfs_digest_rejects_malformed_expected() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("rootfs.ext4");
std::fs::write(&path, b"any bytes").expect("write");
let err = verify_rootfs_digest(&path, "deadbeef").expect_err("short hex must reject");
assert!(err.to_string().contains("64 hex chars"), "got: {err}");
let bad_chars = "z".repeat(64);
let err = verify_rootfs_digest(&path, &bad_chars).expect_err("non-hex chars must reject");
assert!(err.to_string().contains("64 hex chars"), "got: {err}");
}
#[test]
fn derive_mem_size_mib_uses_spec_when_present() {
let mut doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "mem-test",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 },
"run": { "argv": [] }
}
}))
.unwrap();
let run = doc.spec.run.as_mut().expect("run present");
run.limits = Some(cellos_core::RunLimits {
memory_max_bytes: Some(512 * 1024 * 1024),
cpu_max: None,
graceful_shutdown_seconds: None,
});
assert_eq!(derive_mem_size_mib(&doc.spec, DEFAULT_MEM_SIZE_MIB), 512);
}
#[test]
fn derive_mem_size_mib_falls_back_to_env_default() {
let doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "mem-default",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 }
}
}))
.unwrap();
assert_eq!(derive_mem_size_mib(&doc.spec, 256), 256);
}
#[test]
fn derive_mem_size_mib_clamps_to_64_minimum() {
let mut doc: ExecutionCellDocument = serde_json::from_value(serde_json::json!({
"apiVersion": "cellos.io/v1",
"kind": "ExecutionCell",
"spec": {
"id": "tiny",
"authority": { "secretRefs": [] },
"lifetime": { "ttlSeconds": 60 },
"run": { "argv": [] }
}
}))
.unwrap();
let run = doc.spec.run.as_mut().expect("run present");
run.limits = Some(cellos_core::RunLimits {
memory_max_bytes: Some(1024), cpu_max: None,
graceful_shutdown_seconds: None,
});
assert_eq!(derive_mem_size_mib(&doc.spec, DEFAULT_MEM_SIZE_MIB), 64);
}
#[test]
fn validate_jailer_security_config_accepts_safe_defaults() {
let cfg = FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: PathBuf::from("/opt/fc/vmlinux"),
rootfs_image_path: PathBuf::from("/opt/fc/rootfs.ext4"),
jailer_binary_path: Some(PathBuf::from("/opt/fc/jailer")),
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: true,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
};
validate_jailer_security_config(&cfg).expect("safe defaults must validate");
}
#[test]
fn validate_jailer_security_config_rejects_root_uid() {
let cfg = FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: PathBuf::from("/opt/fc/vmlinux"),
rootfs_image_path: PathBuf::from("/opt/fc/rootfs.ext4"),
jailer_binary_path: Some(PathBuf::from("/opt/fc/jailer")),
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 0,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: true,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
};
let err = validate_jailer_security_config(&cfg).expect_err("uid=0 must be rejected");
let msg = err.to_string();
assert!(msg.contains("jailer_uid=0"), "got: {msg}");
assert!(msg.contains("L2-06-4"), "audit tag missing: {msg}");
}
#[test]
fn validate_jailer_security_config_rejects_root_gid() {
let cfg = FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: PathBuf::from("/opt/fc/vmlinux"),
rootfs_image_path: PathBuf::from("/opt/fc/rootfs.ext4"),
jailer_binary_path: Some(PathBuf::from("/opt/fc/jailer")),
chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 0,
scratch_dir: None,
manifest_path: None,
require_jailer: true,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
};
let err = validate_jailer_security_config(&cfg).expect_err("gid=0 must be rejected");
let msg = err.to_string();
assert!(msg.contains("jailer_gid=0"), "got: {msg}");
assert!(msg.contains("L2-06-4"), "audit tag missing: {msg}");
}
#[test]
fn validate_jailer_security_config_rejects_root_chroot() {
let cfg = FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: PathBuf::from("/opt/fc/vmlinux"),
rootfs_image_path: PathBuf::from("/opt/fc/rootfs.ext4"),
jailer_binary_path: Some(PathBuf::from("/opt/fc/jailer")),
chroot_base_dir: PathBuf::from("/"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: true,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
};
let err = validate_jailer_security_config(&cfg).expect_err("chroot=/ must be rejected");
let msg = err.to_string();
assert!(msg.contains("chroot_base_dir=`/`"), "got: {msg}");
assert!(msg.contains("L2-06-4"), "audit tag missing: {msg}");
}
#[test]
fn validate_jailer_security_config_passes_when_jailer_disabled() {
let cfg = FirecrackerConfig {
binary_path: PathBuf::from("/opt/fc/firecracker"),
kernel_image_path: PathBuf::from("/opt/fc/vmlinux"),
rootfs_image_path: PathBuf::from("/opt/fc/rootfs.ext4"),
jailer_binary_path: None, chroot_base_dir: PathBuf::from("/var/lib/cellos/firecracker"),
socket_dir: PathBuf::from("/tmp"),
jailer_uid: 10002,
jailer_gid: 10002,
scratch_dir: None,
manifest_path: None,
require_jailer: false,
allow_no_manifest: true,
enable_network: false,
allow_no_vsock: false,
no_vsock_timeout: std::time::Duration::from_secs(5),
no_seccomp: false,
};
validate_jailer_security_config(&cfg).expect("jailer disabled must short-circuit Ok");
}
#[test]
fn build_jailer_argv_includes_uid_gid_chroot_flags() {
let argv = build_jailer_argv(
"cell-l2-06-4",
"/opt/fc/firecracker",
"10002",
"10003",
"/var/lib/cellos/firecracker",
false,
);
let uid_pos = argv
.iter()
.position(|a| *a == "--uid")
.expect("--uid must be present");
assert_eq!(argv[uid_pos + 1], "10002");
let gid_pos = argv
.iter()
.position(|a| *a == "--gid")
.expect("--gid must be present");
assert_eq!(argv[gid_pos + 1], "10003");
let chroot_pos = argv
.iter()
.position(|a| *a == "--chroot-base-dir")
.expect("--chroot-base-dir must be present");
assert_eq!(argv[chroot_pos + 1], "/var/lib/cellos/firecracker");
}
}