harn-vm 0.10.175

Async bytecode virtual machine for the Harn programming language
//! Cases for the namespace handover that can be read in-process.
//!
//! Kept beside the backend cases because they need the same Linux policy
//! fixtures, and separate from them because this one asserts a different kind
//! of claim: it names a step that fails silently and carries the control that
//! proves it can fail.
//!
//! The other half of the handover cannot be read here at all. Whether the
//! ruleset descriptor survives the `exec` is only answerable from the far
//! side of one, so that case runs a real helper process and lives in
//! `tests/harn_vm/namespace_handover.rs`.

use super::tests::linux_policy_with_workspace_ops;
use super::*;

/// A loopback grant admits the socket calls, and cannot do so outside a
/// namespace.
///
/// Both halves are the claim. Without the first, the grant is worth nothing:
/// a filter with no `socket` term refuses loopback exactly as hard as it
/// refuses the internet, so the build tool the grant was opened for still
/// cannot start while every layer above reports a working sandbox — a
/// boundary that refuses everything reads as a boundary that works.
///
/// Without the second, the first is a hole. Those terms carry no address
/// condition, so on the host network they would admit an outbound connection
/// anywhere. What makes them safe is that the child has no route off the
/// host, and the only thing guaranteeing that is the namespace. So the test
/// asserts the widened filter is unreachable without one: the same grant with
/// no helper to build the namespace is refused, rather than rendered with the
/// wider filter and no boundary.
#[test]
fn a_loopback_grant_admits_sockets_only_where_a_namespace_bounds_them() {
    let mut policy = linux_policy_with_workspace_ops(&["read_text"]);
    policy.side_effect_level = Some("process_exec".to_string());
    assert!(
        !allowed_syscalls(&policy).contains(&libc::SYS_socket),
        "the premise is that this policy admits no socket calls before the grant",
    );

    policy.process_sandbox.allow_tcp_loopback = true;
    policy.process_sandbox.netns_launcher_path = Some("/opt/example/launch".to_string());
    let granted = allowed_syscalls(&policy);
    for syscall in [
        libc::SYS_socket,
        libc::SYS_bind,
        libc::SYS_listen,
        libc::SYS_connect,
        libc::SYS_accept,
    ] {
        assert!(
            granted.contains(&syscall),
            "a loopback grant must admit the calls a loopback client needs",
        );
    }

    // NEGATIVE CONTROL: the same grant with nothing to build the namespace.
    // It must be refused, because the filter it would otherwise be rendered
    // with places no limit on where a socket may reach.
    let mut unnamespaced = policy;
    unnamespaced.process_sandbox.netns_launcher_path = None;
    resolve_netns_launcher(&unnamespaced)
        .expect_err("a loopback grant with no namespace helper must be refused, never widened");
}

/// The private `/tmp` mount hides every host path under `/tmp`, so a child
/// granted anything there keeps the host's: its workspace, or the program it
/// was spawned to run.
#[test]
fn a_grant_under_tmp_keeps_the_host_tmp() {
    if !super::tests::live_landlock_available("a_grant_under_tmp_keeps_the_host_tmp") {
        return;
    }
    // Tests that point `HOME` under `/tmp` hold this lock; HOME-derived grants
    // would otherwise land under `/tmp` mid-run.
    let _env_lock = crate::runtime_paths::test_env_lock()
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    let support = landlock_support::host_support().expect("Landlock support");
    let home = PathBuf::from(std::env::var_os("HOME").expect("HOME"));
    let under_tmp = tempfile::tempdir_in("/tmp").unwrap();
    let tmp_program = under_tmp.path().join("tool");
    std::fs::copy("/bin/true", &tmp_program).unwrap();
    let cases = [
        (home.clone(), PathBuf::from("/bin/true"), true),
        (PathBuf::from("/tmp"), PathBuf::from("/bin/true"), false),
        (home, tmp_program, false),
    ];
    for (parent, program, mounted) in cases {
        let workspace = tempfile::tempdir_in(&parent).unwrap();
        let mut policy = linux_policy_with_workspace_ops(&["read_text", "write_text"]);
        policy.workspace_roots = vec![workspace.path().display().to_string()];
        policy.process_sandbox.allow_tcp_loopback = true;
        let profile = landlock_profile_for(&program.display().to_string(), &policy, support)
            .expect("profile");
        assert_eq!(
            profile.private_tmp.is_some(),
            mounted,
            "workspace under {}, program {}",
            parent.display(),
            program.display()
        );
    }
}