use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsStr; use std::fs; use std::os::unix::ffi::OsStrExt; 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, SANDBOX_INIT_DESTINATION, SandboxBuildErrorKind, SandboxBuildRequest, TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION, build_sandbox_bundle, production_sandbox_layout, stage_host_terminal, }; use serde_json::Value; use tempfile::TempDir; struct Fixture { tree: TempDir, 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(); fs::create_dir(tree.path().join("prepared-root")).unwrap(); let configuration_path = configuration_directory.join("lazaret.toml"); fs::write( &configuration_path, format!( r#"schema = "lazaret-trusted-host-config-v1" [storage] data_root = "{}" [network] default_allow = ["192.0.2.10/32"] [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.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" }}] [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_source = tree .path() .join("external-host-terminfo") .join("x") .join("xterm-lazaret"); fs::create_dir_all(terminfo_source.parent().unwrap()).unwrap(); fs::write(&terminfo_source, compiled_terminfo("xterm-lazaret")).unwrap(); Self { tree, configuration, terminfo_source, } } fn terminal(&self) -> lazaret_runtime_supervisor::sandbox::StagedHostTerminal { let terminal = HostTerminal::from_compiled_entry( "xterm-lazaret", Some("truecolor".to_owned()), fs::read(&self.terminfo_source).unwrap(), ) .unwrap(); stage_host_terminal(&self.configuration, ContainerId::new([0x11; 32]), terminal).unwrap() } 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() } 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") } 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()), prepared_root: self.tree.path().join("prepared-root"), } } 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(), format!("runsc-overlay/{container}"), "state-anchor/merged/root".to_owned(), "state-anchor/runtime-files".to_owned(), 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(); } for name in ["resolv.conf", "hosts", "hostname"] { fs::write( runtime.join("state-anchor/runtime-files").join(name), b"fixture", ) .unwrap(); } 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(); fs::write( runtime.join(format!( "sandbox-control/{container}/bin/lazaret-sandbox-init" )), b"executable", ) .unwrap(); } 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)) } fn compiled_terminfo(term: &str) -> Vec { 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 } fn current_network_namespace() -> NamespaceIdentity { lazaret_linux::filesystem::namespace_identity(Path::new(NETWORK_NAMESPACE_PATH)).unwrap() } fn root_process(arguments: impl IntoIterator) -> 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::>(), ["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::>(), [ format!("-root={runtime_domain}/runsc"), "-rootless".to_owned(), "-network=host".to_owned(), "-ignore-cgroups".to_owned(), format!( "-overlay2=root:dir={runtime_domain}/runsc-overlay/{}", "11".repeat(32) ), "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_eq!( specification["root"]["path"], fixture .tree .path() .join("prepared-root") .display() .to_string() ); assert!(!first.command().arguments().iter().any(|argument| { matches!( argument.to_str(), Some("-detach" | "-console-socket" | "-c") ) })); } #[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!([{ "hostID": 0, "containerID": 0, "size": 1 }]) ); 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() ); } #[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::>(); 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"]).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.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()) ); 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")); 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}")) ); } } #[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)] { let error = HostTerminal::from_compiled_entry(term, None, compiled_terminfo("xterm-lazaret")) .unwrap_err(); assert_eq!(error.kind(), SandboxBuildErrorKind::InvalidTerminal); } assert!( HostTerminal::from_compiled_entry("passwd", None, fs::read("/etc/passwd").unwrap()) .is_err() ); assert!( HostTerminal::from_compiled_entry( "other-terminal", None, compiled_terminfo("xterm-lazaret") ) .is_err() ); 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()); } let terminal = fixture.terminal(); fs::remove_file(fixture.staged_terminfo_source()).unwrap(); fs::write( fixture.staged_terminfo_source(), compiled_terminfo("xterm-lazaret"), ) .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), prepared_root: fixture.tree.path().join("prepared-root"), }) .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()), prepared_root: fixture.tree.path().join("prepared-root"), }) .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"); } #[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(); let rootfs = fixture.tree.path().join("prepared-root"); fs::rename(&rootfs, fixture.tree.path().join("prepared-root-selected")).unwrap(); 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"); }