diff --git a/judge/crates/sandbox/src/acceptance.rs b/judge/crates/sandbox/src/acceptance.rs index 78ef0bd..74a7112 100644 --- a/judge/crates/sandbox/src/acceptance.rs +++ b/judge/crates/sandbox/src/acceptance.rs @@ -7,6 +7,7 @@ use crate::{ spec::{ExecutionSpec, SpecPolicy}, }; use std::{fs, io, io::Read, os::unix::fs::PermissionsExt, path::Path}; +mod normal; pub(crate) fn verify_guest(require_env: bool) -> io::Result<()> { if (require_env && std::env::var("KF_SANDBOX_INDEPENDENT_VM").as_deref() != Ok("1")) || fs::read_to_string("/kf/INDEPENDENT_VM")?.trim() != "isolated-test-guest" @@ -34,6 +35,37 @@ struct Fixture { cg: String, publications: String, } +impl Fixture { + fn new(program: &str) -> io::Result { + let cg = format!("/sys/fs/cgroup/kf-acceptance-{}", std::process::id()); + let publications = format!("/kf/execution-publications-{}", std::process::id()); + fs::create_dir(&cg)?; + fs::create_dir(&publications)?; + fs::set_permissions(&cg, fs::Permissions::from_mode(0o700))?; + fs::set_permissions(&publications, fs::Permissions::from_mode(0o700))?; + let fixture = Self { cg, publications }; + fs::copy(program, format!("{}/program-001", fixture.publications))?; + fs::write( + format!("{}/input-001", fixture.publications), + vec![0xa5; 65537], + )?; + Ok(fixture) + } + fn root(&self) -> io::Result { + ArtifactRoot::from_directory(rustix::fs::open( + &self.publications, + rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?) + } + fn config(&self) -> io::Result { + let mut config: RuntimeConfig = serde_json::from_str(include_str!( + "../../../../deploy/sandbox.runtime.example.json" + ))?; + config.delegated_cgroup_root = self.cg.clone(); + config.validate().map_err(io::Error::other) + } +} impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir(&self.cg); @@ -82,6 +114,9 @@ fn prefix(file: &crate::artifacts::SnapshotFile) -> io::Result { } pub fn run_case(case: &str) -> io::Result { verify_guest(true)?; + if case.starts_with("normal-") { + return normal::run_case(case); + } let rollback = case.strip_prefix("cancel-").filter(|s| { matches!( *s, @@ -112,31 +147,9 @@ pub fn run_case(case: &str) -> io::Result { _ => return Err(io::Error::other("unknown fixed case")), }; let id = format!("execution-{case}"); - let cg = format!("/sys/fs/cgroup/kf-acceptance-{}", std::process::id()); - let publications = format!("/kf/execution-publications-{}", std::process::id()); - fs::create_dir(&cg)?; - fs::create_dir(&publications)?; - fs::set_permissions(&cg, fs::Permissions::from_mode(0o700))?; - fs::set_permissions(&publications, fs::Permissions::from_mode(0o700))?; - let fixture = Fixture { cg, publications }; - fs::copy( - "/kf/bin/execution-fixture", - format!("{}/program-001", fixture.publications), - )?; - fs::write( - format!("{}/input-001", fixture.publications), - vec![0xa5; 65537], - )?; - let root = ArtifactRoot::from_directory(rustix::fs::open( - &fixture.publications, - rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC, - rustix::fs::Mode::empty(), - )?)?; - let mut config: RuntimeConfig = serde_json::from_str(include_str!( - "../../../../deploy/sandbox.runtime.example.json" - ))?; - config.delegated_cgroup_root = fixture.cg.clone(); - let config = config.validate().map_err(io::Error::other)?; + let fixture = Fixture::new("/kf/bin/execution-fixture")?; + let root = fixture.root()?; + let config = fixture.config()?; let mut request: ExecutionSpec = serde_json::from_str(include_str!("../tests/fixtures/spec.valid.json"))?; request.run_id = id; @@ -199,13 +212,21 @@ pub fn run_case(case: &str) -> io::Result { e.failure.stage, e.failure.errno, e.cleanup_failure )) })?; - verify_snapshot(&completed.stdout, case == "binary")?; - verify_snapshot(&completed.stderr, case == "binary")?; + describe(case, &spec, &completed, case == "binary") +} +fn describe( + case: &str, + spec: &crate::spec::ValidatedSpec, + completed: &run::Completed, + pattern: bool, +) -> io::Result { + verify_snapshot(&completed.stdout, pattern)?; + verify_snapshot(&completed.stderr, pattern)?; for o in &completed.outputs { - verify_snapshot(&o.file, case == "binary")?; + verify_snapshot(&o.file, pattern)?; } - let outcome = completed.report.classify(&spec).map_err(io::Error::other)?; + let outcome = completed.report.classify(spec).map_err(io::Error::other)?; let outputs=completed.outputs.iter().map(|o|Ok(serde_json::json!({"path":o.path,"bytes":o.file.bytes(),"prefix_hex":prefix(&o.file)?}))).collect::>>()?; - let result = serde_json::json!({"case":case,"immutable_snapshots_verified":true,"complete_binary_content_verified":case=="binary","report":completed.report,"outcome":outcome,"proof":completed.proof,"stdout_prefix_hex":prefix(&completed.stdout)?,"stderr_prefix_hex":prefix(&completed.stderr)?,"outputs":outputs}); + let result = serde_json::json!({"case":case,"immutable_snapshots_verified":true,"complete_binary_content_verified":pattern,"report":completed.report,"outcome":outcome,"proof":completed.proof,"stdout_prefix_hex":prefix(&completed.stdout)?,"stderr_prefix_hex":prefix(&completed.stderr)?,"outputs":outputs}); Ok(result) } diff --git a/judge/crates/sandbox/src/acceptance/normal.rs b/judge/crates/sandbox/src/acceptance/normal.rs new file mode 100644 index 0000000..617e828 --- /dev/null +++ b/judge/crates/sandbox/src/acceptance/normal.rs @@ -0,0 +1,128 @@ +//! Fixed language fixtures using the same private root execution pipeline. +use super::{Fixture, describe}; +use crate::{ + launcher::run::{self, MonitorMode}, + outcome::ExecutionOutcome, + spec::{ExecutionProfile, ExecutionSpec, InputArtifact, ResourceLimits, SpecPolicy}, +}; +use std::{fs, io, io::Read}; + +pub(super) fn run_case(case: &str) -> io::Result { + let (program, language, compile) = match case { + "normal-static" => ("/kf/bin/normal-static", "C", false), + "normal-c" | "normal-empty" | "normal-nonzero" => ("/kf/bin/normal-c", "C", false), + "normal-cpp" => ("/kf/bin/normal-cpp", "C++", false), + "normal-compile-c" => ("/kf/bin/normal-c", "C", true), + "normal-compile-cpp" => ("/kf/bin/normal-cpp", "C++", true), + _ => return Err(io::Error::other("unknown normal case")), + }; + let fixture = Fixture::new(program)?; + let config = fixture.config()?; + // Software-emulated GCC consumes much more CPU than the native target. + // Only this fixed VM fixture gets a larger, still finite compiler budget. + let mut compiler_config = config.config().clone(); + compiler_config.per_task_ceiling.cpu_time_us = 30_000_000; + compiler_config.per_task_ceiling.wall_time_us = 60_000_000; + let compiler_config = compiler_config.validate().map_err(io::Error::other)?; + let root = fixture.root()?; + let binary = std::env::current_exe()?; + let mut request: ExecutionSpec = + serde_json::from_str(include_str!("../../tests/fixtures/spec.valid.json"))?; + let mut compilation = None; + if compile { + let cpp = language == "C++"; + let source_name = if cpp { "main.cpp" } else { "main.c" }; + let source = if cpp { + include_str!("../../tests/fixtures/normal.cpp") + } else { + include_str!("../../tests/fixtures/normal.c") + }; + fs::write(format!("{}/source-001", fixture.publications), source)?; + let mut compiler = request.clone(); + compiler.run_id = format!("{case}-build"); + compiler.profile = if cpp { + ExecutionProfile::CompileCpp + } else { + ExecutionProfile::CompileC + }; + compiler.limits = ResourceLimits::for_profile(compiler.profile); + compiler.limits.cpu_time_us = 30_000_000; + compiler.limits.wall_time_us = 60_000_000; + compiler.argv = vec![ + if cpp { "/usr/bin/g++" } else { "/usr/bin/gcc" }.into(), + "-O0".into(), + "-Wall".into(), + "-Wextra".into(), + "-Werror".into(), + format!("/work/{source_name}"), + "-o".into(), + "/work/main.bin".into(), + ]; + compiler.inputs = vec![InputArtifact { + artifact_id: "source-001".into(), + path: source_name.into(), + }]; + compiler.outputs = vec!["main.bin".into()]; + compiler.stdin_artifact = None; + let spec = compiler + .validate(&SpecPolicy::default()) + .map_err(io::Error::other)?; + let completed = run::execute(&compiler_config, &spec, &binary, &root, MonitorMode::Normal) + .map_err(|e| io::Error::other(format!("compiler initialization: {:?}", e.failure)))?; + let result = describe(case, &spec, &completed, false)?; + if completed.report.classify(&spec).map_err(io::Error::other)? + != (ExecutionOutcome::Exited { code: 0 }) + || completed.outputs.len() != 1 + { + return Err(io::Error::other(format!( + "sandbox compilation failed: {result}" + ))); + } + let output = &completed.outputs[0].file; + let mut magic = [0_u8; 4]; + output.reader()?.read_exact(&mut magic)?; + if magic != *b"\x7fELF" || output.bytes() < 4096 { + return Err(io::Error::other("compiler output is not ELF")); + } + // Publish the sealed export only after full compiler teardown. The + // next task snapshots it anew as its single approved executable input. + let mut destination = fs::File::create(format!("{}/program-001", fixture.publications))?; + let copied = io::copy(&mut output.reader()?, &mut destination)?; + if copied != output.bytes() { + return Err(io::Error::other("compiler publication length")); + } + compilation = Some(result); + } + request.run_id = format!("{case}-run"); + let mode = match case { + "normal-empty" => "empty", + "normal-nonzero" => "nonzero", + _ => "normal", + }; + request.argv.push(mode.into()); + if mode == "empty" { + request.stdin_artifact = None; + } + let spec = request + .validate(&SpecPolicy::default()) + .map_err(io::Error::other)?; + let completed = run::execute(&config, &spec, &binary, &root, MonitorMode::Normal) + .map_err(|e| io::Error::other(format!("normal initialization: {:?}", e.failure)))?; + let expected = format!( + "{language} bytes={}\n", + if mode == "empty" { 0 } else { 65537 } + ); + let mut bytes = Vec::new(); + completed + .stdout + .reader()? + .take(64) + .read_to_end(&mut bytes)?; + if bytes != expected.as_bytes() || completed.stdout.bytes() != bytes.len() as u64 { + return Err(io::Error::other("normal complete stdout content")); + } + let mut result = describe(case, &spec, &completed, false)?; + result["complete_stdout_content_verified"] = true.into(); + result["compilation"] = compilation.into(); + Ok(result) +} diff --git a/judge/crates/sandbox/tests/fixtures/normal.c b/judge/crates/sandbox/tests/fixtures/normal.c new file mode 100644 index 0000000..30649d6 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/normal.c @@ -0,0 +1,16 @@ +#include +#include +#include +int main(int argc, char **argv) { + if (argc != 2) return 100; + unsigned char bytes[4096]; + size_t count = 0; + ssize_t n; + while ((n = read(0, bytes, sizeof bytes)) > 0) { + for (ssize_t i = 0; i < n; i++) if (bytes[i] != 0xa5) return 101; + count += (size_t)n; + } + if (n < 0 || count != (strcmp(argv[1], "empty") ? 65537U : 0U)) return 102; + printf("C bytes=%zu\n", count); + return !strcmp(argv[1], "nonzero") ? 23 : 0; +} diff --git a/judge/crates/sandbox/tests/fixtures/normal.cpp b/judge/crates/sandbox/tests/fixtures/normal.cpp new file mode 100644 index 0000000..cf1867b --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/normal.cpp @@ -0,0 +1,14 @@ +#include +#include +int main(int argc, char **argv) { + if (argc != 2 || std::string(argv[1]) != "normal") return 100; + std::size_t count = 0; + char byte; + while (std::cin.get(byte)) { + if (static_cast(byte) != 0xa5) return 101; + ++count; + } + if (!std::cin.eof() || count != 65537) return 102; + std::cout << "C++ bytes=" << count << '\n'; + return 0; +} diff --git a/judge/crates/sandbox/tests/normal_vm.rs b/judge/crates/sandbox/tests/normal_vm.rs new file mode 100644 index 0000000..faf5fce --- /dev/null +++ b/judge/crates/sandbox/tests/normal_vm.rs @@ -0,0 +1,99 @@ +#![cfg(all(target_os = "linux", feature = "vm-acceptance"))] +#[allow(dead_code)] +mod support; +use std::{fs, process::Command}; + +fn check_run(v: &serde_json::Value, code: u8) { + assert_eq!( + v["outcome"], + serde_json::json!({"kind":"exited", "code":code}), + "{v}" + ); + assert_eq!(v["report"]["cleanup"], "complete", "{v}"); + assert_eq!(v["report"]["errors"], serde_json::json!([])); + assert_eq!(v["report"]["limits"], serde_json::json!([])); + assert_eq!(v["immutable_snapshots_verified"], true); + assert_eq!( + v["proof"]["order"], + serde_json::json!([ + "verified_isolation", + "exec_gate", + "cgroup_empty", + "owned_reaped", + "final_accounting", + "final_export", + "resource_removal" + ]) + ); + for metric in [ + "cpu_time_us", + "wall_time_us", + "memory_peak_bytes", + "memory_events", + ] { + assert_eq!(v["report"]["usage"][metric]["state"], "measured", "{v}"); + } +} +fn run(case: &str, code: u8) -> serde_json::Value { + support::require_vm(); + let output = Command::new(support::binary()) + .args(["vm-case", case]) + .env_clear() + .env("KF_SANDBOX_INDEPENDENT_VM", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{case}: {} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + check_run(&v, code); + for dir in ["tasks", "identities", "captures"] { + assert_eq!( + fs::read_dir(format!("/var/lib/kf-judge/{dir}")) + .unwrap() + .count(), + 0 + ); + } + println!("KF_NORMAL_PROOF {v}"); + v +} +#[test] +#[ignore = "independent capped VM: static and dynamic C/C++ in actual private root"] +fn static_and_dynamic_language_programs() { + for case in ["normal-static", "normal-c", "normal-cpp"] { + assert_eq!(run(case, 0)["complete_stdout_content_verified"], true); + } +} +#[test] +#[ignore = "independent capped VM: sandbox compiler exported ELF runs as a new task"] +fn sandbox_compilation_then_artifact_execution() { + for case in ["normal-compile-c", "normal-compile-cpp"] { + let v = run(case, 0); + check_run(&v["compilation"], 0); + assert_eq!(v["compilation"]["outputs"][0]["path"], "main.bin"); + assert!( + v["compilation"]["outputs"][0]["prefix_hex"] + .as_str() + .unwrap() + .starts_with("7f454c46") + ); + assert_ne!(v["compilation"]["report"]["run_id"], v["report"]["run_id"]); + } +} +#[test] +#[ignore = "independent capped VM: empty stdin EOF, binary capture/export and nonzero exit"] +fn empty_binary_and_nonzero_are_real_results() { + assert_eq!( + run("normal-empty", 0)["report"]["stdout"]["retained_bytes"], + 10 + ); + run("normal-nonzero", 23); + let v = run("binary", 7); + assert_eq!(v["complete_binary_content_verified"], true); + assert_eq!(v["report"]["stdout"]["retained_bytes"], 65537); + assert_eq!(v["outputs"][0]["bytes"], 32769); +}