2026-07-15 14:59:43 +09:00
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
|
use std::ffi::OsStr;
|
|
|
|
|
use std::fs;
|
2026-07-15 15:16:25 +09:00
|
|
|
use std::os::unix::ffi::OsStrExt;
|
2026-07-15 14:59:43 +09:00
|
|
|
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,
|
2026-07-17 04:40:02 +09:00
|
|
|
SANDBOX_INIT_DESTINATION, SandboxBuildErrorKind, SandboxBuildRequest, SandboxBundle,
|
2026-07-16 20:41:40 +09:00
|
|
|
TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION, build_sandbox_bundle,
|
|
|
|
|
production_sandbox_layout, stage_host_terminal,
|
2026-07-15 14:59:43 +09:00
|
|
|
};
|
|
|
|
|
use serde_json::Value;
|
|
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
struct Fixture {
|
2026-07-16 15:20:13 +09:00
|
|
|
tree: TempDir,
|
2026-07-15 14:59:43 +09:00
|
|
|
configuration: ResolvedDomainConfiguration,
|
|
|
|
|
terminfo_source: 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();
|
2026-07-17 03:58:27 +09:00
|
|
|
fs::create_dir(tree.path().join("prepared-root")).unwrap();
|
2026-07-15 14:59:43 +09:00
|
|
|
let configuration_path = configuration_directory.join("lazaret.toml");
|
|
|
|
|
fs::write(
|
|
|
|
|
&configuration_path,
|
|
|
|
|
format!(
|
|
|
|
|
r#"schema = "lazaret-trusted-host-config-v1"
|
|
|
|
|
|
|
|
|
|
[storage]
|
|
|
|
|
data_root = "{}"
|
|
|
|
|
|
2026-07-16 20:41:40 +09:00
|
|
|
[network]
|
|
|
|
|
default_allow = ["192.0.2.10/32"]
|
|
|
|
|
|
2026-07-15 14:59:43 +09:00
|
|
|
[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 }},
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-16 20:41:40 +09:00
|
|
|
[profiles.default.readiness]
|
|
|
|
|
denied = [{{ address = "10.20.30.40", port = 443 }}]
|
|
|
|
|
permitted = [{{ address = "192.0.2.10", port = 443 }}]
|
|
|
|
|
dns = [{{ resolver = "198.51.100.53", query = "example.invalid" }}]
|
|
|
|
|
|
2026-07-15 14:59:43 +09:00
|
|
|
[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();
|
2026-07-15 15:16:25 +09:00
|
|
|
let terminfo_source = tree
|
|
|
|
|
.path()
|
|
|
|
|
.join("external-host-terminfo")
|
|
|
|
|
.join("x")
|
|
|
|
|
.join("xterm-lazaret");
|
2026-07-15 14:59:43 +09:00
|
|
|
fs::create_dir_all(terminfo_source.parent().unwrap()).unwrap();
|
2026-07-15 15:16:25 +09:00
|
|
|
fs::write(&terminfo_source, compiled_terminfo("xterm-lazaret")).unwrap();
|
2026-07-15 14:59:43 +09:00
|
|
|
Self {
|
2026-07-16 15:20:13 +09:00
|
|
|
tree,
|
2026-07-15 14:59:43 +09:00
|
|
|
configuration,
|
|
|
|
|
terminfo_source,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:16:25 +09:00
|
|
|
fn terminal(&self) -> lazaret_runtime_supervisor::sandbox::StagedHostTerminal {
|
|
|
|
|
let terminal = HostTerminal::from_compiled_entry(
|
2026-07-15 14:59:43 +09:00
|
|
|
"xterm-lazaret",
|
|
|
|
|
Some("truecolor".to_owned()),
|
2026-07-15 15:16:25 +09:00
|
|
|
fs::read(&self.terminfo_source).unwrap(),
|
2026-07-15 14:59:43 +09:00
|
|
|
)
|
2026-07-15 15:16:25 +09:00
|
|
|
.unwrap();
|
|
|
|
|
stage_host_terminal(&self.configuration, ContainerId::new([0x11; 32]), terminal).unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 15:20:13 +09:00
|
|
|
fn reload_configuration(&self) -> ResolvedDomainConfiguration {
|
|
|
|
|
let root = self.tree.path();
|
|
|
|
|
load_trusted_configuration(ConfigurationLoadRequest {
|
|
|
|
|
explicit_configuration_path: Some(root.join("configuration/lazaret.toml")),
|
|
|
|
|
selected_profile: "default".to_owned(),
|
|
|
|
|
workspace: root.join("workspace"),
|
|
|
|
|
home: root.join("home"),
|
|
|
|
|
xdg_config_home: None,
|
|
|
|
|
xdg_data_home: None,
|
|
|
|
|
xdg_runtime_dir: root.join("runtime"),
|
|
|
|
|
environment: BTreeMap::from([
|
|
|
|
|
("SELECTED_HOST_SECRET".to_owned(), "selected-value".into()),
|
|
|
|
|
("UNSELECTED_HOST_SECRET".to_owned(), "must-not-enter".into()),
|
|
|
|
|
]),
|
|
|
|
|
sandbox_layout: production_sandbox_layout().unwrap(),
|
|
|
|
|
})
|
|
|
|
|
.unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:16:25 +09:00
|
|
|
fn staged_terminfo_source(&self) -> PathBuf {
|
|
|
|
|
PathBuf::from(
|
|
|
|
|
String::from_utf8(
|
|
|
|
|
self.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes()
|
|
|
|
|
.to_vec(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap(),
|
|
|
|
|
)
|
|
|
|
|
.join("sandbox-control")
|
|
|
|
|
.join("11".repeat(32))
|
|
|
|
|
.join("terminfo/x/xterm-lazaret")
|
2026-07-15 14:59:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()),
|
2026-07-17 03:58:27 +09:00
|
|
|
prepared_root: self.tree.path().join("prepared-root"),
|
2026-07-15 14:59:43 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-16 15:20:13 +09:00
|
|
|
|
|
|
|
|
fn stage_handoff(&self, bundle: &lazaret_runtime_supervisor::sandbox::SandboxBundle) {
|
|
|
|
|
let runtime = host_path(
|
|
|
|
|
self.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
);
|
|
|
|
|
let container = "11".repeat(32);
|
|
|
|
|
for directory in [
|
|
|
|
|
format!("bundles/{container}"),
|
|
|
|
|
"runsc".to_owned(),
|
2026-07-17 03:58:27 +09:00
|
|
|
format!("runsc-overlay/{container}"),
|
|
|
|
|
"state-anchor/merged/root".to_owned(),
|
|
|
|
|
"state-anchor/runtime-files".to_owned(),
|
2026-07-16 15:20:13 +09:00
|
|
|
format!("sandbox-control/{container}/credentials"),
|
|
|
|
|
format!("sandbox-control/{container}/mailbox/requests"),
|
|
|
|
|
format!("sandbox-control/{container}/mailbox/results"),
|
|
|
|
|
format!("sandbox-control/{container}/bin"),
|
|
|
|
|
] {
|
|
|
|
|
fs::create_dir_all(runtime.join(directory)).unwrap();
|
|
|
|
|
}
|
2026-07-17 03:58:27 +09:00
|
|
|
for name in ["resolv.conf", "hosts", "hostname"] {
|
|
|
|
|
fs::write(
|
|
|
|
|
runtime.join("state-anchor/runtime-files").join(name),
|
|
|
|
|
b"fixture",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
}
|
2026-07-16 15:20:13 +09:00
|
|
|
fs::write(
|
|
|
|
|
runtime.join(format!("bundles/{container}/config.json")),
|
|
|
|
|
bundle.specification_json(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
runtime.join(format!("sandbox-control/{container}/bin/lazaret-escalate")),
|
|
|
|
|
b"executable",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-07-16 20:41:40 +09:00
|
|
|
fs::write(
|
|
|
|
|
runtime.join(format!(
|
|
|
|
|
"sandbox-control/{container}/bin/lazaret-sandbox-init"
|
|
|
|
|
)),
|
|
|
|
|
b"executable",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-07-16 15:20:13 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn bundle_config_path(&self) -> PathBuf {
|
|
|
|
|
host_path(
|
|
|
|
|
self.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
)
|
|
|
|
|
.join("bundles")
|
|
|
|
|
.join("11".repeat(32))
|
|
|
|
|
.join("config.json")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn host_path(bytes: &[u8]) -> PathBuf {
|
|
|
|
|
PathBuf::from(OsStr::from_bytes(bytes))
|
2026-07-15 14:59:43 +09:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 15:16:25 +09:00
|
|
|
fn compiled_terminfo(term: &str) -> Vec<u8> {
|
|
|
|
|
let names = format!("{term}|Lazaret test terminal\0").into_bytes();
|
|
|
|
|
let mut bytes = Vec::with_capacity(12 + names.len());
|
|
|
|
|
for value in [0x011a_u16, u16::try_from(names.len()).unwrap(), 0, 0, 0, 0] {
|
|
|
|
|
bytes.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
bytes.extend_from_slice(&names);
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 14:59:43 +09:00
|
|
|
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"],
|
2026-07-16 20:41:40 +09:00
|
|
|
serde_json::json!([{
|
2026-07-17 03:58:27 +09:00
|
|
|
"hostID": 0, "containerID": 0, "size": 1
|
2026-07-16 20:41:40 +09:00
|
|
|
}])
|
2026-07-15 14:59:43 +09:00
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
specification["linux"]["gidMappings"],
|
2026-07-16 20:41:40 +09:00
|
|
|
serde_json::json!([{
|
2026-07-17 03:58:27 +09:00
|
|
|
"hostID": 0, "containerID": 0, "size": 1
|
2026-07-16 20:41:40 +09:00
|
|
|
}])
|
2026-07-15 14:59:43 +09:00
|
|
|
);
|
|
|
|
|
|
2026-07-17 04:40:02 +09:00
|
|
|
assert_runtime_command(&first, &fixture);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
specification["root"]["path"],
|
|
|
|
|
fixture
|
|
|
|
|
.tree
|
|
|
|
|
.path()
|
|
|
|
|
.join("prepared-root")
|
|
|
|
|
.display()
|
|
|
|
|
.to_string()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn assert_runtime_command(bundle: &SandboxBundle, fixture: &Fixture) {
|
|
|
|
|
assert_eq!(bundle.command().program(), OsStr::new("runsc"));
|
2026-07-15 14:59:43 +09:00
|
|
|
let runtime_domain = String::from_utf8(
|
|
|
|
|
fixture
|
|
|
|
|
.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes()
|
|
|
|
|
.to_vec(),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(
|
2026-07-17 04:40:02 +09:00
|
|
|
bundle
|
2026-07-15 14:59:43 +09:00
|
|
|
.command()
|
|
|
|
|
.arguments()
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|argument| argument.to_str().unwrap())
|
|
|
|
|
.collect::<Vec<_>>(),
|
|
|
|
|
[
|
|
|
|
|
format!("-root={runtime_domain}/runsc"),
|
2026-07-15 19:09:24 +09:00
|
|
|
"-rootless".to_owned(),
|
|
|
|
|
"-network=host".to_owned(),
|
2026-07-15 14:59:43 +09:00
|
|
|
"-ignore-cgroups".to_owned(),
|
2026-07-17 03:58:27 +09:00
|
|
|
format!(
|
|
|
|
|
"-overlay2=root:dir={runtime_domain}/runsc-overlay/{}",
|
|
|
|
|
"11".repeat(32)
|
|
|
|
|
),
|
2026-07-15 14:59:43 +09:00
|
|
|
"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),
|
|
|
|
|
]
|
|
|
|
|
);
|
2026-07-17 04:40:02 +09:00
|
|
|
assert!(!bundle.command().arguments().iter().any(|argument| {
|
2026-07-15 14:59:43 +09:00
|
|
|
matches!(
|
|
|
|
|
argument.to_str(),
|
|
|
|
|
Some("-detach" | "-console-socket" | "-c")
|
|
|
|
|
)
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 20:41:40 +09:00
|
|
|
#[test]
|
|
|
|
|
fn product_init_role_has_exact_terminal_neutral_rootless_bundle_shape() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let root = RootProcess::new(
|
|
|
|
|
SANDBOX_INIT_DESTINATION,
|
|
|
|
|
["--lazaret-private-sandbox-init-v1".to_owned()],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let specification = value(&build_sandbox_bundle(fixture.request(root)).unwrap());
|
|
|
|
|
assert_eq!(specification["process"]["terminal"], false);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
specification["process"]["args"],
|
|
|
|
|
serde_json::json!([
|
|
|
|
|
SANDBOX_INIT_DESTINATION,
|
|
|
|
|
"--lazaret-private-sandbox-init-v1"
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(specification["process"]["user"]["uid"], 0);
|
|
|
|
|
assert_eq!(specification["process"]["user"]["gid"], 0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
specification["linux"]["uidMappings"],
|
|
|
|
|
serde_json::json!([{
|
2026-07-17 03:58:27 +09:00
|
|
|
"hostID": 0, "containerID": 0, "size": 1
|
2026-07-16 20:41:40 +09:00
|
|
|
}])
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
mounts(&specification)[SANDBOX_INIT_DESTINATION]["source"],
|
|
|
|
|
host_path(
|
|
|
|
|
fixture
|
|
|
|
|
.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
)
|
|
|
|
|
.join("sandbox-control")
|
|
|
|
|
.join("11".repeat(32))
|
|
|
|
|
.join("bin/lazaret-sandbox-init")
|
|
|
|
|
.display()
|
|
|
|
|
.to_string()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 14:59:43 +09:00
|
|
|
#[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"));
|
2026-07-17 03:58:27 +09:00
|
|
|
assert!(options(mounts["/root"]).contains("rw"));
|
2026-07-15 14:59:43 +09:00
|
|
|
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"],
|
2026-07-15 15:16:25 +09:00
|
|
|
fixture.staged_terminfo_source().display().to_string()
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read(fixture.staged_terminfo_source()).unwrap(),
|
|
|
|
|
fs::read(&fixture.terminfo_source).unwrap()
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!bundle
|
|
|
|
|
.specification_json()
|
|
|
|
|
.windows(fixture.terminfo_source.as_os_str().as_bytes().len())
|
|
|
|
|
.any(|window| window == fixture.terminfo_source.as_os_str().as_bytes())
|
2026-07-15 14:59:43 +09:00
|
|
|
);
|
|
|
|
|
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"));
|
2026-07-17 03:58:27 +09:00
|
|
|
for name in ["resolv.conf", "hosts", "hostname"] {
|
|
|
|
|
let destination = format!("/etc/{name}");
|
|
|
|
|
let mount = &mounts[destination.as_str()];
|
|
|
|
|
assert!(options(mount).contains("ro"));
|
|
|
|
|
assert!(
|
|
|
|
|
mount["source"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.ends_with(&format!("state-anchor/runtime-files/{name}"))
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-15 14:59:43 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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([
|
|
|
|
|
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)] {
|
2026-07-15 15:16:25 +09:00
|
|
|
let error =
|
|
|
|
|
HostTerminal::from_compiled_entry(term, None, compiled_terminfo("xterm-lazaret"))
|
|
|
|
|
.unwrap_err();
|
2026-07-15 14:59:43 +09:00
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::InvalidTerminal);
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
2026-07-15 15:16:25 +09:00
|
|
|
HostTerminal::from_compiled_entry("passwd", None, fs::read("/etc/passwd").unwrap())
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
HostTerminal::from_compiled_entry(
|
|
|
|
|
"other-terminal",
|
2026-07-15 14:59:43 +09:00
|
|
|
None,
|
2026-07-15 15:16:25 +09:00
|
|
|
compiled_terminfo("xterm-lazaret")
|
2026-07-15 14:59:43 +09:00
|
|
|
)
|
|
|
|
|
.is_err()
|
|
|
|
|
);
|
2026-07-15 15:16:25 +09:00
|
|
|
for invalid in [vec![], b"not compiled terminfo".to_vec(), vec![0_u8; 12]] {
|
|
|
|
|
assert!(HostTerminal::from_compiled_entry("xterm-lazaret", None, invalid).is_err());
|
|
|
|
|
}
|
2026-07-15 14:59:43 +09:00
|
|
|
|
|
|
|
|
let terminal = fixture.terminal();
|
2026-07-15 15:16:25 +09:00
|
|
|
fs::remove_file(fixture.staged_terminfo_source()).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
fixture.staged_terminfo_source(),
|
|
|
|
|
compiled_terminfo("xterm-lazaret"),
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-07-15 14:59:43 +09:00
|
|
|
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),
|
2026-07-17 03:58:27 +09:00
|
|
|
prepared_root: fixture.tree.path().join("prepared-root"),
|
2026-07-15 14:59:43 +09:00
|
|
|
})
|
|
|
|
|
.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()),
|
2026-07-17 03:58:27 +09:00
|
|
|
prepared_root: fixture.tree.path().join("prepared-root"),
|
2026-07-15 14:59:43 +09:00
|
|
|
})
|
|
|
|
|
.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");
|
|
|
|
|
}
|
2026-07-16 15:20:13 +09:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn final_handoff_refuses_replaced_workspace_and_runtime_sources() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let handoff = bundle
|
|
|
|
|
.capture_handoff_authority(&fixture.configuration)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let workspace = host_path(
|
|
|
|
|
fixture
|
|
|
|
|
.configuration
|
|
|
|
|
.workspace()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
);
|
|
|
|
|
fs::rename(&workspace, workspace.with_extension("selected")).unwrap();
|
|
|
|
|
fs::create_dir(&workspace).unwrap();
|
|
|
|
|
fs::write(workspace.join("replacement-marker"), b"unchanged").unwrap();
|
|
|
|
|
let error = handoff.revalidate().unwrap_err();
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read(workspace.join("replacement-marker")).unwrap(),
|
|
|
|
|
b"unchanged"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let handoff = bundle
|
|
|
|
|
.capture_handoff_authority(&fixture.configuration)
|
|
|
|
|
.unwrap();
|
2026-07-17 03:58:27 +09:00
|
|
|
let rootfs = fixture.tree.path().join("prepared-root");
|
|
|
|
|
fs::rename(&rootfs, fixture.tree.path().join("prepared-root-selected")).unwrap();
|
2026-07-16 15:20:13 +09:00
|
|
|
fs::create_dir(&rootfs).unwrap();
|
|
|
|
|
fs::write(rootfs.join("replacement-marker"), b"unchanged").unwrap();
|
|
|
|
|
let error = handoff.revalidate().unwrap_err();
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read(rootfs.join("replacement-marker")).unwrap(),
|
|
|
|
|
b"unchanged"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reloaded_same_label_configuration_cannot_replace_bundle_authority() {
|
|
|
|
|
for selected in ["workspace", "volume", "runtime-domain"] {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let path = match selected {
|
|
|
|
|
"workspace" => host_path(
|
|
|
|
|
fixture
|
|
|
|
|
.configuration
|
|
|
|
|
.workspace()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
),
|
|
|
|
|
"volume" => host_path(
|
|
|
|
|
fixture.configuration.volumes()[0]
|
|
|
|
|
.record()
|
|
|
|
|
.canonical_host_source
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
),
|
|
|
|
|
"runtime-domain" => host_path(
|
|
|
|
|
fixture
|
|
|
|
|
.configuration
|
|
|
|
|
.runtime_domain_root()
|
|
|
|
|
.canonical_label()
|
|
|
|
|
.as_bytes(),
|
|
|
|
|
),
|
|
|
|
|
_ => unreachable!(),
|
|
|
|
|
};
|
|
|
|
|
let selected_path = path.with_extension(format!("{selected}-selected"));
|
|
|
|
|
fs::rename(&path, &selected_path).unwrap();
|
|
|
|
|
fs::create_dir(&path).unwrap();
|
|
|
|
|
fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap();
|
|
|
|
|
let marker = path.join("replacement-marker");
|
|
|
|
|
fs::write(&marker, b"unchanged").unwrap();
|
|
|
|
|
let reloaded = fixture.reload_configuration();
|
|
|
|
|
let Err(error) = bundle.capture_handoff_authority(&reloaded) else {
|
|
|
|
|
panic!("reloaded {selected} replacement was accepted");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(fs::read(marker).unwrap(), b"unchanged");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn post_build_terminfo_replacement_cannot_become_handoff_authority() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
let terminfo = fixture.staged_terminfo_source();
|
|
|
|
|
fs::rename(&terminfo, terminfo.with_extension("selected")).unwrap();
|
|
|
|
|
fs::write(&terminfo, b"replacement remains unchanged").unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let Err(error) = bundle.capture_handoff_authority(&fixture.configuration) else {
|
|
|
|
|
panic!("post-build terminfo replacement was accepted");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read(terminfo).unwrap(),
|
|
|
|
|
b"replacement remains unchanged"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bundle_authorization_distinguishes_equal_runsc_argv() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let first = build_sandbox_bundle(fixture.request(root_process(["first".to_owned()]))).unwrap();
|
|
|
|
|
let second =
|
|
|
|
|
build_sandbox_bundle(fixture.request(root_process(["second".to_owned()]))).unwrap();
|
|
|
|
|
assert_eq!(first.command().program(), second.command().program());
|
|
|
|
|
assert_eq!(first.command().arguments(), second.command().arguments());
|
|
|
|
|
assert_ne!(first.command(), second.command());
|
|
|
|
|
fixture.stage_handoff(&first);
|
|
|
|
|
let handoff = first
|
|
|
|
|
.capture_handoff_authority(&fixture.configuration)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(!handoff.matches_command(second.command()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn staged_oci_configuration_must_match_bundle_at_capture_and_release() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let config = fixture.bundle_config_path();
|
|
|
|
|
fs::write(&config, b"pre-capture replacement").unwrap();
|
|
|
|
|
let Err(error) = bundle.capture_handoff_authority(&fixture.configuration) else {
|
|
|
|
|
panic!("mutated pre-capture OCI configuration was accepted");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(fs::read(&config).unwrap(), b"pre-capture replacement");
|
|
|
|
|
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let bundle = build_sandbox_bundle(fixture.request(root_process([]))).unwrap();
|
|
|
|
|
fixture.stage_handoff(&bundle);
|
|
|
|
|
let handoff = bundle
|
|
|
|
|
.capture_handoff_authority(&fixture.configuration)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let config = fixture.bundle_config_path();
|
|
|
|
|
fs::write(&config, b"post-capture replacement").unwrap();
|
|
|
|
|
let error = handoff.revalidate().unwrap_err();
|
|
|
|
|
assert_eq!(error.kind(), SandboxBuildErrorKind::ChangedAuthority);
|
|
|
|
|
assert_eq!(fs::read(config).unwrap(), b"post-capture replacement");
|
|
|
|
|
}
|