feat(sandbox): bind managed requests to pinned runtime identities
This commit is contained in:
@@ -32,6 +32,7 @@ def test_catalog_uses_wire_path_order_including_dot_and_directory_boundaries(tmp
|
||||
{"usr/lib/ld-linux-x86-64.so.2": {"kind": "file", "sha256": builder.sha(library)}},
|
||||
)
|
||||
manifest = json.loads((image / "manifest.json").read_bytes())
|
||||
assert manifest["architecture"] == "x86_64_v3"
|
||||
paths = [item["path"] for item in manifest["files"]]
|
||||
assert paths == sorted(paths)
|
||||
assert paths.index("usr/lib/module.py") < paths.index("usr/lib/module/a.py")
|
||||
|
||||
@@ -62,7 +62,7 @@ impl ImageCatalog {
|
||||
.validate_for_language(language)
|
||||
.map_err(|_| refused())?;
|
||||
if self.format_version != 2
|
||||
|| self.architecture != "x86_64"
|
||||
|| self.architecture != "x86_64_v3"
|
||||
|| self.image_kind != identity.image_kind
|
||||
|| self.image_revision != identity.image_revision
|
||||
|| self.toolchain_id != identity.toolchain_id
|
||||
@@ -143,6 +143,9 @@ pub(crate) fn verify(image: &OwnedFd, identity: &RuntimeIdentity, bytes: &[u8])
|
||||
}
|
||||
let catalog: ImageCatalog = serde_json::from_slice(bytes).map_err(|_| refused())?;
|
||||
catalog.validate(identity)?;
|
||||
if !compatible_cpu() {
|
||||
return Err(refused());
|
||||
}
|
||||
let mut files: BTreeMap<_, _> = catalog.files.iter().map(|f| (f.path.as_str(), f)).collect();
|
||||
let mut dirs: std::collections::BTreeSet<_> =
|
||||
catalog.directories.iter().map(String::as_str).collect();
|
||||
@@ -156,6 +159,29 @@ pub(crate) fn verify(image: &OwnedFd, identity: &RuntimeIdentity, bytes: &[u8])
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fixed images include an x86-64-v3 support-library closure. Detect the actual
|
||||
/// processor/OS AVX support in Root, before the first image binary is executed.
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
fn compatible_cpu() -> bool {
|
||||
std::is_x86_feature_detected!("sse3")
|
||||
&& std::is_x86_feature_detected!("ssse3")
|
||||
&& std::is_x86_feature_detected!("sse4.1")
|
||||
&& std::is_x86_feature_detected!("sse4.2")
|
||||
&& std::is_x86_feature_detected!("popcnt")
|
||||
&& std::is_x86_feature_detected!("avx")
|
||||
&& std::is_x86_feature_detected!("avx2")
|
||||
&& std::is_x86_feature_detected!("bmi1")
|
||||
&& std::is_x86_feature_detected!("bmi2")
|
||||
&& std::is_x86_feature_detected!("fma")
|
||||
&& std::is_x86_feature_detected!("f16c")
|
||||
&& std::is_x86_feature_detected!("lzcnt")
|
||||
&& std::is_x86_feature_detected!("cmpxchg16b")
|
||||
}
|
||||
#[cfg(not(target_arch = "x86_64"))]
|
||||
fn compatible_cpu() -> bool {
|
||||
false
|
||||
}
|
||||
fn walk<'a>(
|
||||
directory: &OwnedFd,
|
||||
prefix: &str,
|
||||
|
||||
@@ -267,7 +267,11 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
||||
rules(
|
||||
ioctls
|
||||
.into_iter()
|
||||
.map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, op)?]))
|
||||
// Linux request numbers occupy 32 bits. musl exposes them as
|
||||
// signed int (including TCGETS2's high bit), glibc as u64.
|
||||
// Reinterpret that fixed bit pattern, never sign-extend it into
|
||||
// an allowed high-word ioctl bypass.
|
||||
.map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, u64::from(op as u32))?]))
|
||||
.collect::<io::Result<_>>()?,
|
||||
)?,
|
||||
);
|
||||
|
||||
@@ -125,7 +125,7 @@ impl ValidatedRuntimeConfig {
|
||||
pub fn spec_policy(&self) -> SpecPolicy {
|
||||
SpecPolicy::with_limits_ceiling(self.0.per_task_ceiling.clone())
|
||||
.expect("validated immutable limits ceiling")
|
||||
.for_image(self.0.image_kind)
|
||||
.for_image(self.0.image_kind, self.0.runtime_identity.clone())
|
||||
}
|
||||
|
||||
/// Slot mapping only; the supervisor must lease slots until the old task
|
||||
|
||||
@@ -152,6 +152,10 @@ pub struct InputArtifact {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutionSpec {
|
||||
pub schema_version: SchemaVersion,
|
||||
/// Bind a managed request to the operator's exact immutable image. Omission
|
||||
/// preserves the original GNU v1 wire bytes, never managed authority.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_identity: Option<kf_contracts::judging_v2::RuntimeIdentity>,
|
||||
pub run_id: String,
|
||||
pub profile: ExecutionProfile,
|
||||
pub argv: Vec<String>,
|
||||
@@ -172,6 +176,7 @@ pub struct ExecutionSpec {
|
||||
pub struct SpecPolicy {
|
||||
limits_ceiling: ResourceLimits,
|
||||
image_kind: Option<crate::runtime::ImageKind>,
|
||||
runtime_identity: Option<kf_contracts::judging_v2::RuntimeIdentity>,
|
||||
}
|
||||
|
||||
impl Default for SpecPolicy {
|
||||
@@ -179,6 +184,7 @@ impl Default for SpecPolicy {
|
||||
Self {
|
||||
limits_ceiling: ResourceLimits::ceilings(),
|
||||
image_kind: None,
|
||||
runtime_identity: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,11 +195,17 @@ impl SpecPolicy {
|
||||
Ok(Self {
|
||||
limits_ceiling: limits,
|
||||
image_kind: None,
|
||||
runtime_identity: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn for_image(mut self, kind: crate::runtime::ImageKind) -> Self {
|
||||
pub(crate) fn for_image(
|
||||
mut self,
|
||||
kind: crate::runtime::ImageKind,
|
||||
identity: Option<kf_contracts::judging_v2::RuntimeIdentity>,
|
||||
) -> Self {
|
||||
self.image_kind = Some(kind);
|
||||
self.runtime_identity = identity;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -235,6 +247,7 @@ impl ValidatedSpec {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ValidationCode {
|
||||
InvalidRuntimeIdentity,
|
||||
InvalidIdentifier,
|
||||
InvalidArgv,
|
||||
UnsupportedExecutable,
|
||||
@@ -354,6 +367,30 @@ fn validate_limits(limits: &ResourceLimits, cap: &ResourceLimits) -> Result<(),
|
||||
impl ExecutionSpec {
|
||||
pub fn validate(self, policy: &SpecPolicy) -> Result<ValidatedSpec, SpecError> {
|
||||
identifier(&self.run_id, "run_id")?;
|
||||
let language = match self.profile {
|
||||
ExecutionProfile::Native | ExecutionProfile::CompileC => {
|
||||
kf_contracts::judging_v2::Language::C17
|
||||
}
|
||||
ExecutionProfile::CompileCpp => kf_contracts::judging_v2::Language::Cpp20,
|
||||
ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun => {
|
||||
kf_contracts::judging_v2::Language::Python313
|
||||
}
|
||||
ExecutionProfile::JavaCompile | ExecutionProfile::JavaRun => {
|
||||
kf_contracts::judging_v2::Language::Java21
|
||||
}
|
||||
};
|
||||
if self.profile.managed_runtime() && self.runtime_identity.is_none()
|
||||
|| self
|
||||
.runtime_identity
|
||||
.as_ref()
|
||||
.is_some_and(|identity| identity.validate_for_language(language).is_err())
|
||||
|| policy.image_kind.is_some() && self.runtime_identity != policy.runtime_identity
|
||||
{
|
||||
return Err(error(
|
||||
"runtime_identity",
|
||||
ValidationCode::InvalidRuntimeIdentity,
|
||||
));
|
||||
}
|
||||
if self.argv.is_empty()
|
||||
|| self.argv.len() > 128
|
||||
|| self
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
|
||||
//! Run in a separate guest with AVX2 masked, using the ordinary Root binary.
|
||||
#[allow(dead_code)]
|
||||
mod support;
|
||||
use kf_contracts::judging_v2::RuntimeIdentity;
|
||||
use kf_sandbox::{
|
||||
image::{ImageCatalog, catalog_digest},
|
||||
profiles,
|
||||
runtime::ImageKind,
|
||||
spec::{ExecutionProfile, ExecutionSpec, InputArtifact, ResourceLimits},
|
||||
};
|
||||
use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, process::Command};
|
||||
|
||||
#[test]
|
||||
#[ignore = "independent capped VM, actual CPU lacks AVX2; no host runtime execution"]
|
||||
fn insufficient_actual_cpu_refuses_before_image_execution() {
|
||||
support::require_vm();
|
||||
assert!(!std::is_x86_feature_detected!("avx2"));
|
||||
let dir = PathBuf::from(format!("/kf/cpu-refusal-{}", std::process::id()));
|
||||
fs::create_dir(&dir).unwrap();
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
for name in ["inputs", "outputs"] {
|
||||
fs::create_dir(dir.join(name)).unwrap();
|
||||
}
|
||||
let mut root = support::Root::new();
|
||||
root.config.max_concurrent_runs = 1;
|
||||
root.config.identities.count = 1;
|
||||
root.config.image_kind = ImageKind::LinuxCPython313X86_64V1;
|
||||
root.config.image_root = "/var/lib/kf-judge/images/python313".into();
|
||||
let data = fs::read(format!("{}/manifest.json", root.config.image_root)).unwrap();
|
||||
let catalog: ImageCatalog = serde_json::from_slice(&data).unwrap();
|
||||
assert_eq!(catalog.architecture, "x86_64_v3");
|
||||
let identity = RuntimeIdentity {
|
||||
toolchain_id: catalog.toolchain_id.clone(),
|
||||
image_kind: catalog.image_kind,
|
||||
image_revision: catalog.image_revision,
|
||||
image_digest: catalog_digest(&data),
|
||||
runtime_version: catalog.runtime_version.clone(),
|
||||
};
|
||||
catalog.validate(&identity).unwrap();
|
||||
root.config.runtime_identity = Some(identity.clone());
|
||||
root.config.clone().validate().unwrap();
|
||||
let limits = ResourceLimits::for_profile(ExecutionProfile::PythonRun);
|
||||
let request = ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: Some(identity),
|
||||
run_id: "cpu-unsupported".into(),
|
||||
profile: ExecutionProfile::PythonRun,
|
||||
argv: profiles::argv(ExecutionProfile::PythonRun, &limits).unwrap(),
|
||||
env: Default::default(),
|
||||
cwd: ".".into(),
|
||||
inputs: vec![InputArtifact {
|
||||
artifact_id: "source".into(),
|
||||
path: "main.py".into(),
|
||||
}],
|
||||
stdin_artifact: None,
|
||||
outputs: vec![],
|
||||
limits,
|
||||
};
|
||||
request
|
||||
.clone()
|
||||
.validate(&root.validated().spec_policy())
|
||||
.unwrap();
|
||||
for (name, bytes) in [
|
||||
("runtime.json", serde_json::to_vec(&root.config).unwrap()),
|
||||
("spec.json", serde_json::to_vec(&request).unwrap()),
|
||||
(
|
||||
"inputs/source",
|
||||
b"raise AssertionError('incompatible runtime executed')\n".to_vec(),
|
||||
),
|
||||
] {
|
||||
fs::write(dir.join(name), bytes).unwrap();
|
||||
fs::set_permissions(dir.join(name), fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let output = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap())
|
||||
.env_clear()
|
||||
.args(["run", "--config"])
|
||||
.arg(dir.join("runtime.json"))
|
||||
.arg("--spec")
|
||||
.arg(dir.join("spec.json"))
|
||||
.arg("--artifacts")
|
||||
.arg(dir.join("inputs"))
|
||||
.arg("--output")
|
||||
.arg(dir.join("outputs"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(2));
|
||||
assert!(output.stdout.is_empty());
|
||||
let refused: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
|
||||
assert_eq!(refused["error"], "runtime_not_ready");
|
||||
assert!(refused["cleanup_failure"].is_null());
|
||||
assert_eq!(fs::read_dir(dir.join("outputs")).unwrap().count(), 0);
|
||||
for name in ["tasks", "identities", "captures"] {
|
||||
let path = PathBuf::from("/var/lib/kf-judge").join(name);
|
||||
if path.exists() {
|
||||
assert_eq!(fs::read_dir(path).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!fs::read_dir(&root.path).unwrap().any(|entry| entry
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.unwrap()
|
||||
.is_dir())
|
||||
);
|
||||
println!(
|
||||
"KF_ACTUAL_CPU_REFUSAL_PROOF {}",
|
||||
serde_json::json!({"actual_avx2":false,"image_architecture":catalog.architecture,"identity":request.runtime_identity,"refusal":refused,"publication_empty":true,"owned_resources_zero":true})
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -6,10 +6,39 @@ use kf_sandbox::{
|
||||
spec::*,
|
||||
};
|
||||
|
||||
fn identity(profile: ExecutionProfile) -> RuntimeIdentity {
|
||||
let python = matches!(
|
||||
profile,
|
||||
ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun
|
||||
);
|
||||
RuntimeIdentity {
|
||||
toolchain_id: (if python {
|
||||
"cpython313-v1"
|
||||
} else {
|
||||
"openjdk21-v1"
|
||||
})
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
image_kind: if python {
|
||||
ContractKind::CPython313
|
||||
} else {
|
||||
ContractKind::OpenJdk21
|
||||
},
|
||||
image_revision: 1,
|
||||
image_digest: "a".repeat(64).try_into().unwrap(),
|
||||
runtime_version: (if python { "3.13.16" } else { "21.0.12.1+1-LTS" })
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(profile: ExecutionProfile) -> ExecutionSpec {
|
||||
let limits = ResourceLimits::for_profile(profile);
|
||||
ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: Some(identity(profile)),
|
||||
run_id: "managed-policy".into(),
|
||||
profile,
|
||||
argv: profiles::argv(profile, &limits).unwrap(),
|
||||
@@ -46,6 +75,68 @@ fn request(profile: ExecutionProfile) -> ExecutionSpec {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_requests_bind_the_exact_operator_identity() {
|
||||
let legacy: RuntimeConfig = serde_json::from_str(include_str!(
|
||||
"../../../../deploy/sandbox.runtime.example.json"
|
||||
))
|
||||
.unwrap();
|
||||
for (profile, kind) in [
|
||||
(
|
||||
ExecutionProfile::PythonRun,
|
||||
ImageKind::LinuxCPython313X86_64V1,
|
||||
),
|
||||
(ExecutionProfile::JavaRun, ImageKind::LinuxOpenJdk21X86_64V1),
|
||||
] {
|
||||
let mut config = legacy.clone();
|
||||
config.image_kind = kind;
|
||||
config.runtime_identity = Some(identity(profile));
|
||||
let policy = config.validate().unwrap().spec_policy();
|
||||
let original = request(profile);
|
||||
assert!(original.clone().validate(&policy).is_ok());
|
||||
let mut missing = original.clone();
|
||||
missing.runtime_identity = None;
|
||||
assert_eq!(
|
||||
missing.validate(&policy).unwrap_err().code,
|
||||
ValidationCode::InvalidRuntimeIdentity
|
||||
);
|
||||
for field in ["digest", "revision", "version", "kind", "toolchain"] {
|
||||
let mut wrong = original.clone();
|
||||
let id = wrong.runtime_identity.as_mut().unwrap();
|
||||
match field {
|
||||
"digest" => id.image_digest = "b".repeat(64).try_into().unwrap(),
|
||||
"revision" => id.image_revision += 1,
|
||||
"version" => id.runtime_version = "21.0.99+1-LTS".to_owned().try_into().unwrap(),
|
||||
"kind" => id.image_kind = ContractKind::GnuCxx,
|
||||
"toolchain" => id.toolchain_id = "gnu-cxx-v1".to_owned().try_into().unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
wrong.validate(&policy).unwrap_err().code,
|
||||
ValidationCode::InvalidRuntimeIdentity,
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let request: ExecutionSpec =
|
||||
serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap();
|
||||
assert!(request.runtime_identity.is_none());
|
||||
assert!(
|
||||
!serde_json::to_string(&request)
|
||||
.unwrap()
|
||||
.contains("runtime_identity")
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.clone()
|
||||
.validate(&legacy.validate().unwrap().spec_policy())
|
||||
.is_ok()
|
||||
);
|
||||
let mut wrong = request;
|
||||
wrong.runtime_identity = Some(identity(ExecutionProfile::PythonRun));
|
||||
assert!(wrong.validate(&SpecPolicy::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_environment_and_source_or_class_sets_are_closed() {
|
||||
for profile in [
|
||||
|
||||
@@ -105,6 +105,7 @@ impl Fixture {
|
||||
limits.wall_time_us = 60_000_000;
|
||||
ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: self.root.config.runtime_identity.clone(),
|
||||
run_id: id.into(),
|
||||
profile,
|
||||
argv: profiles::argv(profile, &limits).unwrap(),
|
||||
@@ -261,7 +262,124 @@ impl Fixture {
|
||||
fn stdout(&self, id: &str) -> Vec<u8> {
|
||||
fs::read(self.dir.join("outputs").join(id).join("stdout")).unwrap()
|
||||
}
|
||||
fn java_program(&self, source: &[u8], id: &str) -> ExecutionSpec {
|
||||
self.input("source", source);
|
||||
let compile = self.request(id, ExecutionProfile::JavaCompile);
|
||||
let result = self.execute(&compile);
|
||||
assert_eq!(
|
||||
result["outcome"],
|
||||
serde_json::json!({"kind":"exited","code":0})
|
||||
);
|
||||
let mut run = self.request("java-bound-run", ExecutionProfile::JavaRun);
|
||||
run.inputs.clear();
|
||||
for (index, artifact) in result["report"]["artifacts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("class-{index}");
|
||||
self.input(
|
||||
&id,
|
||||
&fs::read(
|
||||
self.dir
|
||||
.join("outputs")
|
||||
.join(&compile.run_id)
|
||||
.join(format!("artifact-{index}")),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
run.inputs.push(InputArtifact {
|
||||
artifact_id: id,
|
||||
path: artifact["path"].as_str().unwrap().into(),
|
||||
});
|
||||
}
|
||||
run
|
||||
}
|
||||
fn cancel_through_ordinary_peer(&self, spec: &ExecutionSpec) {
|
||||
// The independent fixture controller is a bounded ordinary UID client.
|
||||
// Only the default Root helper performs privileged task operations.
|
||||
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o711)).unwrap();
|
||||
let inputs = self.dir.join("inputs");
|
||||
std::os::unix::fs::chown(&inputs, Some(1000), Some(1000)).unwrap();
|
||||
fs::set_permissions(&inputs, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
for entry in fs::read_dir(&inputs).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
std::os::unix::fs::chown(&path, Some(1000), Some(1000)).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let wire = self.dir.join("wire.json");
|
||||
fs::write(
|
||||
&wire,
|
||||
serde_json::to_vec(&serde_json::json!({"spec":spec})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::os::unix::fs::chown(&wire, Some(1000), Some(1000)).unwrap();
|
||||
fs::set_permissions(&wire, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
let socket = self.dir.join("socket");
|
||||
let mut helper = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap())
|
||||
.env_clear()
|
||||
.args(["serve-one", "--config"])
|
||||
.arg(&self.config)
|
||||
.arg("--socket")
|
||||
.arg(&socket)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
while !fs::metadata(&socket).is_ok_and(|m| m.mode() & 0o777 == 0o660) {
|
||||
assert!(
|
||||
helper.try_wait().unwrap().is_none(),
|
||||
"helper failed before peer publication"
|
||||
);
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"bounded helper publication"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let client = Command::new("/kf/bin/managed-disconnect")
|
||||
.env_clear()
|
||||
.arg(&socket)
|
||||
.arg(&inputs)
|
||||
.arg(&wire)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(client.status.success(), "ordinary controller failed");
|
||||
let output = helper.wait_with_output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(result["report"]["cleanup"], "complete");
|
||||
assert_eq!(result["report"]["errors"], serde_json::json!([]));
|
||||
assert_eq!(result["report"]["limits"], serde_json::json!([]));
|
||||
assert!(
|
||||
result["report"]["cancelled_at_wall_time_us"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
> 0
|
||||
);
|
||||
// Marker comes from actual user main after runtime bootstrap, so a
|
||||
// cancelled preparation alone cannot pass this execution test.
|
||||
assert!(
|
||||
result["report"]["stdout"]["retained_bytes"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
>= 5
|
||||
);
|
||||
assert!(!socket.exists());
|
||||
self.empty();
|
||||
println!(
|
||||
"KF_MANAGED_PEER_CANCEL_PROOF {}",
|
||||
serde_json::json!({"ordinary_uid":1000,"profile":spec.profile,"identity":spec.runtime_identity,"spec":spec,"result":result,"owned_resources_zero":true})
|
||||
);
|
||||
}
|
||||
fn image_refusals(&mut self, spec: &ExecutionSpec, binary: &str) {
|
||||
self.identity_refusals(spec);
|
||||
let image = PathBuf::from(&self.root.config.image_root);
|
||||
let executable = image.join(binary);
|
||||
let backup = self.dir.join("trusted-runtime-backup");
|
||||
@@ -307,6 +425,19 @@ impl Fixture {
|
||||
self.root.config.image_root
|
||||
);
|
||||
}
|
||||
fn identity_refusals(&self, spec: &ExecutionSpec) {
|
||||
let mut wrong = spec.clone();
|
||||
wrong.runtime_identity = None;
|
||||
self.reject(&wrong, "invalid_execution_spec");
|
||||
let mut wrong = spec.clone();
|
||||
wrong.runtime_identity.as_mut().unwrap().image_digest = "d".repeat(64).try_into().unwrap();
|
||||
self.reject(&wrong, "invalid_execution_spec");
|
||||
let mut wrong = spec.clone();
|
||||
wrong.runtime_identity.as_mut().unwrap().runtime_version =
|
||||
"1.0.0".to_owned().try_into().unwrap();
|
||||
self.reject(&wrong, "invalid_execution_spec");
|
||||
println!("KF_MANAGED_REQUEST_IDENTITY_STATUS=0");
|
||||
}
|
||||
fn empty(&self) {
|
||||
for name in ["tasks", "captures", "identities"] {
|
||||
let p = PathBuf::from("/var/lib/kf-judge").join(name);
|
||||
@@ -413,3 +544,163 @@ fn java_profile_compiles_class_set_and_runs_same_set() {
|
||||
f.reject(&wrong, "invalid_execution_spec");
|
||||
f.image_refusals(&wrong, "usr/lib/jvm/bin/java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "malicious and overlimit code only in an independent no-NIC/no-share capped VM"]
|
||||
fn python_isolation_and_kernel_limits_have_actual_evidence() {
|
||||
let f = Fixture::new("python313", ImageKind::LinuxCPython313X86_64V1);
|
||||
f.input("source", br#"import ctypes, errno, os, socket
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
assert os.geteuid() != 0
|
||||
for number, args in [(41,(2,1,0)),(101,(0,0,0,0)),(165,(0,0,0,0,0)),(308,(-1,0)),(272,(0x10000000,)),(321,(0,0,0)),(126,(0,0)),(62,(os.getpid(),0))]:
|
||||
ctypes.set_errno(0)
|
||||
assert libc.syscall(number,*args) == -1, number
|
||||
assert ctypes.get_errno() == errno.EPERM, (number,ctypes.get_errno())
|
||||
try:
|
||||
socket.socket()
|
||||
raise AssertionError('network allowed')
|
||||
except PermissionError:
|
||||
pass
|
||||
for path in ['/kf/INDEPENDENT_VM','/var/lib/kf-judge','/root/.ssh','/home/kronecker','/proc/1/root/kf/INDEPENDENT_VM']:
|
||||
assert not os.path.exists(path), path
|
||||
try:
|
||||
open('/usr/lib/python3.13/os.py','wb')
|
||||
raise AssertionError('image writable')
|
||||
except OSError:
|
||||
pass
|
||||
print('python-isolation-ok')
|
||||
"#);
|
||||
let result = f.execute(&f.request("py-isolation", ExecutionProfile::PythonRun));
|
||||
assert_eq!(
|
||||
result["outcome"],
|
||||
serde_json::json!({"kind":"exited","code":0})
|
||||
);
|
||||
assert_eq!(f.stdout("py-isolation"), b"python-isolation-ok\n");
|
||||
for (id, source, primary) in [
|
||||
("py-cpu", b"print('cpu-ready',flush=True)\nwhile True: pass\n".as_slice(), "cpu_time"),
|
||||
("py-memory", b"print('memory-ready',flush=True)\na=[]\nwhile True: a.append(bytearray(8*1024*1024))\n".as_slice(), "memory"),
|
||||
("py-output", b"import os\nwhile True: os.write(1,b'x'*65536)\n".as_slice(), "stdout"),
|
||||
] {
|
||||
f.input("source", source);
|
||||
let mut spec = f.request(id, ExecutionProfile::PythonRun);
|
||||
if primary == "cpu_time" {
|
||||
spec.limits.cpu_time_us = 2_000_000;
|
||||
spec.limits.wall_time_us = 10_000_000;
|
||||
}
|
||||
spec.limits.stdout_bytes = 4096;
|
||||
let result = f.execute(&spec);
|
||||
assert_eq!(result["outcome"], serde_json::json!({"kind":"limit_exceeded","primary":primary}));
|
||||
assert!(result["report"]["usage"]["cpu_time_us"]["value"].as_u64().is_some());
|
||||
assert!(result["report"]["usage"]["memory_peak_bytes"]["value"].as_u64().is_some());
|
||||
if primary == "memory" {
|
||||
assert!(result["report"]["usage"]["memory_events"]["value"]["oom_kill"].as_u64().unwrap() > 0);
|
||||
}
|
||||
}
|
||||
f.input(
|
||||
"source",
|
||||
b"raise MemoryError('OutOfMemoryError: user text')\n",
|
||||
);
|
||||
let result = f.execute(&f.request("py-user-oom", ExecutionProfile::PythonRun));
|
||||
assert_eq!(result["outcome"]["code"], 1);
|
||||
assert_eq!(result["report"]["limits"], serde_json::json!([]));
|
||||
assert_eq!(
|
||||
result["report"]["usage"]["memory_events"]["value"]["oom_kill"],
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "JVM security, threads and real cgroup OOM only in an independent capped VM"]
|
||||
fn java_isolation_and_whole_jvm_limits_have_actual_evidence() {
|
||||
let f = Fixture::new("java21", ImageKind::LinuxOpenJdk21X86_64V1);
|
||||
let program = f.java_program(br#"import java.io.*; import java.net.*; import java.nio.file.*; import java.lang.reflect.*; import sun.misc.Unsafe;
|
||||
public class Main {
|
||||
public static void main(String[] args) throws Exception {
|
||||
String mode = new BufferedReader(new InputStreamReader(System.in)).readLine();
|
||||
if (mode.equals("normal")) {
|
||||
if (Runtime.getRuntime().availableProcessors()!=1) throw new AssertionError("cpu ergonomics");
|
||||
if (Runtime.getRuntime().maxMemory()>224L*1024*1024) throw new AssertionError("heap ergonomics");
|
||||
try { new ServerSocket(0); throw new AssertionError("network allowed"); } catch(IOException | ExceptionInInitializerError expected) { Throwable cause=expected; while(cause.getCause()!=null) cause=cause.getCause(); if(!(cause instanceof IOException) || !cause.getMessage().contains("Operation not permitted")) throw expected; }
|
||||
for(String p:new String[]{"/kf/INDEPENDENT_VM","/var/lib/kf-judge","/root/.ssh","/home/kronecker"}) if(Files.exists(Path.of(p))) throw new AssertionError("host visible");
|
||||
try { Files.write(Path.of("/usr/lib/jvm/release"),new byte[]{1}); throw new AssertionError("image writable"); } catch(IOException expected) {}
|
||||
System.out.println("java-isolation-ok");
|
||||
} else if(mode.equals("fake-oom")) { throw new OutOfMemoryError("user controlled");
|
||||
} else if(mode.equals("cpu")) { System.out.println("cpu-ready"); long x=0; while(true) x=Math.multiplyExact(x,1)+1;
|
||||
} else if(mode.equals("memory")) {
|
||||
System.out.println("memory-ready"); Field field=Unsafe.class.getDeclaredField("theUnsafe"); field.setAccessible(true); Unsafe unsafe=(Unsafe)field.get(null);
|
||||
while(true) { long p=unsafe.allocateMemory(16L*1024*1024); unsafe.setMemory(p,16L*1024*1024,(byte)1); }
|
||||
} else if(mode.equals("output")) { byte[] bytes=new byte[65536]; while(true) System.out.write(bytes);
|
||||
} else if(mode.equals("threads")) { while(true) new Thread(()->{try{Thread.sleep(60000);}catch(InterruptedException e){}}).start();
|
||||
} else { throw new AssertionError("mode"); }
|
||||
}
|
||||
}"#, "java-security-compile");
|
||||
for (id, mode, primary) in [
|
||||
("java-isolation", "normal", None),
|
||||
("java-user-oom", "fake-oom", None),
|
||||
("java-cpu", "cpu", Some("cpu_time")),
|
||||
("java-memory", "memory", Some("memory")),
|
||||
("java-output", "output", Some("stdout")),
|
||||
("java-threads", "threads", Some("processes")),
|
||||
] {
|
||||
f.input("stdin", format!("{mode}\n").as_bytes());
|
||||
let mut spec = program.clone();
|
||||
spec.run_id = id.into();
|
||||
spec.stdin_artifact = Some("stdin".into());
|
||||
if mode == "cpu" {
|
||||
spec.limits.cpu_time_us = 5_000_000;
|
||||
spec.limits.wall_time_us = 15_000_000;
|
||||
}
|
||||
spec.limits.stdout_bytes = 4096;
|
||||
if mode == "threads" {
|
||||
spec.limits.processes = 16;
|
||||
}
|
||||
spec.argv = profiles::argv(spec.profile, &spec.limits).unwrap();
|
||||
let result = f.execute(&spec);
|
||||
if let Some(primary) = primary {
|
||||
assert_eq!(
|
||||
result["outcome"],
|
||||
serde_json::json!({"kind":"limit_exceeded","primary":primary})
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
result["outcome"]["code"],
|
||||
if mode == "normal" { 0 } else { 1 }
|
||||
);
|
||||
assert_eq!(result["report"]["limits"], serde_json::json!([]));
|
||||
assert_eq!(
|
||||
result["report"]["usage"]["memory_events"]["value"]["oom_kill"],
|
||||
0
|
||||
);
|
||||
}
|
||||
if mode == "normal" {
|
||||
assert_eq!(f.stdout(id), b"java-isolation-ok\n");
|
||||
}
|
||||
if mode == "memory" {
|
||||
assert!(
|
||||
result["report"]["usage"]["memory_events"]["value"]["oom_kill"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
> 0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "live Python fork tree cancellation only in independent no-NIC/no-share capped VM"]
|
||||
fn python_live_fork_tree_peer_cancellation_is_drained() {
|
||||
let f = Fixture::new("python313", ImageKind::LinuxCPython313X86_64V1);
|
||||
f.input(
|
||||
"source",
|
||||
b"import os,time\nprint('py-live',flush=True)\nos.fork()\nwhile True: time.sleep(10)\n",
|
||||
);
|
||||
f.cancel_through_ordinary_peer(&f.request("py-peer-cancel", ExecutionProfile::PythonRun));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "live Java thread tree cancellation only in independent no-NIC/no-share capped VM"]
|
||||
fn java_live_threads_peer_cancellation_is_drained() {
|
||||
let f = Fixture::new("java21", ImageKind::LinuxOpenJdk21X86_64V1);
|
||||
let run = f.java_program(br#"public class Main { public static void main(String[] args) throws Exception { for(int i=0;i<3;i++)new Thread(()->{while(true)try{Thread.sleep(10000);}catch(InterruptedException e){}}).start(); System.out.println("java-live"); while(true)Thread.sleep(10000); } }"#, "java-cancel-compile");
|
||||
f.cancel_through_ordinary_peer(&run);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod native {
|
||||
fn execute(&mut self, request: &CaseRequest) -> Result<Observation, PortError> {
|
||||
let spec = ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: None,
|
||||
run_id: request.run_id.clone(),
|
||||
profile: ExecutionProfile::Native,
|
||||
argv: vec!["/work/main".into()],
|
||||
|
||||
@@ -140,6 +140,7 @@ pub fn compiler_spec(
|
||||
};
|
||||
ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: None,
|
||||
run_id,
|
||||
profile,
|
||||
argv: vec![
|
||||
@@ -394,6 +395,7 @@ impl<A: ArtifactPort> RunPort for Attempt<'_, A> {
|
||||
drop(input);
|
||||
let spec = ExecutionSpec {
|
||||
schema_version: kf_contracts::SchemaVersion,
|
||||
runtime_identity: None,
|
||||
run_id: run_id.into(),
|
||||
profile: ExecutionProfile::Native,
|
||||
argv: vec!["/work/main".into()],
|
||||
|
||||
@@ -267,7 +267,9 @@ def finish(args, pin, root, records):
|
||||
raise ValueError("image exceeds catalog capacity")
|
||||
catalog = {
|
||||
"format_version": 2,
|
||||
"architecture": "x86_64",
|
||||
# The signed support-library closure requires the x86-64-v3 ISA.
|
||||
# Publish that minimum explicitly; Root checks it before executing ELF.
|
||||
"architecture": "x86_64_v3",
|
||||
"image_kind": pin["kind"],
|
||||
"image_revision": 1,
|
||||
"toolchain_id": pin["toolchain_id"],
|
||||
|
||||
Reference in New Issue
Block a user