Build deterministic OCI sandbox specifications

This commit is contained in:
ms2k 2026-07-15 14:59:43 +09:00
commit cf5948e8be
4 changed files with 1422 additions and 0 deletions

View file

@ -11,6 +11,12 @@ path = "src/main.rs"
[dependencies]
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
oci-spec.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile = "=3.27.0"
[lints]
workspace = true

View file

@ -0,0 +1,3 @@
#![doc = "Runtime construction interfaces owned by the Lazaret Runtime Supervisor."]
pub mod sandbox;

View file

@ -0,0 +1,727 @@
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::fs::Metadata;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::MetadataExt;
use std::path::{Component, Path, PathBuf};
use lazaret_core::configuration::{ResolvedDomainConfiguration, SandboxLayoutV1};
use lazaret_core::identity::{ContainerId, NamespaceIdentity};
use lazaret_core::records::VolumeAccess;
use oci_spec::runtime::{
Capabilities, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder,
LinuxNamespaceBuilder, LinuxNamespaceType, Mount, MountBuilder, ProcessBuilder, RootBuilder,
Spec, SpecBuilder, UserBuilder,
};
pub const WORKSPACE_DESTINATION: &str = "/workspace";
pub const CONTROL_ROOT_DESTINATION: &str = "/run/lazaret";
pub const CREDENTIALS_DESTINATION: &str = "/run/lazaret/credentials";
pub const MAILBOX_REQUESTS_DESTINATION: &str = "/run/lazaret/mailbox/requests";
pub const MAILBOX_RESULTS_DESTINATION: &str = "/run/lazaret/mailbox/results";
pub const ESCALATION_CLIENT_DESTINATION: &str = "/run/lazaret/bin/lazaret-escalate";
pub const TERMINFO_ROOT_DESTINATION: &str = "/run/lazaret/terminfo";
pub const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
const OCI_VERSION: &str = "1.2.1";
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SandboxBuildErrorKind {
InvalidProcess,
InvalidPath,
InvalidTerminal,
ChangedAuthority,
NetworkNamespaceMismatch,
OciSpecification,
JsonEncoding,
}
#[derive(Debug)]
pub struct SandboxBuildError {
kind: SandboxBuildErrorKind,
detail: String,
}
impl SandboxBuildError {
fn new(kind: SandboxBuildErrorKind, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
#[must_use]
pub const fn kind(&self) -> SandboxBuildErrorKind {
self.kind
}
}
impl fmt::Display for SandboxBuildError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", error_label(self.kind), self.detail)
}
}
impl std::error::Error for SandboxBuildError {}
const fn error_label(kind: SandboxBuildErrorKind) -> &'static str {
match kind {
SandboxBuildErrorKind::InvalidProcess => "invalid root process",
SandboxBuildErrorKind::InvalidPath => "invalid sandbox path",
SandboxBuildErrorKind::InvalidTerminal => "invalid host terminal",
SandboxBuildErrorKind::ChangedAuthority => "changed host authority",
SandboxBuildErrorKind::NetworkNamespaceMismatch => "network namespace mismatch",
SandboxBuildErrorKind::OciSpecification => "invalid OCI specification",
SandboxBuildErrorKind::JsonEncoding => "OCI JSON encoding failed",
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RootProcess {
arguments: Vec<String>,
}
impl RootProcess {
/// Creates a direct argument vector for the terminal-neutral root process.
///
/// # Errors
/// The program must be an absolute normalized sandbox path and no argument may contain NUL.
pub fn new(
program: impl Into<String>,
arguments: impl IntoIterator<Item = String>,
) -> Result<Self, SandboxBuildError> {
let program = program.into();
validate_sandbox_executable(&program)?;
let mut complete = vec![program];
for argument in arguments {
if argument.as_bytes().contains(&0) {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidProcess,
"argument contains NUL",
));
}
complete.push(argument);
}
Ok(Self {
arguments: complete,
})
}
#[must_use]
pub fn arguments(&self) -> &[String] {
&self.arguments
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct FileIdentity {
device: u64,
inode: u64,
}
impl FileIdentity {
fn from_metadata(metadata: &Metadata) -> Self {
Self {
device: metadata.dev(),
inode: metadata.ino(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HostTerminal {
term: String,
color_term: Option<String>,
source: PathBuf,
identity: FileIdentity,
}
impl HostTerminal {
/// Selects one exact compiled host terminfo entry beneath a trusted root.
///
/// # Errors
/// Invalid names, inaccessible entries, non-files, and sources outside the roots are refused.
pub fn from_trusted_roots(
term: impl Into<String>,
color_term: Option<String>,
source: &Path,
trusted_roots: &[PathBuf],
) -> Result<Self, SandboxBuildError> {
let term = term.into();
validate_terminal_name(&term)?;
if color_term.as_deref().is_some_and(|value| {
value.is_empty() || value.bytes().any(|byte| byte < 0x20 || byte == 0x7f)
}) {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"COLORTERM contains control bytes or is empty",
));
}
let source = std::fs::canonicalize(source).map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
format!("cannot canonicalize terminfo source: {error}"),
)
})?;
if source.file_name() != Some(OsStr::new(&term)) {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"terminfo source name differs from TERM",
));
}
let beneath_trusted_root = trusted_roots.iter().any(|root| {
std::fs::canonicalize(root)
.ok()
.is_some_and(|root| source.starts_with(&root) && source != root)
});
if !beneath_trusted_root {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"terminfo source is outside every trusted root",
));
}
let metadata = std::fs::metadata(&source).map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
format!("cannot inspect terminfo source: {error}"),
)
})?;
if !metadata.is_file() {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"terminfo source is not a regular file",
));
}
Ok(Self {
term,
color_term,
source,
identity: FileIdentity::from_metadata(&metadata),
})
}
fn revalidate(&self) -> Result<(), SandboxBuildError> {
let metadata = std::fs::metadata(&self.source).map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
format!("cannot revalidate terminfo source: {error}"),
)
})?;
if !metadata.is_file() || FileIdentity::from_metadata(&metadata) != self.identity {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::ChangedAuthority,
"terminfo source changed identity",
));
}
Ok(())
}
fn destination(&self) -> PathBuf {
let bucket = char::from(self.term.as_bytes()[0]).to_string();
Path::new(TERMINFO_ROOT_DESTINATION)
.join(bucket)
.join(&self.term)
}
}
pub struct SandboxBuildRequest<'a> {
pub configuration: &'a ResolvedDomainConfiguration,
pub container_id: ContainerId,
pub expected_network_namespace: NamespaceIdentity,
pub root_process: RootProcess,
pub terminal: Option<HostTerminal>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunscCommand {
program: OsString,
arguments: Vec<OsString>,
}
impl RunscCommand {
#[must_use]
pub fn program(&self) -> &OsStr {
&self.program
}
#[must_use]
pub fn arguments(&self) -> &[OsString] {
&self.arguments
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SandboxBundle {
specification: Spec,
specification_json: Vec<u8>,
command: RunscCommand,
}
impl SandboxBundle {
#[must_use]
pub const fn specification(&self) -> &Spec {
&self.specification
}
#[must_use]
pub fn specification_json(&self) -> &[u8] {
&self.specification_json
}
#[must_use]
pub const fn command(&self) -> &RunscCommand {
&self.command
}
}
/// Returns the complete protected destination roots used by bundle construction.
///
/// # Errors
/// Returns an error if the fixed layout violates the configuration layout contract.
pub fn production_sandbox_layout() -> Result<SandboxLayoutV1, SandboxBuildError> {
SandboxLayoutV1::from_production_layout([
WORKSPACE_DESTINATION.to_owned(),
CONTROL_ROOT_DESTINATION.to_owned(),
"/proc".to_owned(),
"/sys".to_owned(),
"/dev".to_owned(),
])
.map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
format!("production layout is invalid: {error}"),
)
})
}
/// Builds deterministic OCI bytes and the direct attached-runsc argument vector.
///
/// # Errors
/// Refuses changed host authority, namespace mismatch, invalid paths, or encoding failures.
pub fn build_sandbox_bundle(
request: SandboxBuildRequest<'_>,
) -> Result<SandboxBundle, SandboxBuildError> {
require_network_namespace(request.expected_network_namespace)?;
request
.configuration
.workspace()
.open_authority()
.map_err(changed_configuration_authority)?;
for volume in request.configuration.volumes() {
volume
.open_source()
.map_err(changed_configuration_authority)?;
}
if let Some(terminal) = &request.terminal {
terminal.revalidate()?;
}
let paths = runtime_paths(request.configuration, request.container_id)?;
let mounts = build_mounts(
request.configuration,
request.terminal.as_ref(),
&paths.control_sources,
)?;
let process = build_process(
request.configuration,
request.root_process,
request.terminal.as_ref(),
)?;
let linux = build_linux()?;
let command = build_runsc_command(&paths);
let root = RootBuilder::default()
.path(paths.rootfs)
.readonly(false)
.build()
.map_err(oci_error)?;
let specification = SpecBuilder::default()
.version(OCI_VERSION)
.root(root)
.mounts(mounts)
.process(process)
.hostname("lazaret")
.linux(linux)
.build()
.map_err(oci_error)?;
let specification_json = serde_json::to_vec(&specification).map_err(|error| {
SandboxBuildError::new(SandboxBuildErrorKind::JsonEncoding, error.to_string())
})?;
Ok(SandboxBundle {
specification,
specification_json,
command,
})
}
struct RuntimePaths {
container_name: String,
bundle: PathBuf,
runsc_root: PathBuf,
rootfs: PathBuf,
control_sources: PathBuf,
}
fn runtime_paths(
configuration: &ResolvedDomainConfiguration,
container_id: ContainerId,
) -> Result<RuntimePaths, SandboxBuildError> {
let runtime_domain = host_path(configuration.runtime_domain_root().canonical_label())?;
let container_name = hex_lower(container_id.as_bytes());
Ok(RuntimePaths {
bundle: runtime_domain.join("bundles").join(&container_name),
runsc_root: runtime_domain.join("runsc"),
rootfs: runtime_domain.join("state-anchor").join("merged"),
control_sources: runtime_domain.join("sandbox-control"),
container_name,
})
}
fn build_mounts(
configuration: &ResolvedDomainConfiguration,
terminal: Option<&HostTerminal>,
control_sources: &Path,
) -> Result<Vec<Mount>, SandboxBuildError> {
let mut mounts = standard_mounts()?;
mounts.push(bind_mount(
host_path(configuration.workspace().canonical_label())?,
PathBuf::from(WORKSPACE_DESTINATION),
false,
false,
)?);
for volume in configuration.volumes() {
let record = volume.record();
mounts.push(bind_mount(
host_path(&record.canonical_host_source)?,
PathBuf::from(&record.container_path),
record.access == VolumeAccess::ReadOnly,
false,
)?);
}
mounts.extend(control_mounts(control_sources)?);
if let Some(terminal) = terminal {
mounts.push(bind_mount(
terminal.source.clone(),
terminal.destination(),
true,
true,
)?);
}
Ok(mounts)
}
fn build_linux() -> Result<oci_spec::runtime::Linux, SandboxBuildError> {
let private_namespaces = [
LinuxNamespaceType::Pid,
LinuxNamespaceType::Ipc,
LinuxNamespaceType::Uts,
LinuxNamespaceType::Mount,
LinuxNamespaceType::User,
]
.into_iter()
.map(|typ| {
LinuxNamespaceBuilder::default()
.typ(typ)
.build()
.map_err(oci_error)
})
.chain(std::iter::once(
LinuxNamespaceBuilder::default()
.typ(LinuxNamespaceType::Network)
.path(PathBuf::from(NETWORK_NAMESPACE_PATH))
.build()
.map_err(oci_error),
))
.collect::<Result<Vec<_>, _>>()?;
let identity_mapping = LinuxIdMappingBuilder::default()
.container_id(0_u32)
.host_id(0_u32)
.size(1_u32)
.build()
.map_err(oci_error)?;
LinuxBuilder::default()
.namespaces(private_namespaces)
.uid_mappings(vec![identity_mapping])
.gid_mappings(vec![identity_mapping])
.rootfs_propagation("rprivate")
.build()
.map_err(oci_error)
}
fn build_runsc_command(paths: &RuntimePaths) -> RunscCommand {
RunscCommand {
program: OsString::from("runsc"),
arguments: vec![
OsString::from(format!(
"-root={}",
paths
.runsc_root
.to_str()
.expect("validated runtime path remains UTF-8")
)),
OsString::from("-ignore-cgroups"),
OsString::from("run"),
OsString::from(format!(
"-bundle={}",
paths
.bundle
.to_str()
.expect("validated runtime path remains UTF-8")
)),
OsString::from(&paths.container_name),
],
}
}
fn build_process(
configuration: &ResolvedDomainConfiguration,
root_process: RootProcess,
terminal: Option<&HostTerminal>,
) -> Result<oci_spec::runtime::Process, SandboxBuildError> {
let mut environment = BTreeMap::from([("PATH".to_owned(), DEFAULT_PATH.to_owned())]);
for (name, value) in configuration.ephemeral_environment() {
let value = value.expose().to_str().ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidProcess,
"resolved environment value is not UTF-8",
)
})?;
environment.insert(name.clone(), value.to_owned());
}
if let Some(terminal) = terminal {
environment.insert("TERM".to_owned(), terminal.term.clone());
if let Some(color_term) = &terminal.color_term {
environment.insert("COLORTERM".to_owned(), color_term.clone());
} else {
environment.remove("COLORTERM");
}
environment.insert(
"TERMINFO_DIRS".to_owned(),
format!("{TERMINFO_ROOT_DESTINATION}:"),
);
}
let environment = environment
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>();
let empty = Capabilities::new();
let capabilities = LinuxCapabilitiesBuilder::default()
.bounding(empty.clone())
.effective(empty.clone())
.inheritable(empty.clone())
.permitted(empty.clone())
.ambient(empty)
.build()
.map_err(oci_error)?;
let user = UserBuilder::default()
.uid(0_u32)
.gid(0_u32)
.umask(0o022_u32)
.build()
.map_err(oci_error)?;
ProcessBuilder::default()
.terminal(false)
.user(user)
.args(root_process.arguments)
.env(environment)
.cwd(PathBuf::from(WORKSPACE_DESTINATION))
.capabilities(capabilities)
.no_new_privileges(true)
.build()
.map_err(oci_error)
}
fn standard_mounts() -> Result<Vec<Mount>, SandboxBuildError> {
[
("/proc", "proc", "proc", vec!["nosuid", "noexec", "nodev"]),
(
"/dev",
"tmpfs",
"tmpfs",
vec!["nosuid", "strictatime", "mode=755", "size=65536k"],
),
(
"/dev/pts",
"devpts",
"devpts",
vec![
"nosuid",
"noexec",
"newinstance",
"ptmxmode=0666",
"mode=0620",
],
),
(
"/dev/shm",
"tmpfs",
"shm",
vec!["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"],
),
(
"/dev/mqueue",
"mqueue",
"mqueue",
vec!["nosuid", "noexec", "nodev"],
),
(
"/sys",
"sysfs",
"sysfs",
vec!["nosuid", "noexec", "nodev", "ro"],
),
]
.into_iter()
.map(|(destination, typ, source, options)| {
MountBuilder::default()
.destination(PathBuf::from(destination))
.typ(typ)
.source(PathBuf::from(source))
.options(options.into_iter().map(str::to_owned).collect::<Vec<_>>())
.build()
.map_err(oci_error)
})
.collect()
}
fn control_mounts(control_sources: &Path) -> Result<Vec<Mount>, SandboxBuildError> {
[
("credentials", CREDENTIALS_DESTINATION, true, false),
(
"mailbox/requests",
MAILBOX_REQUESTS_DESTINATION,
false,
false,
),
("mailbox/results", MAILBOX_RESULTS_DESTINATION, true, false),
(
"bin/lazaret-escalate",
ESCALATION_CLIENT_DESTINATION,
true,
false,
),
]
.into_iter()
.map(|(source, destination, read_only, no_exec)| {
bind_mount(
control_sources.join(source),
PathBuf::from(destination),
read_only,
no_exec,
)
})
.collect()
}
fn bind_mount(
source: PathBuf,
destination: PathBuf,
read_only: bool,
no_exec: bool,
) -> Result<Mount, SandboxBuildError> {
validate_absolute_normalized(&source)?;
validate_absolute_normalized(&destination)?;
let mut options = vec!["bind".to_owned(), "nosuid".to_owned(), "nodev".to_owned()];
options.push(if read_only { "ro" } else { "rw" }.to_owned());
if no_exec {
options.push("noexec".to_owned());
}
MountBuilder::default()
.destination(destination)
.typ("bind")
.source(source)
.options(options)
.build()
.map_err(oci_error)
}
fn require_network_namespace(expected: NamespaceIdentity) -> Result<(), SandboxBuildError> {
let observed = lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH))
.map_err(|error| {
SandboxBuildError::new(
SandboxBuildErrorKind::NetworkNamespaceMismatch,
format!("cannot inspect Runtime Supervisor network namespace: {error}"),
)
})?;
if observed != expected {
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::NetworkNamespaceMismatch,
"Runtime Supervisor network namespace identity differs from readiness evidence",
));
}
Ok(())
}
fn changed_configuration_authority(error: impl fmt::Display) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::ChangedAuthority, error.to_string())
}
fn oci_error(error: impl fmt::Display) -> SandboxBuildError {
SandboxBuildError::new(SandboxBuildErrorKind::OciSpecification, error.to_string())
}
fn validate_sandbox_executable(program: &str) -> Result<(), SandboxBuildError> {
validate_absolute_normalized(Path::new(program)).map_err(|_| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidProcess,
"program is not a normalized absolute sandbox path",
)
})
}
fn validate_terminal_name(term: &str) -> Result<(), SandboxBuildError> {
let valid = !term.is_empty()
&& term.len() <= 64
&& term
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.' | b'_'));
if valid {
Ok(())
} else {
Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidTerminal,
"TERM violates the conservative terminal-name grammar",
))
}
}
fn validate_absolute_normalized(path: &Path) -> Result<(), SandboxBuildError> {
if !path.is_absolute()
|| path.as_os_str().as_bytes().contains(&0)
|| path
.components()
.any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
{
return Err(SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"path is not normalized and absolute",
));
}
utf8_path(path)?;
Ok(())
}
fn utf8_path(path: &Path) -> Result<&str, SandboxBuildError> {
path.to_str().ok_or_else(|| {
SandboxBuildError::new(
SandboxBuildErrorKind::InvalidPath,
"OCI JSON path is not UTF-8",
)
})
}
fn host_path(path: &lazaret_core::identity::HostPath) -> Result<PathBuf, SandboxBuildError> {
let path = PathBuf::from(OsString::from_vec(path.as_bytes().to_vec()));
validate_absolute_normalized(&path)?;
Ok(path)
}
fn hex_lower(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
use fmt::Write as _;
write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
}
output
}

