Build deterministic OCI sandbox specifications

This commit is contained in:
ms2k 2026-07-15 14:59:43 +09:00
commit cf5948e8be

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");
}