View file

@ -0,0 +1,433 @@
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use lazaret_core::configuration::{
ConfigurationLoadRequest, ResolvedDomainConfiguration, load_trusted_configuration,
};
use lazaret_core::identity::{ContainerId, DeviceId, InodeId, NamespaceIdentity};
use lazaret_runtime_supervisor::sandbox::{
CREDENTIALS_DESTINATION, ESCALATION_CLIENT_DESTINATION, HostTerminal,
MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, RootProcess,
SandboxBuildErrorKind, SandboxBuildRequest, TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION,
build_sandbox_bundle, production_sandbox_layout,
};
use serde_json::Value;
use tempfile::TempDir;
struct Fixture {
tree: TempDir,
configuration: ResolvedDomainConfiguration,
terminfo_source: PathBuf,
terminfo_root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let tree = tempfile::tempdir().unwrap();
let configuration_directory = tree.path().join("configuration");
let workspace = tree.path().join("workspace");
let runtime = tree.path().join("runtime");
fs::create_dir(&configuration_directory).unwrap();
fs::create_dir(&workspace).unwrap();
fs::create_dir(&runtime).unwrap();
let configuration_path = configuration_directory.join("lazaret.toml");
fs::write(
&configuration_path,
format!(
r#"schema = "lazaret-trusted-host-config-v1"
[storage]
data_root = "{}"
[profiles.default]
image = "example.invalid/agent:v1"
persistent_dirs = ["/root"]
env = {{ SELECTED_SECRET = "env:SELECTED_HOST_SECRET" }}
volumes = [
{{ host = "{{profile_dir}}/read-only", container = "/root/read-only", kind = "directory", access = "read-only", create = true }},
{{ host = "{{profile_dir}}/read-write", container = "/root/read-write", kind = "directory", access = "read-write", create = true }},
]
[profiles.unselected]
image = "example.invalid/unselected:v1"
env = {{ UNSELECTED_SECRET = "env:UNSELECTED_HOST_SECRET" }}
"#,
tree.path().join("data").display()
),
)
.unwrap();
let environment = BTreeMap::from([
("SELECTED_HOST_SECRET".to_owned(), "selected-value".into()),
("UNSELECTED_HOST_SECRET".to_owned(), "must-not-enter".into()),
]);
let configuration = load_trusted_configuration(ConfigurationLoadRequest {
explicit_configuration_path: Some(configuration_path),
selected_profile: "default".to_owned(),
workspace,
home: tree.path().join("home"),
xdg_config_home: None,
xdg_data_home: None,
xdg_runtime_dir: runtime,
environment,
sandbox_layout: production_sandbox_layout().unwrap(),
})
.unwrap();
let terminfo_root = tree.path().join("trusted-terminfo");
let terminfo_source = terminfo_root.join("x").join("xterm-lazaret");
fs::create_dir_all(terminfo_source.parent().unwrap()).unwrap();
fs::write(&terminfo_source, b"compiled host terminfo capabilities").unwrap();
Self {
tree,
configuration,
terminfo_source,
terminfo_root,
}
}
fn terminal(&self) -> HostTerminal {
HostTerminal::from_trusted_roots(
"xterm-lazaret",
Some("truecolor".to_owned()),
&self.terminfo_source,
std::slice::from_ref(&self.terminfo_root),
)
.unwrap()
}
fn request(&self, root_process: RootProcess) -> SandboxBuildRequest<'_> {
SandboxBuildRequest {
configuration: &self.configuration,
container_id: ContainerId::new([0x11; 32]),
expected_network_namespace: current_network_namespace(),
root_process,
terminal: Some(self.terminal()),
}
}
}
fn current_network_namespace() -> NamespaceIdentity {
lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH)).unwrap()
}
fn root_process(arguments: impl IntoIterator<Item = String>) -> RootProcess {
RootProcess::new("/sbin/lazaret-sandbox-init", arguments).unwrap()
}
fn value(bundle: &lazaret_runtime_supervisor::sandbox::SandboxBundle) -> Value {
serde_json::from_slice(bundle.specification_json()).unwrap()
}
fn mounts(specification: &Value) -> BTreeMap<&str, &Value> {
specification["mounts"]
.as_array()
.unwrap()
.iter()
.map(|mount| (mount["destination"].as_str().unwrap(), mount))
.collect()
}
fn options(mount: &Value) -> BTreeSet<&str> {
mount["options"]
.as_array()
.unwrap()
.iter()
.map(|option| option.as_str().unwrap())
.collect()
}
#[test]
fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape() {
let fixture = Fixture::new();
let arguments = vec!["literal;$(touch /host)".to_owned(), "two words".to_owned()];
let first = build_sandbox_bundle(fixture.request(root_process(arguments.clone()))).unwrap();
let second = build_sandbox_bundle(fixture.request(root_process(arguments.clone()))).unwrap();
assert_eq!(first, second);
let specification = value(&first);
assert_eq!(specification["ociVersion"], "1.2.1");
assert_eq!(specification["hostname"], "lazaret");
assert_eq!(specification["process"]["terminal"], false);
assert_eq!(specification["process"]["noNewPrivileges"], true);
assert_eq!(specification["process"]["cwd"], WORKSPACE_DESTINATION);
assert_eq!(
specification["process"]["args"],
serde_json::json!([
"/sbin/lazaret-sandbox-init",
"literal;$(touch /host)",
"two words"
])
);
assert_eq!(
specification["process"]["capabilities"],
serde_json::json!({
"bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": []
})
);
let namespaces = specification["linux"]["namespaces"].as_array().unwrap();
assert_eq!(
namespaces
.iter()
.map(|namespace| namespace["type"].as_str().unwrap())
.collect::<Vec<_>>(),
["pid", "ipc", "uts", "mount", "user", "network"]
);
for namespace in &namespaces[..5] {
assert!(namespace.get("path").is_none());
}
assert_eq!(namespaces[5]["path"], NETWORK_NAMESPACE_PATH);
assert_eq!(
specification["linux"]["uidMappings"],
serde_json::json!([{ "hostID": 0, "containerID": 0, "size": 1 }])
);
assert_eq!(
specification["linux"]["gidMappings"],
serde_json::json!([{ "hostID": 0, "containerID": 0, "size": 1 }])
);
assert_eq!(first.command().program(), OsStr::new("runsc"));
let runtime_domain = String::from_utf8(
fixture
.configuration
.runtime_domain_root()
.canonical_label()
.as_bytes()
.to_vec(),
)
.unwrap();
assert_eq!(
first
.command()
.arguments()
.iter()
.map(|argument| argument.to_str().unwrap())
.collect::<Vec<_>>(),
[
format!("-root={runtime_domain}/runsc"),
"-ignore-cgroups".to_owned(),
"run".to_owned(),
format!(
"-bundle={}/bundles/{}",
String::from_utf8(
fixture
.configuration
.runtime_domain_root()
.canonical_label()
.as_bytes()
.to_vec()
)
.unwrap(),
"11".repeat(32)
),
"11".repeat(32),
]
);
assert!(!first.command().arguments().iter().any(|argument| {
matches!(
argument.to_str(),
Some("-detach" | "-console-socket" | "-c")
)
}));
}
#[test]
fn selected_environment_volume_modes_and_host_terminal_are_preserved() {
let fixture = Fixture::new();
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
let specification = value(&bundle);
let environment = specification["process"]["env"]
.as_array()
.unwrap()
.iter()
.map(|entry| entry.as_str().unwrap())
.collect::<BTreeSet<_>>();
assert!(environment.contains("SELECTED_SECRET=selected-value"));
assert!(environment.contains("TERM=xterm-lazaret"));
assert!(environment.contains("COLORTERM=truecolor"));
assert!(environment.contains("TERMINFO_DIRS=/run/lazaret/terminfo:"));
let mounts = mounts(&specification);
assert!(options(mounts[WORKSPACE_DESTINATION]).contains("rw"));
assert!(options(mounts["/root/read-only"]).contains("ro"));
assert!(options(mounts["/root/read-write"]).contains("rw"));
let terminfo = &mounts["/run/lazaret/terminfo/x/xterm-lazaret"];
assert_eq!(
terminfo["source"],
fixture.terminfo_source.display().to_string()
);
assert!(
options(terminfo).is_superset(&BTreeSet::from(["bind", "ro", "nodev", "nosuid", "noexec"]))
);
assert!(options(mounts[CREDENTIALS_DESTINATION]).contains("ro"));
assert!(options(mounts[MAILBOX_REQUESTS_DESTINATION]).contains("rw"));
assert!(options(mounts[MAILBOX_RESULTS_DESTINATION]).contains("ro"));
assert!(options(mounts[ESCALATION_CLIENT_DESTINATION]).contains("ro"));
assert!(!options(mounts[ESCALATION_CLIENT_DESTINATION]).contains("noexec"));
}
#[test]
fn configuration_document_unselected_secret_and_unselected_host_paths_do_not_enter_bundle() {
let fixture = Fixture::new();
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
let bytes = bundle.specification_json();
for forbidden in [
b"lazaret-trusted-host-config-v1".as_slice(),
b"UNSELECTED_SECRET".as_slice(),
b"must-not-enter".as_slice(),
b"example.invalid/unselected:v1".as_slice(),
b"configuration/lazaret.toml".as_slice(),
] {
assert!(
!bytes
.windows(forbidden.len())
.any(|window| window == forbidden)
);
}
let specification = value(&bundle);
let allowed_external_sources = BTreeSet::from([
fixture.terminfo_source.display().to_string(),
String::from_utf8(
fixture
.configuration
.workspace()
.canonical_label()
.as_bytes()
.to_vec(),
)
.unwrap(),
String::from_utf8(
fixture.configuration.volumes()[0]
.record()
.canonical_host_source
.as_bytes()
.to_vec(),
)
.unwrap(),
String::from_utf8(
fixture.configuration.volumes()[1]
.record()
.canonical_host_source
.as_bytes()
.to_vec(),
)
.unwrap(),
]);
let runtime_domain = String::from_utf8(
fixture
.configuration
.runtime_domain_root()
.canonical_label()
.as_bytes()
.to_vec(),
)
.unwrap();
for mount in specification["mounts"].as_array().unwrap() {
let Some(source) = mount.get("source").and_then(Value::as_str) else {
continue;
};
if source.starts_with('/') {
assert!(
allowed_external_sources.contains(source) || source.starts_with(&runtime_domain)
);
}
}
}
#[test]
fn hostile_terminal_names_sources_and_changed_entries_fail_closed() {
let fixture = Fixture::new();
for term in ["../xterm", "xterm/name", "xterm\nname", "", &"x".repeat(65)] {
let error = HostTerminal::from_trusted_roots(
term,
None,
&fixture.terminfo_source,
std::slice::from_ref(&fixture.terminfo_root),
)
.unwrap_err();
assert_eq!(error.kind(), SandboxBuildErrorKind::InvalidTerminal);
}
let outside = fixture.tree.path().join("outside").join("xterm-lazaret");
fs::create_dir(outside.parent().unwrap()).unwrap();
fs::write(&outside, b"outside").unwrap();
assert!(
HostTerminal::from_trusted_roots(
"xterm-lazaret",
None,
&outside,
std::slice::from_ref(&fixture.terminfo_root),
)
.is_err()
);
let terminal = fixture.terminal();
fs::remove_file(&fixture.terminfo_source).unwrap();
fs::write(&fixture.terminfo_source, b"replacement").unwrap();
let error = build_sandbox_bundle(SandboxBuildRequest {
configuration: &fixture.configuration,
container_id: ContainerId::new([0x11; 32]),
expected_network_namespace: current_network_namespace(),
root_process: root_process([]),
terminal: Some(terminal),
})
.unwrap_err();
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
}
#[test]
fn invalid_processes_and_wrong_network_identity_fail_closed() {
assert!(RootProcess::new("bin/agent", []).is_err());
assert!(RootProcess::new("/bin/agent", ["nul\0argument".to_owned()]).is_err());
let fixture = Fixture::new();
for literal in [
"$(touch /host)",
"`touch /host`",
"a; touch /host",
"a && touch /host",
"a | touch /host",
"*.secret",
"$HOME",
] {
let bundle =
build_sandbox_bundle(fixture.request(root_process([literal.to_owned()]))).unwrap();
assert_eq!(
value(&bundle)["process"]["args"],
serde_json::json!(["/sbin/lazaret-sandbox-init", literal])
);
}
let error = build_sandbox_bundle(SandboxBuildRequest {
configuration: &fixture.configuration,
container_id: ContainerId::new([0x11; 32]),
expected_network_namespace: NamespaceIdentity {
device: DeviceId::new(u64::MAX),
inode: InodeId::new(u64::MAX),
},
root_process: root_process([]),
terminal: Some(fixture.terminal()),
})
.unwrap_err();
assert_eq!(
error.kind(),
SandboxBuildErrorKind::NetworkNamespaceMismatch
);
}
#[test]
fn production_layout_reserves_every_mount_root() {
let layout = production_sandbox_layout().unwrap();
assert_eq!(
layout.protected_destinations(),
&BTreeSet::from([
"/dev".to_owned(),
"/proc".to_owned(),
"/run/lazaret".to_owned(),
"/sys".to_owned(),
"/workspace".to_owned(),
])
);
assert_eq!(TERMINFO_ROOT_DESTINATION, "/run/lazaret/terminfo");
}