feat(worker): judge versioned sealed runtime programs

This commit is contained in:
2026-10-09 20:15:17 +08:00
parent 2f1166954a
commit 802e019699
21 changed files with 2867 additions and 84 deletions
+1
View File
@@ -439,6 +439,7 @@ def test_actual_expiry_stopping_quarantine_retry_and_old_attempt(
break
await asyncio.sleep(0.05)
assert pending[0]["binding"] == g["binding"]
assert pending == [dict(binding=g["binding"], case_count=2)]
stopped = dict(
schema_version=1, kind="judge.stopped", binding=g["binding"], cleanup_complete=False
)
+101
View File
@@ -0,0 +1,101 @@
"""Offline package data only; no compiler, submitted source or image execution."""
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
SPEC = importlib.util.spec_from_file_location(
"gnu_image_builder", Path(__file__).resolve().parents[2] / "scripts/build_gnu_image.py"
)
builder = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(builder)
def test_absolute_package_link_is_relocated_inside_verified_image(tmp_path):
root = tmp_path / "image"
target = root / "usr/lib/plugin.so"
target.parent.mkdir(parents=True)
target.write_bytes(b"synthetic pinned package bytes")
link = root / "usr/lib/plugin-link.so"
link.symlink_to("/usr/lib/plugin.so")
records = {
"usr/lib/plugin-link.so": {"kind": "symlink", "target": "/usr/lib/plugin.so"},
"usr/lib/plugin.so": {"kind": "file", "sha256": builder.sha(target)},
}
source, record = builder.resolve_verified(root, records, "usr/lib/plugin-link.so")
assert source == target
assert source.read_bytes() == b"synthetic pinned package bytes"
assert record == records["usr/lib/plugin.so"]
@pytest.mark.parametrize("target", ["../../../../outside", "plugin-link.so"])
def test_package_link_escape_or_cycle_refused(tmp_path, target):
link = tmp_path / "usr/lib/plugin-link.so"
link.parent.mkdir(parents=True)
link.symlink_to(target)
records = {"usr/lib/plugin-link.so": {"kind": "symlink", "target": target}}
with pytest.raises(ValueError):
builder.resolve_verified(tmp_path, records, "usr/lib/plugin-link.so")
def test_replaced_or_unverified_ancestor_link_is_refused(tmp_path):
library = tmp_path / "usr/lib"
library.parent.mkdir()
library.symlink_to("/usr/lib")
records = {"usr/lib/plugin.so": {"kind": "file", "sha256": "a" * 64}}
with pytest.raises(ValueError, match="unverified GNU ancestor"):
builder.resolve_verified(tmp_path, records, "usr/lib/plugin.so")
records["usr/lib"] = {"kind": "symlink", "target": "/different/path"}
with pytest.raises(ValueError, match="differs from verified manifest"):
builder.resolve_verified(tmp_path, records, "usr/lib/plugin.so")
def test_verified_manifest_does_not_authorize_changed_package_bytes(tmp_path):
root = tmp_path / "support"
library = root / "usr/lib/ld-linux-x86-64.so.2"
library.parent.mkdir(parents=True)
library.write_bytes(b"original synthetic library")
manifest = {
"packages": [
{
"name": "gcc",
"version": "16.2.1+r23+gd564253eb6c8-1",
"sha256": "ebac12ceef494c616a36fc6f0f792b365dc59b6ed46ca1a69f138aecc810b6ab",
},
{
"name": "glibc",
"version": "2.44+r50+g1848099f063e-3",
"sha256": "071d807f4d3bf6b921f86b9a91e83a47ecc026c5821634efa46b09d890e06785",
},
{
"name": "binutils",
"version": "2.47-2",
"sha256": "62efd213e87590f025cf861b646793d3cacb03e7e26187b73d33b00adaf7a977",
},
],
"files": [
{
"path": "usr/lib/ld-linux-x86-64.so.2",
"kind": "file",
"bytes": library.stat().st_size,
"sha256": builder.sha(library),
}
],
}
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps(manifest))
library.write_bytes(b"changed synthetic library")
output = tmp_path / "output"
args = SimpleNamespace(
support_dir=root,
support_manifest=manifest_path,
support_manifest_sha256=builder.sha(manifest_path),
output=output,
)
with pytest.raises(ValueError, match="differs from verified public package bytes"):
builder.build(args)
assert not (output / "manifest.json").exists()
assert not list(output.rglob("*.so*"))
@@ -209,6 +209,13 @@ def test_actual_v2_cancel_requires_matching_stop_protocol_and_fence(
headers={"Authorization": "Bearer " + platform},
)
).status_code == 204
pending = await client.get(
"/internal/pending-stops", headers=worker_headers(workers[0])
)
assert pending.status_code == 200
assert pending.json() == [
dict(schema_version=2, binding=grant["binding"], case_count=2)
]
stopped = dict(
schema_version=2,
kind="judge.stopped",
+12 -2
View File
@@ -460,8 +460,18 @@ impl Repository {
}
pub async fn pending_stops(&self, worker: Identifier) -> Result<Value> {
let s = self.database.session().await?;
let rows=s.client.query("SELECT e.id,a.id attempt_id,a.fencing_version,jsonb_array_length(a.manifest->'cases') case_count FROM evaluations e JOIN judge_attempts a ON a.id=e.current_attempt_id WHERE e.state='cancelling' AND a.worker_id=$1 ORDER BY e.updated_at,e.id LIMIT 16",&[&worker.as_uuid()]).await?;
Ok(Value::Array(rows.iter().map(|r|json!({"binding":LeaseBinding{evaluation_id:identifier(r.get("id")),attempt_id:identifier(r.get("attempt_id")),worker_id:worker,fencing_version:r.get::<_,i64>("fencing_version")as u64},"case_count":r.get::<_,i32>("case_count")})).collect()))
let rows=s.client.query("SELECT e.id,a.id attempt_id,a.fencing_version,jsonb_array_length(a.manifest->'cases') case_count,(a.manifest->>'schema_version')::int schema_version FROM evaluations e JOIN judge_attempts a ON a.id=e.current_attempt_id WHERE e.state='cancelling' AND a.worker_id=$1 ORDER BY e.updated_at,e.id LIMIT 16",&[&worker.as_uuid()]).await?;
let entries = rows.iter().map(|r| {
let mut entry=json!({"binding":LeaseBinding{evaluation_id:identifier(r.get("id")),attempt_id:identifier(r.get("attempt_id")),worker_id:worker,fencing_version:r.get::<_,i64>("fencing_version")as u64},"case_count":r.get::<_,i32>("case_count")});
match r.get::<_,i32>("schema_version") {
// Preserve the old pending-stop response and all v1 report bytes.
1 => {},
2 => entry["schema_version"] = json!(2),
_ => return Err(Error::Invalid),
}
Ok(entry)
}).collect::<Result<Vec<_>>>()?;
Ok(Value::Array(entries))
}
pub async fn recover(&self) -> Result<u64> {
let mut s = self.database.session().await?;
+1
View File
@@ -229,3 +229,4 @@ mod tests {
}
pub mod submission;
pub mod submission_v2;
+1 -1
View File
@@ -62,7 +62,7 @@ fn empty_compile(status: CompilationStatus) -> CompilationReport {
memory_peak_bytes: None,
}
}
fn classify(o: &Observation, expected: &[u8]) -> Verdict {
pub(crate) fn classify(o: &Observation, expected: &[u8]) -> Verdict {
match o.kind {
ExecutionKind::Exited { code: 0 } => {
if o.stdout == expected {
+478
View File
@@ -0,0 +1,478 @@
//! Explicit v2 preparation and typed program execution; legacy wire stays separate.
//! Ports own clocks/cancellation/storage/execution; this module is pure domain.
use super::submission::{
ControlPort, ExpectedPort, SkipReason, SkippedCase, StageFailure, classify,
};
use crate::{ExecutionKind, Observation};
use kf_contracts::judging_v2::*;
use serde::Serialize;
pub struct Compiled {
pub report: CompilationReport,
pub cleanup_complete: bool,
pub failure: Option<FailureCode>,
}
pub trait PreparationPort {
fn prepare(
&mut self,
manifest: &JobManifest,
binding: &LeaseBinding,
run_id: &str,
) -> Result<Compiled, StageFailure>;
}
pub trait RunPort {
fn run(
&mut self,
program: &CompiledProgram,
case: &TestCase,
limits: &RunLimits,
run_id: &str,
) -> Result<Observation, StageFailure>;
}
#[derive(Debug, Serialize)]
pub struct SubmissionResult {
pub report: JudgementReport,
pub compile_run_id: String,
pub case_run_ids: Vec<String>,
pub skipped: Vec<SkippedCase>,
}
fn empty_compile(status: CompilationStatus) -> CompilationReport {
CompilationReport {
status,
program: None,
diagnostics: String::new().try_into().expect("empty diagnostics"),
diagnostics_truncated: false,
cpu_time_us: None,
memory_peak_bytes: None,
}
}
fn failure(code: FailureCode) -> StageFailure {
StageFailure {
code,
cleanup_complete: true,
}
}
fn gate(p: &impl ControlPort, m: &JobManifest) -> Result<bool, StageFailure> {
if p.elapsed_us() >= m.job_wall_time_us {
return Err(failure(FailureCode::JobDeadline));
}
Ok(p.cancelled())
}
pub fn judge_submission<P: PreparationPort + RunPort + ExpectedPort + ControlPort>(
p: &mut P,
m: &JobManifest,
binding: LeaseBinding,
digest: Digest,
) -> Result<SubmissionResult, &'static str> {
m.validate()?;
binding.validate()?;
if binding.evaluation_id != m.evaluation_id {
return Err("submission binding differs");
}
let compile_run_id = format!("c-{}", binding.attempt_id.as_uuid());
let compilation = match gate(p, m) {
Ok(true) => Ok(Compiled {
report: empty_compile(CompilationStatus::Cancelled),
cleanup_complete: true,
failure: None,
}),
Err(e) => Err(e),
Ok(false) => p
.stage(Stage::Compiling, None)
.and_then(|()| p.prepare(m, &binding, &compile_run_id)),
};
let mut cleanup_complete;
let mut failure_code;
let mut compilation = match compilation {
Ok(c) => {
c.report.validate()?;
if c.report.program.as_ref().is_some_and(|program| {
program.binding != binding
|| program.source != m.source
|| program.toolchain != m.toolchain
|| program.kind != m.language.contract().program_kind
|| (program.kind != ProgramKind::PythonSource
&& (program
.files
.iter()
.any(|f| f.artifact.bytes > m.compile_limits.file_bytes)
|| program.bytes() > m.compile_limits.workdir_bytes))
}) {
return Err("preparation program differs from authorized manifest or lease");
}
cleanup_complete = c.cleanup_complete;
failure_code = c.failure;
if !cleanup_complete {
failure_code = Some(FailureCode::CleanupFailed);
}
if failure_code.is_some() {
empty_compile(CompilationStatus::SystemError)
} else {
c.report
}
}
Err(e) => {
cleanup_complete = e.cleanup_complete;
failure_code = Some(if cleanup_complete {
e.code
} else {
FailureCode::CleanupFailed
});
empty_compile(CompilationStatus::SystemError)
}
};
if failure_code.is_none() && p.elapsed_us() >= m.job_wall_time_us {
failure_code = Some(FailureCode::JobDeadline);
if compilation.status != CompilationStatus::Succeeded {
compilation.status = CompilationStatus::SystemError;
compilation.program = None;
}
}
let mut cases = Vec::with_capacity(m.cases.len());
let mut run_ids = Vec::with_capacity(m.cases.len());
if let Some(program) = &compilation.program {
for (index, c) in m.cases.iter().enumerate() {
match gate(p, m) {
Ok(true) => break,
Err(e) => {
failure_code = Some(e.code);
break;
}
Ok(false) => {}
}
let run_id = format!("r-{}-{index}", binding.attempt_id.as_uuid());
let observation = p
.stage(Stage::Running, Some(index as u16))
.and_then(|()| p.run(program, c, &m.run_limits, &run_id));
run_ids.push(run_id);
let result = match observation {
Err(e) => {
cleanup_complete = e.cleanup_complete;
failure_code = Some(if cleanup_complete {
e.code
} else {
FailureCode::CleanupFailed
});
CaseReport {
case_id: c.case_id,
verdict: Verdict::SystemError,
cpu_time_us: None,
memory_peak_bytes: None,
}
}
Ok(o) => {
let verdict = if o.kind == ExecutionKind::InfrastructureFailure {
Verdict::SystemError
} else {
match gate(p, m) {
Err(e) => {
failure_code = Some(e.code);
Verdict::SystemError
}
Ok(true) => Verdict::Cancelled,
Ok(false) if !matches!(o.kind, ExecutionKind::Exited { code: 0 }) => {
classify(&o, &[])
}
Ok(false) => match p.expected(c) {
Ok(bytes)
if bytes.len() as u64 == c.expected.bytes
&& bytes.len() as u64 <= m.run_limits.stdout_bytes =>
{
classify(&o, &bytes)
}
Ok(_) => {
failure_code = Some(FailureCode::ArtifactUnavailable);
Verdict::SystemError
}
Err(e) => {
failure_code = Some(e.code);
Verdict::SystemError
}
},
}
};
if verdict == Verdict::SystemError && failure_code.is_none() {
failure_code = Some(FailureCode::SandboxUnavailable);
}
CaseReport {
case_id: c.case_id,
verdict,
cpu_time_us: o.cpu_time_us,
memory_peak_bytes: o.memory_peak_bytes,
}
}
};
let stop = matches!(result.verdict, Verdict::SystemError | Verdict::Cancelled);
cases.push(result);
if stop {
break;
}
}
}
let verdict = if failure_code.is_some() {
Verdict::SystemError
} else {
match compilation.status {
CompilationStatus::CompilationError | CompilationStatus::CompilationLimit => {
Verdict::CompilationError
}
CompilationStatus::Cancelled => Verdict::Cancelled,
CompilationStatus::SystemError => {
failure_code = Some(FailureCode::SandboxUnavailable);
Verdict::SystemError
}
CompilationStatus::Succeeded => {
if cases.len() != m.cases.len()
|| cases.iter().any(|c| c.verdict == Verdict::Cancelled)
{
Verdict::Cancelled
} else {
cases
.iter()
.find(|c| c.verdict != Verdict::Accepted)
.map_or(Verdict::Accepted, |c| c.verdict)
}
}
}
};
let cpu = if !cases.is_empty() && cases.iter().all(|c| c.cpu_time_us.is_some()) {
Some(
cases
.iter()
.try_fold(0_u64, |n, c| n.checked_add(c.cpu_time_us.unwrap_or(0)))
.ok_or("CPU sum overflow")?,
)
} else {
None
};
let memory = if !cases.is_empty() && cases.iter().all(|c| c.memory_peak_bytes.is_some()) {
cases.iter().filter_map(|c| c.memory_peak_bytes).max()
} else {
None
};
let reason = if verdict == Verdict::Cancelled {
SkipReason::Cancelled
} else if verdict == Verdict::CompilationError {
SkipReason::CompilationFailed
} else {
SkipReason::InfrastructureFailure
};
let skipped = m.cases[cases.len()..]
.iter()
.map(|c| SkippedCase {
case_id: c.case_id,
reason,
})
.collect();
let report = JudgementReport {
schema_version: SchemaVersion,
kind: ResultKind::Value,
binding,
manifest_digest: digest,
compilation,
cases,
verdict,
cleanup_complete,
failure_code,
run_cpu_time_us: cpu,
run_memory_peak_bytes: memory,
};
report.validate_for_manifest(m, &report.manifest_digest)?;
Ok(SubmissionResult {
report,
compile_run_id,
case_run_ids: run_ids,
skipped,
})
}
#[cfg(test)]
mod tests {
use super::*;
struct Port {
prepared: CompiledProgram,
observations: Vec<Observation>,
prepare_status: CompilationStatus,
prepares: usize,
runs: usize,
expected_reads: usize,
cancel_after: usize,
clock: u64,
advance_us: u64,
fail_cleanup: bool,
}
impl PreparationPort for Port {
fn prepare(
&mut self,
_: &JobManifest,
_: &LeaseBinding,
_: &str,
) -> Result<Compiled, StageFailure> {
self.prepares += 1;
Ok(Compiled {
report: CompilationReport {
status: self.prepare_status,
program: (self.prepare_status == CompilationStatus::Succeeded)
.then(|| self.prepared.clone()),
diagnostics: String::new().try_into().unwrap(),
diagnostics_truncated: false,
cpu_time_us: None,
memory_peak_bytes: Some(4),
},
cleanup_complete: !self.fail_cleanup,
failure: None,
})
}
}
impl RunPort for Port {
fn run(
&mut self,
program: &CompiledProgram,
_: &TestCase,
_: &RunLimits,
_: &str,
) -> Result<Observation, StageFailure> {
assert_eq!(program, &self.prepared);
self.runs += 1;
Ok(self.observations.remove(0))
}
}
impl ExpectedPort for Port {
fn expected(&mut self, case: &TestCase) -> Result<Vec<u8>, StageFailure> {
self.expected_reads += 1;
Ok(vec![0; case.expected.bytes as usize])
}
}
impl ControlPort for Port {
fn elapsed_us(&self) -> u64 {
self.clock
.saturating_add(self.runs as u64 * self.advance_us)
}
fn cancelled(&self) -> bool {
self.runs >= self.cancel_after
}
fn stage(&mut self, _: Stage, _: Option<u16>) -> Result<(), StageFailure> {
Ok(())
}
}
fn fixture(language: &str) -> (JobManifest, LeaseBinding, Digest, Port) {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../tests/fixtures/contracts-v2");
let m: JobManifest = kf_contracts::judging::decode_wire(
&std::fs::read(root.join(format!("judge-manifest.valid-{language}.json"))).unwrap(),
)
.unwrap();
let r: JudgementReport = kf_contracts::judging::decode_wire(
&std::fs::read(root.join(format!("judge-result.valid-{language}.json"))).unwrap(),
)
.unwrap();
let p = Port {
prepared: r.compilation.program.unwrap(),
observations: m
.cases
.iter()
.map(|case| Observation {
kind: ExecutionKind::Exited { code: 0 },
stdout: vec![0; case.expected.bytes as usize],
cpu_time_us: Some(3),
memory_peak_bytes: None,
})
.collect(),
prepare_status: CompilationStatus::Succeeded,
prepares: 0,
runs: 0,
expected_reads: 0,
cancel_after: usize::MAX,
clock: 0,
advance_us: 0,
fail_cleanup: false,
};
(m, r.binding, r.manifest_digest, p)
}
#[test]
fn typed_prepare_once_same_program_every_point_and_unknown_measurements() {
for language in ["c17", "cpp20", "python313", "java21"] {
let (m, b, d, mut p) = fixture(language);
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(p.prepares, 1);
assert_eq!(p.runs, m.cases.len());
assert_eq!(r.report.verdict, Verdict::Accepted);
assert_eq!(r.report.run_memory_peak_bytes, None);
assert_eq!(r.report.compilation.cpu_time_us, None);
assert_eq!(r.report.run_cpu_time_us, Some(3 * m.cases.len() as u64));
assert!(r.skipped.is_empty());
}
}
#[test]
fn bad_program_is_refused_before_any_execution_or_expected_read() {
let (m, b, d, mut p) = fixture("java21");
p.prepared.source.digest = "f".repeat(64).try_into().unwrap();
assert!(judge_submission(&mut p, &m, b, d).is_err());
assert_eq!(p.runs, 0);
assert_eq!(p.expected_reads, 0);
let (m, b, d, mut p) = fixture("python313");
p.prepared.binding.fencing_version += 1;
assert!(judge_submission(&mut p, &m, b, d).is_err());
assert_eq!(p.runs, 0);
}
#[test]
fn preparation_failure_and_cleanup_precedence_never_fake_points() {
for status in [
CompilationStatus::CompilationError,
CompilationStatus::CompilationLimit,
CompilationStatus::Cancelled,
] {
let (m, b, d, mut p) = fixture("java21");
p.prepare_status = status;
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(p.runs, 0);
assert_eq!(r.skipped.len(), m.cases.len());
assert_eq!(
r.report.verdict,
if status == CompilationStatus::Cancelled {
Verdict::Cancelled
} else {
Verdict::CompilationError
}
);
}
let (m, b, d, mut p) = fixture("python313");
p.fail_cleanup = true;
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(r.report.failure_code, Some(FailureCode::CleanupFailed));
assert!(!r.report.cleanup_complete);
assert_eq!(p.runs, 0);
}
#[test]
fn trusted_outcome_precedes_comparison_and_text_cannot_fake_memory_limit() {
for (kind, verdict) in [
(ExecutionKind::Exited { code: 1 }, Verdict::RuntimeError),
(ExecutionKind::MemoryLimit, Verdict::MemoryLimitExceeded),
(ExecutionKind::TimeLimit, Verdict::TimeLimitExceeded),
(ExecutionKind::OutputLimit, Verdict::OutputLimitExceeded),
] {
let (m, b, d, mut p) = fixture("java21");
for o in &mut p.observations {
o.kind = kind;
o.stdout = b"OutOfMemoryError".to_vec();
}
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(r.report.verdict, verdict);
assert_eq!(p.expected_reads, 0);
}
}
#[test]
fn job_deadline_and_cancel_skip_tail_without_fabricated_grades() {
let (m, b, d, mut p) = fixture("python313");
p.advance_us = m.job_wall_time_us;
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(r.report.failure_code, Some(FailureCode::JobDeadline));
assert_eq!(p.runs, 1);
assert_eq!(p.expected_reads, 0);
let (m, b, d, mut p) = fixture("java21");
p.cancel_after = 1;
let r = judge_submission(&mut p, &m, b, d).unwrap();
assert_eq!(r.report.verdict, Verdict::Cancelled);
assert_eq!(r.skipped.len(), m.cases.len() - 1);
assert_eq!(p.runs, 1);
}
}
+95 -13
View File
@@ -236,6 +236,7 @@ impl OutputPlan {
let paths = if self.java_classes {
let mut paths = Vec::new();
let mut count = 0;
let mut unsupported = false;
// The entire group is frozen and drained. Discover only bounded,
// flat compiler outputs through the held private work directory.
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", work.as_raw_fd()))? {
@@ -251,10 +252,36 @@ impl OutputPlan {
continue;
}
if !kf_contracts::judging_v2::class_name(&name) {
// javac accepts packages and Unicode class names outside
// this protocol's flat ASCII class set. Suppress the whole
// batch so Worker reports unsupported entry as user CE.
// Links/special files remain infrastructure refusals and
// no unrecognized output is ever opened for reading.
let fd = rustix::fs::openat(
work,
name.as_str(),
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?;
let st = rustix::fs::fstat(&fd)?;
if st.st_dev != rustix::fs::fstat(work)?.st_dev
|| !matches!(
FileType::from_raw_mode(st.st_mode),
FileType::RegularFile | FileType::Directory
)
|| (FileType::from_raw_mode(st.st_mode) == FileType::RegularFile
&& st.st_nlink != 1)
{
return Err(refused("class export path/type"));
}
unsupported = true;
continue;
}
paths.push(name);
}
if unsupported {
return Ok(Vec::new());
}
paths.sort();
paths
} else {
@@ -406,8 +433,33 @@ pub struct StagePublication {
dev: u64,
ino: u64,
files: Vec<StageEntry>,
staged_bytes: u64,
removed: bool,
}
const MAX_STAGE_FILES: usize = kf_contracts::judging_v2::MAX_CLASS_FILES + 1;
const MAX_STAGE_JOURNAL_BYTES: u64 = 65_536;
fn indexed_program(name: &str) -> bool {
name.strip_prefix("program-").is_some_and(|index| {
index.len() == 3
&& index.bytes().all(|b| b.is_ascii_digit())
&& index
.parse::<usize>()
.is_ok_and(|i| i < kf_contracts::judging_v2::MAX_CLASS_FILES)
})
}
fn stage_entries_valid(names: &[&str]) -> bool {
let indexed = names.iter().any(|n| indexed_program(n));
let mut unique = std::collections::HashSet::new();
names.len() <= if indexed { MAX_STAGE_FILES } else { 2 }
&& names.iter().all(|n| {
unique.insert(*n)
&& if indexed {
*n == "stdin" || indexed_program(n)
} else {
matches!(*n, "source" | "program" | "stdin")
}
})
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StageEntry {
@@ -462,6 +514,7 @@ impl StagePublication {
dev: st.st_dev,
ino: st.st_ino,
files: vec![],
staged_bytes: 0,
removed: false,
};
let header = StageOwner {
@@ -537,16 +590,16 @@ impl StagePublication {
|| j.st_mode & 0o7777 != 0o600
|| j.st_nlink != 1
|| j.st_size < 0
|| j.st_size > 2048
|| j.st_size as u64 > MAX_STAGE_JOURNAL_BYTES
|| FileType::from_raw_mode(j.st_mode) != FileType::RegularFile
{
return Err(refused("untrusted stage journal"));
}
let mut bytes = String::new();
readonly_handle(&journal)?
.take(2049)
.take(MAX_STAGE_JOURNAL_BYTES + 1)
.read_to_string(&mut bytes)?;
if bytes.len() > 2048 || !bytes.ends_with('\n') {
if bytes.len() as u64 > MAX_STAGE_JOURNAL_BYTES || !bytes.ends_with('\n') {
return Err(refused("stage journal framing"));
}
let mut lines = bytes.lines();
@@ -572,13 +625,7 @@ impl StagePublication {
.map_err(|_| refused("stage entry journal"))
})
.collect::<io::Result<Vec<_>>>()?;
let mut unique = std::collections::HashSet::new();
if entries.len() > 2
|| entries.iter().any(|e| {
!matches!(e.name.as_str(), "source" | "program" | "stdin")
|| !unique.insert(e.name.clone())
})
{
if !stage_entries_valid(&entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>()) {
return Err(refused("stage entry set"));
}
let mut value = Self {
@@ -588,6 +635,7 @@ impl StagePublication {
dev: st.st_dev,
ino: st.st_ino,
files: entries,
staged_bytes: 0,
removed: false,
};
value.cleanup()?;
@@ -599,9 +647,12 @@ impl StagePublication {
&self.root
}
pub fn put(&mut self, name: &str, reader: &mut impl Read, bytes: u64) -> io::Result<()> {
if !matches!(name, "source" | "program" | "stdin")
|| self.files.len() >= 2
let mut names: Vec<_> = self.files.iter().map(|e| e.name.as_str()).collect();
names.push(name);
if !stage_entries_valid(&names)
|| bytes > MAX_SNAPSHOT_BYTES
|| (indexed_program(name) && bytes == 0)
|| self.staged_bytes.saturating_add(bytes) > MAX_SNAPSHOT_BYTES
{
return Err(refused("fixed staging entry/budget"));
}
@@ -628,6 +679,7 @@ impl StagePublication {
file.write_all(b"\n")?;
file.sync_all()?;
self.files.push(entry);
self.staged_bytes += bytes;
let mut file = File::from(fd);
let copied = io::copy(&mut reader.take(bytes + 1), &mut file)?;
if copied != bytes {
@@ -650,7 +702,7 @@ impl StagePublication {
use rustix::fd::AsRawFd;
let actual: Vec<_> =
std::fs::read_dir(format!("/proc/self/fd/{}", self.root.fd.as_raw_fd()))?
.take(5)
.take(MAX_STAGE_FILES + 2)
.map(|e| Ok(e?.file_name()))
.collect::<io::Result<_>>()?;
if actual.len() != self.files.len() + 1
@@ -710,3 +762,33 @@ impl Drop for StagePublication {
let _ = self.cleanup();
}
}
#[cfg(test)]
mod stage_policy_tests {
use super::*;
#[test]
fn bounded_indexed_batch_and_legacy_names_are_separate() {
assert!(stage_entries_valid(&["source"]));
assert!(stage_entries_valid(&["program", "stdin"]));
assert!(!stage_entries_valid(&["source", "program", "stdin"]));
let mut names: Vec<_> = (0..256).map(|i| format!("program-{i:03}")).collect();
names.push("stdin".into());
assert!(stage_entries_valid(
&names.iter().map(String::as_str).collect::<Vec<_>>()
));
for name in [
"program-256",
"program-00",
"program-0000",
"program-../a",
"Main.class",
".owner",
"expected",
] {
assert!(!stage_entries_valid(&[name]));
}
assert!(!stage_entries_valid(&["program-000", "source"]));
assert!(!stage_entries_valid(&["program-000", "program"]));
assert!(!stage_entries_valid(&["program-000", "program-000"]));
}
}
+42 -1
View File
@@ -46,6 +46,23 @@ fn refused() -> io::Error {
"image catalog/integrity refused",
)
}
fn catalog_path(kind: ImageKind, name: &str) -> bool {
if kind != ImageKind::GnuCxx {
return crate::spec::path(name, "image").is_ok();
}
// GNU's immutable standard-library directories include c++ and libstdc++.
// This operator catalog rule never changes caller artifact path validation.
!name.is_empty()
&& name.len() <= 4096
&& name.split('/').all(|p| {
!p.is_empty()
&& p != "."
&& p != ".."
&& p.len() <= 255
&& p.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-+".contains(&b))
})
}
pub fn catalog_digest(data: &[u8]) -> Digest {
format!("{:x}", Sha256::digest(data))
.try_into()
@@ -81,7 +98,9 @@ impl ImageCatalog {
.iter()
.chain(self.files.iter().map(|v| &v.path))
{
crate::spec::path(name, "image").map_err(|_| refused())?;
if !catalog_path(self.image_kind, name) {
return Err(refused());
}
if name == "manifest.json"
|| name
.split('/')
@@ -290,3 +309,25 @@ fn walk<'a>(
}
Ok(())
}
#[cfg(test)]
mod path_tests {
use super::*;
#[test]
fn trusted_gnu_library_paths_do_not_widen_request_or_managed_image_paths() {
for p in ["usr/include/c++/16.2.1/vector", "usr/lib/libstdc++.so.6"] {
assert!(catalog_path(ImageKind::GnuCxx, p));
assert!(!catalog_path(ImageKind::CPython313, p));
assert!(crate::spec::path(p, "artifact").is_err());
}
for p in [
"/usr/lib",
"usr/../lib",
"usr//lib",
"usr/./lib",
"usr/lib\0",
] {
assert!(!catalog_path(ImageKind::GnuCxx, p));
}
}
}
+31 -1
View File
@@ -244,10 +244,22 @@ impl ControlChannel {
}
pub(crate) fn execution_deadline(&mut self, wall_us: u64) -> io::Result<()> {
self.runtime_deadline(wall_us, Duration::ZERO)
}
/// The ordinary RPC starts before catalog verification and isolation setup.
/// Its fixed preparation allowance is separate from the student's measured
/// execution wall limit. The factory's job/cancellation watcher stays active.
pub(crate) fn request_deadline(&mut self, wall_us: u64) -> io::Result<()> {
self.runtime_deadline(wall_us, Duration::from_secs(30))
}
fn runtime_deadline(&mut self, wall_us: u64, preparation: Duration) -> io::Result<()> {
if wall_us == 0 || wall_us > 120_000_000 || self.poisoned || !self.pending.is_empty() {
return Err(invalid("execution deadline"));
}
self.deadline = Instant::now() + Duration::from_micros(wall_us) + Duration::from_secs(5);
self.deadline =
Instant::now() + preparation + Duration::from_micros(wall_us) + Duration::from_secs(5);
Ok(())
}
@@ -579,6 +591,24 @@ mod tests {
)
}
#[test]
fn request_preparation_allowance_is_fixed_and_execution_limit_is_separate() {
let (mut request, mut execution) = pair();
let start = Instant::now();
request.request_deadline(3_000_000).unwrap();
execution.execution_deadline(3_000_000).unwrap();
assert!(request.deadline.duration_since(start) >= Duration::from_secs(38));
assert!(request.deadline.duration_since(start) < Duration::from_secs(39));
assert!(execution.deadline.duration_since(start) >= Duration::from_secs(8));
assert!(execution.deadline.duration_since(start) < Duration::from_secs(9));
request.request_deadline(120_000_000).unwrap();
assert!(request.deadline.duration_since(start) < Duration::from_secs(156));
assert!(request.request_deadline(0).is_err());
assert!(request.request_deadline(120_000_001).is_err());
request.poisoned = true;
assert!(request.request_deadline(3_000_000).is_err());
}
#[test]
fn descriptor_transport_preserves_identity_and_cloexec() {
let (mut a, mut b) = pair();
+2 -2
View File
@@ -117,7 +117,7 @@ pub(crate) fn execute_channel(
) -> io::Result<Executed> {
c.send(&Request { spec })?;
c.send_fd(publication)?;
c.execution_deadline(validated.spec().limits.wall_time_us)?;
c.request_deadline(validated.spec().limits.wall_time_us)?;
let report = match c.receive::<Reply>()? {
Reply::Completed { report } => *report,
Reply::Rejected {
@@ -229,7 +229,7 @@ pub(crate) fn serve_stream(
}
let artifacts = ArtifactRoot::from_worker_directory(c.receive_fd()?, ids.worker_uid)?;
let peer = c.duplicate_stream()?;
c.execution_deadline(request.spec.limits.wall_time_us)?;
c.request_deadline(request.spec.limits.wall_time_us)?;
match execution::execute(
config,
request.spec,
@@ -0,0 +1,142 @@
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
//! Indexed Worker publications, actual seals/recovery/foreign preservation in VM only.
#[allow(dead_code)]
mod support;
use kf_sandbox::artifacts::StagePublication;
use std::{
fs,
io::Read,
os::unix::{
fs::{MetadataExt, PermissionsExt, chown},
process::CommandExt,
},
path::PathBuf,
process::Command,
};
fn child(name: &str, path: &std::path::Path) -> std::process::Output {
Command::new(std::env::current_exe().unwrap())
.env_clear()
.env("KF_SANDBOX_INDEPENDENT_VM", "1")
.env("KF_INDEXED_STAGE_DIR", path)
.uid(1000)
.gid(1000)
.args([
"--ignored",
"--exact",
name,
"--test-threads=1",
"--nocapture",
])
.output()
.unwrap()
}
#[test]
#[ignore = "ordinary peer subprocess in capped independent VM"]
fn leave_indexed_publication_for_recovery() {
support::require_vm();
assert_eq!(rustix::process::geteuid().as_raw(), 1000);
let path = PathBuf::from(std::env::var_os("KF_INDEXED_STAGE_DIR").unwrap());
let mut p = StagePublication::create(&path, "dead-owner").unwrap();
for i in 0..256 {
p.put(&format!("program-{i:03}"), &mut b"class".as_slice(), 5)
.unwrap();
}
p.put("stdin", &mut b"".as_slice(), 0).unwrap();
assert!(p.put("program-256", &mut b"class".as_slice(), 5).is_err());
assert!(p.put("program", &mut b"class".as_slice(), 5).is_err());
assert!(p.put("program-001", &mut b"class".as_slice(), 5).is_err());
assert!(fs::metadata(path.join("dead-owner/.owner")).unwrap().len() < 65536);
std::mem::forget(p);
println!("KF_INDEXED_DEAD_OWNER=1");
}
#[test]
#[ignore = "ordinary peer subprocess in capped independent VM"]
fn ordinary_indexed_staging_client() {
support::require_vm();
assert_eq!(rustix::process::geteuid().as_raw(), 1000);
let path = PathBuf::from(std::env::var_os("KF_INDEXED_STAGE_DIR").unwrap());
let mut p = StagePublication::create(&path, "sealed").unwrap();
p.put("program-000", &mut b"\0checked\xff".as_slice(), 9)
.unwrap();
assert!(p.put("program-001", &mut b"".as_slice(), 0).is_err());
for name in [
"../foreign",
"program-1",
"program-256",
"source",
"program",
] {
assert!(p.put(name, &mut b"x".as_slice(), 1).is_err());
}
assert!(
p.put("program-001", &mut b"x".as_slice(), 64 * 1024 * 1024 + 1)
.is_err()
);
let snapshot = p.root().snapshot_artifact("program-000", 9).unwrap();
fs::write(
path.join("sealed/program-000"),
b"overwritten backing bytes",
)
.unwrap();
let mut bytes = vec![];
snapshot.reader().unwrap().read_to_end(&mut bytes).unwrap();
assert_eq!(bytes, b"\0checked\xff");
let seals = rustix::fs::fcntl_get_seals(&snapshot).unwrap();
assert!(seals.contains(
rustix::fs::SealFlags::WRITE
| rustix::fs::SealFlags::GROW
| rustix::fs::SealFlags::SHRINK
| rustix::fs::SealFlags::SEAL
));
assert_eq!(StagePublication::recover(&path).unwrap(), 0);
p.cleanup().unwrap();
let owner = child("leave_indexed_publication_for_recovery", &path);
assert!(owner.status.success(), "{owner:?}");
assert!(String::from_utf8_lossy(&owner.stdout).contains("KF_INDEXED_DEAD_OWNER=1"));
let foreign = path.join("dead-owner/foreign");
fs::write(&foreign, b"foreign canary").unwrap();
let inode = fs::metadata(&foreign).unwrap().ino();
assert!(StagePublication::recover(&path).is_err());
assert_eq!(fs::metadata(&foreign).unwrap().ino(), inode);
assert_eq!(fs::read(&foreign).unwrap(), b"foreign canary");
fs::remove_file(foreign).unwrap();
assert_eq!(StagePublication::recover(&path).unwrap(), 1);
assert_eq!(fs::read_dir(&path).unwrap().count(), 0);
let mut p = StagePublication::create(&path, "replaced").unwrap();
p.put("program-000", &mut b"original".as_slice(), 8)
.unwrap();
let file = path.join("replaced/program-000");
fs::rename(&file, path.join("original-retained")).unwrap();
fs::write(&file, b"different inode").unwrap();
let inode = fs::metadata(&file).unwrap().ino();
assert!(p.cleanup().is_err());
assert_eq!(fs::metadata(&file).unwrap().ino(), inode);
assert_eq!(fs::read(&file).unwrap(), b"different inode");
fs::remove_file(&file).unwrap();
fs::rename(path.join("original-retained"), &file).unwrap();
p.cleanup().unwrap();
assert_eq!(fs::read_dir(&path).unwrap().count(), 0);
println!(
"KF_INDEXED_STAGING_PROOF files=256 stdin=1 bounds=1 seals=1 live_owner=1 dead_owner_recovered=1 foreign_inode_preserved=1 replaced_inode_preserved=1 leftovers=0"
);
}
#[test]
#[ignore = "actual ordinary indexed staging only inside independent capped no-NIC/no-share VM"]
fn indexed_staging_recovers_only_owned_inodes_and_preserves_sealed_bytes() {
support::require_vm();
let dir = PathBuf::from(format!("/kf/indexed-staging-{}", std::process::id()));
fs::create_dir(&dir).unwrap();
chown(&dir, Some(1000), Some(1000)).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
let output = child("ordinary_indexed_staging_client", &dir);
assert!(output.status.success(), "{output:?}");
assert!(String::from_utf8_lossy(&output.stdout).contains("KF_INDEXED_STAGING_PROOF"));
assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
fs::remove_dir(dir).unwrap();
print!("{}", String::from_utf8_lossy(&output.stdout));
println!("KF_INDEXED_STAGING_STATUS=0");
}
@@ -0,0 +1,509 @@
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
//! Real ordinary Worker/Engine and default Root, only in the independent VM.
#[allow(dead_code)]
mod support;
use kf_contracts::{judging as v1, judging_v2 as v2};
use kf_sandbox::{
factory::FactoryClient,
image::{ImageCatalog, catalog_digest},
runtime::{ImageKind, RegisteredImage},
spec::{ExecutionProfile, ResourceLimits},
};
use sha2::{Digest as _, Sha256};
use std::{
fs,
os::unix::{
fs::{PermissionsExt, chown},
process::CommandExt,
},
path::{Path, PathBuf},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
fn store(path: &Path, data: &[u8]) {
fs::write(path, data).unwrap();
chown(path, Some(1000), Some(1000)).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
}
fn id(n: u32) -> String {
format!("aaaaaaaa-aaaa-4aaa-8aaa-{n:012}")
}
fn budget(profile: ExecutionProfile) -> v1::RunLimits {
let mut b = ResourceLimits::for_profile(profile);
if profile.prepares_program() {
b.cpu_time_us = 30_000_000;
b.wall_time_us = 60_000_000;
} else if matches!(
profile,
ExecutionProfile::JavaRun | ExecutionProfile::PythonRun
) {
b.cpu_time_us = 5_000_000;
b.wall_time_us = 10_000_000;
}
serde_json::from_value(serde_json::to_value(b).unwrap()).unwrap()
}
struct Fixture {
root: support::Root,
dir: PathBuf,
process: Child,
next: u32,
}
impl Fixture {
fn new() -> Self {
support::require_vm();
let mut root = support::Root::new();
root.config.max_concurrent_runs = 1;
root.config.identities.count = 1;
root.config.aggregate.task_cpu_quota_us = 100000;
root.config.per_task_ceiling.cpu_time_us = 30_000_000;
root.config.per_task_ceiling.wall_time_us = 60_000_000;
let mut capabilities = Vec::new();
for (name, kind, languages) in [
(
"python313",
ImageKind::LinuxCPython313X86_64V1,
vec![v2::Language::Python313],
),
(
"java21",
ImageKind::LinuxOpenJdk21X86_64V1,
vec![v2::Language::Java21],
),
(
"gnu-v2",
ImageKind::LinuxGnuCxxX86_64V1,
vec![v2::Language::C17, v2::Language::Cpp20],
),
] {
let image_root = format!("/var/lib/kf-judge/images/{name}");
let raw = fs::read(format!("{image_root}/manifest.json")).unwrap();
let c: ImageCatalog = serde_json::from_slice(&raw).unwrap();
let toolchain = v2::RuntimeIdentity {
toolchain_id: c.toolchain_id,
image_kind: c.image_kind,
image_revision: c.image_revision,
image_digest: catalog_digest(&raw),
runtime_version: c.runtime_version,
};
for language in languages {
let (prepare, run) = match language {
v2::Language::C17 => (ExecutionProfile::CompileC, ExecutionProfile::Native),
v2::Language::Cpp20 => (ExecutionProfile::CompileCpp, ExecutionProfile::Native),
v2::Language::Python313 => {
(ExecutionProfile::PythonCheck, ExecutionProfile::PythonRun)
}
v2::Language::Java21 => {
(ExecutionProfile::JavaCompile, ExecutionProfile::JavaRun)
}
};
capabilities.push(v2::RuntimeCapability {
language,
toolchain: toolchain.clone(),
compile_ceiling: budget(prepare),
run_ceiling: budget(run),
});
}
root.config.runtime_images.push(RegisteredImage {
image_root,
image_kind: kind,
runtime_identity: toolchain,
per_task_ceiling: root.config.per_task_ceiling.clone(),
});
}
root.config.clone().validate().unwrap();
let dir = PathBuf::from(format!("/kf/managed-pipeline-{}", std::process::id()));
fs::create_dir(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o711)).unwrap();
for name in ["artifacts", "staging"] {
let p = dir.join(name);
fs::create_dir(&p).unwrap();
chown(&p, Some(1000), Some(1000)).unwrap();
fs::set_permissions(&p, fs::Permissions::from_mode(0o700)).unwrap();
}
let admission = v2::AdmissionPolicy {
schema_version: v2::SchemaVersion,
max_concurrent_jobs: 1,
prefetch: 1,
max_retries: 2,
max_job_wall_time_us: 180_000_000,
max_package_bytes: v1::MAX_PACKAGE_BYTES,
capabilities,
};
admission.validate().unwrap();
store(
&dir.join("admission.json"),
&serde_json::to_vec(&admission).unwrap(),
);
fs::write(
dir.join("root.json"),
serde_json::to_vec(&root.config).unwrap(),
)
.unwrap();
fs::set_permissions(dir.join("root.json"), fs::Permissions::from_mode(0o600)).unwrap();
let mut process = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap())
.env_clear()
.args(["factory", "--config"])
.arg(dir.join("root.json"))
.args(["--lifetime-seconds", "1200"])
.stdout(Stdio::null())
.stderr(fs::File::create(dir.join("root-diagnostics.log")).unwrap())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(90);
while !fs::metadata("/var/lib/kf-judge/factory/control.sock")
.is_ok_and(|m| m.permissions().mode() & 0o777 == 0o660)
{
assert!(
process.try_wait().unwrap().is_none(),
"factory startup refused"
);
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
}
Self {
root,
dir,
process,
next: 1000,
}
}
fn artifact(&self, data: &[u8]) -> v1::ArtifactReference {
let digest: v1::Digest = format!("{:x}", Sha256::digest(data)).try_into().unwrap();
store(&self.dir.join("artifacts").join(digest.as_str()), data);
v1::ArtifactReference {
digest,
bytes: data.len() as u64,
}
}
fn execute(
&mut self,
language: v2::Language,
source: &[u8],
points: &[(&[u8], &[u8])],
verdict: v1::Verdict,
legacy: bool,
) -> serde_json::Value {
self.next += 10;
let mut m: serde_json::Value = serde_json::from_str(include_str!(
"../../../../tests/fixtures/contracts-v2/judge-manifest.valid-python313.json"
))
.unwrap();
let n = self.next;
let binding = v1::LeaseBinding {
evaluation_id: id(n).try_into().unwrap(),
attempt_id: id(n + 1).try_into().unwrap(),
worker_id: id(8).try_into().unwrap(),
fencing_version: 1,
};
let admission: v2::AdmissionPolicy =
serde_json::from_slice(&fs::read(self.dir.join("admission.json")).unwrap()).unwrap();
let cap = admission
.capabilities
.iter()
.find(|c| c.language == language)
.unwrap();
m["evaluation_id"] = serde_json::json!(id(n));
m["submission_id"] = serde_json::json!(id(n + 2));
m["source"] = serde_json::to_value(self.artifact(source)).unwrap();
m["language"] = serde_json::to_value(language).unwrap();
m["toolchain"] = serde_json::to_value(&cap.toolchain).unwrap();
m["compile_limits"] = serde_json::to_value(&cap.compile_ceiling).unwrap();
m["run_limits"] = serde_json::to_value(&cap.run_ceiling).unwrap();
m["job_wall_time_us"] = serde_json::json!(180_000_000);
m["cases"] = serde_json::json!(
points
.iter()
.enumerate()
.map(|(i, (input, expected))| v1::TestCase {
case_id: id(n + 3 + i as u32).try_into().unwrap(),
input: self.artifact(input),
expected: self.artifact(expected)
})
.collect::<Vec<_>>()
);
let canonical = if legacy {
m["schema_version"] = serde_json::json!(1);
m["toolchain"] = serde_json::json!({"toolchain_id":"gnu-cxx-v1","image_kind":"linux_gnu_cxx_x86_64_v1","image_revision":1});
v1::manifest_bytes(&serde_json::from_value(m.clone()).unwrap()).unwrap()
} else {
v2::manifest_bytes(&serde_json::from_value(m.clone()).unwrap()).unwrap()
};
let digest = format!("{:x}", Sha256::digest(canonical));
let mut job = serde_json::json!({"manifest":m,"binding":binding,"manifest_digest":digest});
if !legacy {
job["attempt_number"] = serde_json::json!(1);
}
store(
&self.dir.join("job.json"),
&serde_json::to_vec(&job).unwrap(),
);
let mut command = Command::new(std::env::var_os("KF_WORKER_TEST_BIN").unwrap());
command
.env_clear()
.uid(1000)
.gid(1000)
.arg(if legacy {
"judge-submission"
} else {
"judge-submission-v2"
})
.args(["--socket", "/var/lib/kf-judge/factory/control.sock"])
.arg("--job")
.arg(self.dir.join("job.json"))
.arg("--artifacts")
.arg(self.dir.join("artifacts"))
.arg("--staging")
.arg(self.dir.join("staging"));
if !legacy {
command
.arg("--admission")
.arg(self.dir.join("admission.json"));
}
let output = command.output().unwrap();
if !output.stderr.is_empty() {
println!(
"KF_PIPELINE_WORKER_DIAGNOSTIC {}",
String::from_utf8_lossy(&output.stderr)
);
}
assert!(
output.status.success(),
"worker failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let actual: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
if actual["result"]["report"]["verdict"] != serde_json::to_value(verdict).unwrap() {
use std::io::Read;
let mut diagnostic = Vec::new();
fs::File::open(self.dir.join("root-diagnostics.log"))
.unwrap()
.take(16_384)
.read_to_end(&mut diagnostic)
.unwrap();
println!(
"KF_PIPELINE_ROOT_DIAGNOSTIC {}",
String::from_utf8_lossy(&diagnostic)
);
}
assert_eq!(
actual["result"]["report"]["verdict"],
serde_json::to_value(verdict).unwrap(),
"{actual}"
);
assert_eq!(
actual["result"]["report"]["cleanup_complete"], true,
"{actual}"
);
assert_eq!(
actual["result"]["report"]["schema_version"],
if legacy { 1 } else { 2 }
);
let stages = actual["stages"].as_array().unwrap();
assert!(!stages.is_empty());
let mut ids = std::collections::HashSet::new();
for s in stages {
assert!(s["helper_pid"].as_u64().unwrap() > 1);
assert!(ids.insert(s["helper_pid"].as_u64().unwrap()));
assert_eq!(s["report"]["errors"], serde_json::json!([]));
assert_eq!(s["report"]["cleanup"], "complete");
}
assert_eq!(
stages.len(),
if verdict == v1::Verdict::CompilationError {
1
} else {
points.len() + 1
}
);
assert_eq!(fs::read_dir(self.dir.join("staging")).unwrap().count(), 0);
let stopped = Command::new(std::env::current_exe().unwrap())
.env_clear()
.uid(1000)
.gid(1000)
.args([
"--ignored",
"--exact",
"ordinary_pipeline_stopped_client",
"--test-threads=1",
"--nocapture",
])
.output()
.unwrap();
assert!(stopped.status.success(), "{stopped:?}");
assert!(String::from_utf8_lossy(&stopped.stdout).contains("KF_PIPELINE_STOPPED=1"));
for name in ["tasks", "captures", "identities"] {
assert_eq!(
fs::read_dir(format!("/var/lib/kf-judge/{name}"))
.unwrap()
.count(),
0
);
}
println!(
"KF_MANAGED_PIPELINE_PROOF {}",
serde_json::json!({"worker_uid":1000,"language":language,"legacy":legacy,"job":job,"actual":actual,"staging_empty":true,"global_stopped":true})
);
actual
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.process.kill();
let _ = self.process.wait();
for name in ["requests", "socket.json", "control.sock", "lock"] {
let _ = fs::remove_file(Path::new("/var/lib/kf-judge/factory").join(name));
}
let _ = fs::remove_dir("/var/lib/kf-judge/factory");
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
#[ignore = "ordinary peer subprocess inside the isolated pipeline VM"]
fn ordinary_pipeline_stopped_client() {
assert_eq!(rustix::process::geteuid().as_raw(), 1000);
assert_eq!(rustix::process::getegid().as_raw(), 1000);
assert!(
FactoryClient::new(PathBuf::from("/var/lib/kf-judge/factory/control.sock"))
.stopped()
.unwrap()
);
println!("KF_PIPELINE_STOPPED=1");
}
#[test]
#[ignore = "actual ordinary Worker typed Python/Java and old/new GNU only inside capped no-NIC/no-share VM"]
fn ordinary_worker_prepares_once_and_runs_all_four_runtimes() {
let mut f = Fixture::new();
let points: &[(&[u8], &[u8])] = &[
(b"", b""),
(b"\0\xff\n", b"\0\xff\n"),
("你好\n".as_bytes(), "你好\n".as_bytes()),
];
let py = br#"import os,sys
assert not os.path.exists('previous')
assert not os.path.exists('expected')
assert not os.path.exists('/kf')
open('previous','w').close()
sys.stdout.buffer.write(sys.stdin.buffer.read())
"#;
f.execute(
v2::Language::Python313,
py,
points,
v1::Verdict::Accepted,
false,
);
f.execute(
v2::Language::Python313,
py,
&[(b"x", b"y"), (b"", b"")],
v1::Verdict::WrongAnswer,
false,
);
f.execute(
v2::Language::Python313,
b"def bad(:\n",
&[(b"", b"")],
v1::Verdict::CompilationError,
false,
);
f.execute(
v2::Language::Python313,
b"raise MemoryError('fake kernel OOM')\n",
&[(b"", b"")],
v1::Verdict::RuntimeError,
false,
);
let pyloop = f.execute(
v2::Language::Python313,
b"while True: pass\n",
&[(b"", b"")],
v1::Verdict::TimeLimitExceeded,
false,
);
assert_eq!(
pyloop["result"]["report"]["compilation"]["status"],
"succeeded"
);
let java=br#"import java.io.*; public class Main {static int counter=0;static class Inner {} public static void main(String[] args) throws Exception {new Runnable(){public void run(){}}.run();if(++counter!=1||new File("previous").exists()||new File("expected").exists()||new File("/kf").exists()||Main.class.getResource("Main.java")!=null)throw new AssertionError();new File("previous").createNewFile();Helper.copy();}} class Helper {static void copy() throws Exception {System.in.transferTo(System.out);}}"#;
let j = f.execute(
v2::Language::Java21,
java,
points,
v1::Verdict::Accepted,
false,
);
assert_eq!(
j["result"]["report"]["compilation"]["program"]["files"]
.as_array()
.unwrap()
.len(),
4
);
f.execute(
v2::Language::Java21,
java,
&[(b"x", b"y")],
v1::Verdict::WrongAnswer,
false,
);
for source in [
&b"public class Main { syntax error }"[..],
&b"public class Main {static {if(System.nanoTime()>0){while(true){}}}}"[..],
&b"package unwanted; public class Main {public static void main(String[] a){}}"[..],
&b"public class Wrong {public static void main(String[] a){}}"[..],
] {
f.execute(
v2::Language::Java21,
source,
&[(b"", b"")],
v1::Verdict::CompilationError,
false,
);
}
f.execute(v2::Language::Java21,b"public class Main {public static void main(String[] a){throw new OutOfMemoryError(\"fake kernel OOM\");}}",&[(b"",b"")],v1::Verdict::RuntimeError,false);
gnu_points(&mut f, points);
assert!(
!fs::read_dir(&f.root.path)
.unwrap()
.any(|e| e.unwrap().file_type().unwrap().is_dir())
);
println!("KF_MANAGED_PIPELINE_STATUS=0");
}
fn gnu_points(f: &mut Fixture, points: &[(&[u8], &[u8])]) {
let c=b"#include <stdio.h>\nint main(void){int c;while((c=getchar())!=EOF)putchar(c);return 0;}\n";
let cpp = b"#include <iostream>\nint main(){char c;while(std::cin.get(c))std::cout.put(c);}\n";
f.execute(v2::Language::C17, c, points, v1::Verdict::Accepted, false);
f.execute(
v2::Language::Cpp20,
cpp,
points,
v1::Verdict::Accepted,
false,
);
f.execute(v2::Language::C17, c, points, v1::Verdict::Accepted, true);
f.execute(
v2::Language::Cpp20,
cpp,
points,
v1::Verdict::Accepted,
true,
);
}
#[test]
#[ignore = "targeted actual ordinary Worker new/legacy GNU after typed staging changes inside capped VM"]
fn ordinary_worker_runs_versioned_and_legacy_gnu() {
let mut f = Fixture::new();
let points: &[(&[u8], &[u8])] = &[
(b"", b""),
(b"\0\xff\n", b"\0\xff\n"),
("你好\n".as_bytes(), "你好\n".as_bytes()),
];
gnu_points(&mut f, points);
assert!(
!fs::read_dir(&f.root.path)
.unwrap()
.any(|e| e.unwrap().file_type().unwrap().is_dir())
);
println!("KF_MANAGED_GNU_PIPELINE_STATUS=0");
}
+256 -46
View File
@@ -1,9 +1,16 @@
//! Ordinary transport adapter. AMQP is a bounded wake-up; claim is sole authority.
use crate::pipeline::{ArtifactPort, Attempt};
use crate::{pipeline_v2::RuntimeAttempt, runtime_specs::AuthorizedJob};
use futures_lite::{StreamExt, future::block_on};
use kf_contracts::judging_versions::{JobManifest as VersionedManifest, NodeAdmission};
use kf_contracts::judging_v2 as v2;
use kf_contracts::judging_versions::{
self as versions, JobManifest as VersionedManifest, NodeAdmission,
};
use kf_contracts::{JudgeTaskNotification, SchemaVersion, judging::*};
use kf_engine::submission::{StageFailure, judge_submission};
use kf_engine::{
submission::{StageFailure, judge_submission},
submission_v2,
};
use kf_sandbox::{artifacts::StagePublication, factory::FactoryClient};
use lapin::{Connection, ConnectionProperties, options::*, types::FieldTable};
use reqwest::blocking::Client;
@@ -20,6 +27,37 @@ use std::{
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
const QUEUE: &str = "kf.judge.evaluations.v1";
/// Transport projection after strict versioned wire decoding. It never changes
/// the authorized manifest or its canonical bytes.
#[derive(Clone)]
struct Grant {
version: u16,
binding: LeaseBinding,
lease_token: LeaseToken,
attempt_number: u32,
expires_at_unix_ms: u64,
manifest_digest: Digest,
}
impl From<versions::LeaseGrant> for Grant {
fn from(wire: versions::LeaseGrant) -> Self {
macro_rules! fields {
($g:ident, $version:literal) => {
Self {
version: $version,
binding: $g.binding,
lease_token: $g.lease_token,
attempt_number: $g.attempt_number,
expires_at_unix_ms: $g.expires_at_unix_ms,
manifest_digest: $g.manifest_digest,
}
};
}
match wire {
versions::LeaseGrant::V1(g) => fields!(g, 1),
versions::LeaseGrant::V2(g) => fields!(g, 2),
}
}
}
#[derive(Debug)]
struct TransportError {
operation: &'static str,
@@ -139,7 +177,7 @@ impl Remote {
&self,
route: &str,
body: &T,
grant: Option<&LeaseGrant>,
grant: Option<&Grant>,
) -> io::Result<reqwest::blocking::Response> {
let mut r = self
.client
@@ -173,8 +211,9 @@ impl Remote {
let v: Vec<Pending> = serde_json::from_slice(&b)
.map_err(|_| transport_error("coordinator_pending", "decode"))?;
if v.len() > 16
|| v.iter()
.any(|p| p.case_count == 0 || p.case_count > MAX_CASES)
|| v.iter().any(|p| {
p.case_count == 0 || p.case_count > MAX_CASES || !matches!(p.schema_version, 1 | 2)
})
{
return Err(error());
}
@@ -193,12 +232,7 @@ impl Remote {
if p.binding.worker_id != c.worker_id {
return Err(error());
}
let r = StoppedReport {
schema_version: SchemaVersion,
kind: StoppedKind::Value,
binding: p.binding,
cleanup_complete: clean,
};
let r = p.stopped(clean)?;
if self.post("/internal/stopped", &r, None)?.status().as_u16() != 204 {
return Err(error());
}
@@ -209,13 +243,37 @@ impl Remote {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Pending {
#[serde(default = "legacy_version")]
schema_version: u16,
binding: LeaseBinding,
case_count: usize,
}
fn legacy_version() -> u16 {
1
}
impl Pending {
fn stopped(self, clean: bool) -> io::Result<versions::StoppedReport> {
match self.schema_version {
1 => Ok(versions::StoppedReport::V1(StoppedReport {
schema_version: SchemaVersion,
kind: StoppedKind::Value,
binding: self.binding,
cleanup_complete: clean,
})),
2 => Ok(versions::StoppedReport::V2(v2::StoppedReport {
schema_version: v2::SchemaVersion,
kind: StoppedKind::Value,
binding: self.binding,
cleanup_complete: clean,
})),
_ => Err(error()),
}
}
}
struct RemoteArtifacts {
client: Client,
base: String,
grant: LeaseGrant,
grant: Grant,
}
impl RemoteArtifacts {
fn headers(&self, r: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder {
@@ -234,24 +292,27 @@ impl RemoteArtifacts {
self.grant.binding.attempt_id.as_uuid()
)
}
fn manifest(&self) -> io::Result<JobManifest> {
fn manifest(&self) -> io::Result<VersionedManifest> {
let response = self
.headers(self.client.get(self.url("/manifest")))
.send()
.map_err(|_| error())?;
let m: JobManifest =
decode_wire(&Remote::bytes(response, MAX_WIRE_BYTES)?).map_err(|_| error())?;
authorized_manifest(&Remote::bytes(response, MAX_WIRE_BYTES)?, &self.grant)
}
}
fn authorized_manifest(raw: &[u8], grant: &Grant) -> io::Result<VersionedManifest> {
let m: VersionedManifest = decode_wire(raw).map_err(|_| error())?;
let digest = format!(
"{:x}",
Sha256::digest(manifest_bytes(&m).map_err(|_| error())?)
Sha256::digest(m.canonical_bytes().map_err(|_| error())?)
);
if digest != self.grant.manifest_digest.as_str()
|| m.evaluation_id != self.grant.binding.evaluation_id
if digest != grant.manifest_digest.as_str()
|| m.evaluation_id() != grant.binding.evaluation_id
|| m.version() != grant.version
{
return Err(error());
}
Ok(m)
}
}
impl ArtifactPort for RemoteArtifacts {
fn read(&mut self, reference: &ArtifactReference) -> io::Result<Vec<u8>> {
@@ -282,7 +343,7 @@ fn lease_deadline(ms: u64) -> io::Result<Instant> {
.ok_or_else(error)?;
Ok(Instant::now() + Duration::from_millis(remaining))
}
fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Result<()> {
fn execute(c: &Config, r: &Remote, g: Grant, stop: &AtomicBool) -> io::Result<()> {
if g.binding.worker_id != c.worker_id {
return Err(error());
}
@@ -293,7 +354,7 @@ fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Resu
};
let m = artifacts.manifest()?;
c.admission
.admit(&VersionedManifest::V1(m.clone()), g.attempt_number)
.admit(&m, g.attempt_number)
.map_err(|_| error())?;
let cancel = AtomicBool::new(false);
let done = AtomicBool::new(false);
@@ -329,23 +390,9 @@ fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Resu
});
let result = (|| {
let mut sequence = 0u64;
let mut adapter = Attempt::new(
artifacts,
c.socket.clone(),
c.staging.clone(),
m.job_wall_time_us,
&cancel,
)?
.with_control(|stage, index| {
let mut control = |stage, index| {
sequence += 1;
let report = StageReport {
schema_version: SchemaVersion,
kind: StageKind::Value,
binding: g.binding.clone(),
sequence,
stage,
case_index: index,
};
let report = stage_report(&m, g.binding.clone(), sequence, stage, index);
for _ in 0..2 {
match r.post("/internal/stage", &report, Some(&g)) {
Ok(response) if response.status().as_u16() == 204 => return Ok(()),
@@ -359,22 +406,64 @@ fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Resu
code: FailureCode::LeaseLost,
cleanup_complete: true,
})
});
let mut result = judge_submission(
};
let mut report = match &m {
VersionedManifest::V1(manifest) => {
let mut adapter = Attempt::new(
artifacts,
c.socket.clone(),
c.staging.clone(),
manifest.job_wall_time_us,
&cancel,
)?
.with_control(&mut control);
versions::JudgementReport::V1(
judge_submission(
&mut adapter,
&m,
manifest,
g.binding.clone(),
g.manifest_digest.clone(),
)
.map_err(|_| error())?
.report,
)
}
VersionedManifest::V2(manifest) => {
let authorized = AuthorizedJob::admit(
manifest.clone(),
g.binding.clone(),
g.manifest_digest.clone(),
g.attempt_number,
&c.admission,
)
.map_err(|_| error())?;
transport_cancellation(&mut result.report);
result
.report
let mut adapter = RuntimeAttempt::new(
authorized,
artifacts,
c.socket.clone(),
c.staging.clone(),
&cancel,
)?
.with_control(&mut control);
versions::JudgementReport::V2(
submission_v2::judge_submission(
&mut adapter,
manifest,
g.binding.clone(),
g.manifest_digest.clone(),
)
.map_err(|_| error())?
.report,
)
}
};
transport_cancellation_versioned(&mut report);
report
.validate_for_manifest(&m, &g.manifest_digest)
.map_err(|_| error())?;
// Same immutable report only; a transport ambiguity never becomes success.
for _ in 0..3 {
match r.post("/internal/report", &result.report, Some(&g)) {
match r.post("/internal/report", &report, Some(&g)) {
Ok(response) if response.status().as_u16() == 204 => return Ok(()),
Ok(response) if response.status().as_u16() == 409 => break,
_ => {}
@@ -388,6 +477,47 @@ fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Resu
result
})
}
fn stage_report(
m: &VersionedManifest,
binding: LeaseBinding,
sequence: u64,
stage: Stage,
case_index: Option<u16>,
) -> versions::StageReport {
match m {
VersionedManifest::V1(_) => versions::StageReport::V1(StageReport {
schema_version: SchemaVersion,
kind: StageKind::Value,
binding,
sequence,
stage,
case_index,
}),
VersionedManifest::V2(manifest) => versions::StageReport::V2(v2::StageReport {
schema_version: v2::SchemaVersion,
kind: StageKind::Value,
binding,
sequence,
stage,
case_index,
preparation_kind: manifest.language.contract().preparation_kind,
}),
}
}
fn transport_cancellation_versioned(report: &mut versions::JudgementReport) {
match report {
versions::JudgementReport::V1(r) => transport_cancellation(r),
versions::JudgementReport::V2(r) => {
if r.verdict == Verdict::Cancelled {
r.verdict = Verdict::SystemError;
r.failure_code = Some(FailureCode::LeaseLost);
if r.compilation.status == CompilationStatus::Cancelled {
r.compilation.status = CompilationStatus::SystemError;
}
}
}
}
}
// A task stopped by service shutdown/transport loss is an infrastructure failure.
// User cancellation is authoritative in Coordinator and completes via actual stop proof.
fn transport_cancellation(report: &mut JudgementReport) {
@@ -486,9 +616,9 @@ async fn consume(c: &Config, r: &Remote, stop: &AtomicBool) -> io::Result<()> {
let grant = r.post("/internal/claim", &notice, None)?;
match grant.status().as_u16() {
200 => {
let g: LeaseGrant =
let g: versions::LeaseGrant =
decode_wire(&Remote::bytes(grant, MAX_WIRE_BYTES)?).map_err(|_| error())?;
let result = execute(c, r, g, stop);
let result = execute(c, r, g.into(), stop);
if result.is_err() && !r.reconcile(c)? {
return Err(error());
}
@@ -539,6 +669,86 @@ pub fn run(c: Config, stop: Arc<AtomicBool>) -> io::Result<()> {
mod tests {
use super::*;
#[test]
fn versioned_manifest_is_bound_to_lease_generation_digest_and_identity() {
for (raw, version) in [
(
&include_bytes!("../../../../tests/fixtures/contracts/judge-manifest.valid.json")[..],
1,
),
(
&include_bytes!(
"../../../../tests/fixtures/contracts-v2/judge-manifest.valid-python313.json"
)[..],
2,
),
] {
let m: VersionedManifest = decode_wire(raw).unwrap();
let base: versions::LeaseGrant = decode_wire(include_bytes!(
"../../../../tests/fixtures/contracts-v2/judge-lease.valid.json"
))
.unwrap();
let mut g: Grant = base.into();
g.version = version;
g.binding.evaluation_id = m.evaluation_id();
g.manifest_digest = format!("{:x}", Sha256::digest(m.canonical_bytes().unwrap()))
.try_into()
.unwrap();
assert_eq!(authorized_manifest(raw, &g).unwrap(), m);
g.version = 3 - version;
assert!(authorized_manifest(raw, &g).is_err());
g.version = version;
g.manifest_digest = "f".repeat(64).try_into().unwrap();
assert!(authorized_manifest(raw, &g).is_err());
}
}
#[test]
fn preparation_stage_and_pending_stop_retain_exact_protocol_generation() {
let raw = include_bytes!(
"../../../../tests/fixtures/contracts-v2/judge-manifest.valid-python313.json"
);
let m: VersionedManifest = decode_wire(raw).unwrap();
let g: versions::LeaseGrant = decode_wire(include_bytes!(
"../../../../tests/fixtures/contracts-v2/judge-lease.valid.json"
))
.unwrap();
let mut g: Grant = g.into();
g.binding.evaluation_id = m.evaluation_id();
let report = stage_report(&m, g.binding.clone(), 1, Stage::Compiling, None);
report.validate_for_manifest(&m).unwrap();
let value = serde_json::to_value(report).unwrap();
assert_eq!(value["schema_version"], 2);
assert_eq!(value["preparation_kind"], "syntax_check");
for version in [1, 2, 3] {
let mut pending = serde_json::json!({"binding":g.binding,"case_count":2});
if version != 1 {
pending["schema_version"] = serde_json::json!(version);
}
let p: Pending = serde_json::from_value(pending).unwrap();
if version == 3 {
assert!(p.stopped(true).is_err());
continue;
}
let stopped = serde_json::to_value(p.stopped(true).unwrap()).unwrap();
assert_eq!(stopped["schema_version"], version);
assert_eq!(
stopped,
serde_json::json!({"schema_version":version,"kind":"judge.stopped","binding":g.binding,"cleanup_complete":true})
);
}
let mut legacy: VersionedManifest = decode_wire(include_bytes!(
"../../../../tests/fixtures/contracts/judge-manifest.valid.json"
))
.unwrap();
if let VersionedManifest::V1(m) = &mut legacy {
m.evaluation_id = g.binding.evaluation_id;
}
let report = stage_report(&legacy, g.binding, 1, Stage::Compiling, None);
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["schema_version"], 1);
assert!(value.get("preparation_kind").is_none());
report.validate_for_manifest(&legacy).unwrap();
}
#[test]
fn amqp_numeric_authority_and_default_vhost_are_preserved() {
for (raw, host, vhost) in [
("amqp://fixture:temporary@127.0.0.2:5672/", "127.0.0.2", "/"),
+212
View File
@@ -0,0 +1,212 @@
//! Bounded metadata inspection of sealed javac output, never class loading.
//! Format: https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-4.html
//! Bytecode verification and execution remain inside the isolated JVM.
use std::collections::{BTreeMap, HashSet};
struct Reader<'a>(&'a [u8]);
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], &'static str> {
let value = self.0.get(..n).ok_or("truncated class metadata")?;
self.0 = &self.0[n..];
Ok(value)
}
fn u8(&mut self) -> Result<u8, &'static str> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, &'static str> {
Ok(u16::from_be_bytes(
self.take(2)?.try_into().expect("two bytes"),
))
}
fn u32(&mut self) -> Result<u32, &'static str> {
Ok(u32::from_be_bytes(
self.take(4)?.try_into().expect("four bytes"),
))
}
fn attributes(&mut self, pool: &[Constant<'a>]) -> Result<(), &'static str> {
for _ in 0..self.u16()? {
utf8(pool, self.u16()?)?;
let length = self.u32()? as usize;
self.take(length)?;
}
Ok(())
}
}
enum Constant<'a> {
Other,
Utf8(&'a [u8]),
Class(u16),
}
fn utf8<'a>(pool: &[Constant<'a>], index: u16) -> Result<&'a [u8], &'static str> {
match pool.get(usize::from(index)) {
Some(Constant::Utf8(bytes)) if index != 0 => Ok(bytes),
_ => Err("invalid class UTF8 reference"),
}
}
fn class<'a>(pool: &[Constant<'a>], index: u16) -> Result<&'a [u8], &'static str> {
match pool.get(usize::from(index)) {
Some(Constant::Class(name)) if index != 0 => utf8(pool, *name),
_ => Err("invalid class name reference"),
}
}
struct Info<'a> {
parent: &'a [u8],
main: bool,
}
fn parse<'a>(path: &str, data: &'a [u8]) -> Result<Info<'a>, &'static str> {
if data.len() as u64 > kf_contracts::judging::MAX_PACKAGE_BYTES {
return Err("class metadata byte budget");
}
let mut r = Reader(data);
if r.u32()? != 0xcafebabe || r.u16()? != 0 || r.u16()? != 65 {
return Err("class differs from fixed javac release");
}
let count = usize::from(r.u16()?);
if count < 2 {
return Err("empty class constant pool");
}
let mut pool = vec![Constant::Other];
while pool.len() < count {
let c = match r.u8()? {
1 => {
let n = usize::from(r.u16()?);
Constant::Utf8(r.take(n)?)
}
7 => Constant::Class(r.u16()?),
3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 => {
r.take(4)?;
Constant::Other
}
8 | 16 | 19 | 20 => {
r.take(2)?;
Constant::Other
}
15 => {
r.take(3)?;
Constant::Other
}
5 | 6 => {
r.take(8)?;
if pool.len() + 1 >= count {
return Err("wide constant slot budget");
}
pool.push(Constant::Other);
Constant::Other
}
_ => return Err("unknown class constant"),
};
pool.push(c);
}
r.u16()?;
if class(&pool, r.u16()?)?
!= path
.strip_suffix(".class")
.ok_or("class file path")?
.as_bytes()
{
return Err("class identity differs from sealed file");
}
let parent = class(&pool, r.u16()?)?;
for _ in 0..r.u16()? {
class(&pool, r.u16()?)?;
}
for _ in 0..r.u16()? {
r.u16()?;
utf8(&pool, r.u16()?)?;
utf8(&pool, r.u16()?)?;
r.attributes(&pool)?;
}
let mut main = false;
for _ in 0..r.u16()? {
let flags = r.u16()?;
let name = utf8(&pool, r.u16()?)?;
let descriptor = utf8(&pool, r.u16()?)?;
main |=
flags & 0x0009 == 0x0009 && name == b"main" && descriptor == b"([Ljava/lang/String;)V";
r.attributes(&pool)?;
}
r.attributes(&pool)?;
if !r.0.is_empty() {
return Err("trailing class metadata");
}
Ok(Info { parent, main })
}
pub(crate) fn has_main(files: &[(String, Vec<u8>)]) -> Result<bool, &'static str> {
if files.is_empty()
|| files.len() > kf_contracts::judging_v2::MAX_CLASS_FILES
|| files.iter().map(|(_, d)| d.len() as u64).sum::<u64>()
> kf_contracts::judging::MAX_PACKAGE_BYTES
{
return Err("class collection metadata budget");
}
let mut classes = BTreeMap::new();
for (path, bytes) in files {
if !kf_contracts::judging_v2::class_name(path) {
return Err("unsupported class path");
}
if classes
.insert(
path.strip_suffix(".class").expect("class path").as_bytes(),
parse(path, bytes)?,
)
.is_some()
{
return Err("duplicate class identity");
}
}
let mut name = &b"Main"[..];
let mut seen = HashSet::new();
while let Some(info) = classes.get(name) {
if !seen.insert(name) {
return Err("class superclass cycle");
}
if info.main {
return Ok(true);
}
name = info.parent;
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal(flags: u16, descriptor: &[u8]) -> Vec<u8> {
let mut b = vec![0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 65, 0, 7];
for name in [
&b"Main"[..],
&b"java/lang/Object"[..],
&b"main"[..],
descriptor,
] {
b.push(1);
b.extend_from_slice(&(name.len() as u16).to_be_bytes());
b.extend_from_slice(name);
}
b.extend_from_slice(&[7, 0, 1, 7, 0, 2, 0, 0x21, 0, 5, 0, 6, 0, 0, 0, 0, 0, 1]);
b.extend_from_slice(&flags.to_be_bytes());
b.extend_from_slice(&[0, 3, 0, 4, 0, 0, 0, 0]);
b
}
#[test]
fn entry_signature_and_truncation_are_checked_without_loading_classes() {
let yes = minimal(9, b"([Ljava/lang/String;)V");
assert!(has_main(&[("Main.class".into(), yes.clone())]).unwrap());
for flags in [0, 1, 8] {
assert!(
!has_main(&[(
"Main.class".into(),
minimal(flags, b"([Ljava/lang/String;)V")
)])
.unwrap()
);
}
assert!(!has_main(&[("Main.class".into(), minimal(9, b"()V"))]).unwrap());
for n in 0..yes.len() {
assert!(parse("Main.class", &yes[..n]).is_err());
}
assert!(parse("Other.class", &yes).is_err());
let mut extra = yes;
extra.push(0);
assert!(parse("Main.class", &extra).is_err());
}
}
+9
View File
@@ -116,5 +116,14 @@ pub mod native {
#[cfg(target_os = "linux")]
pub mod pipeline;
#[cfg(target_os = "linux")]
pub mod runtime_specs;
#[cfg(target_os = "linux")]
pub mod pipeline_v2;
#[cfg(target_os = "linux")]
mod java_entry;
#[cfg(target_os = "linux")]
pub mod consumer;
+42
View File
@@ -32,6 +32,18 @@ enum Command {
#[arg(long)]
staging: PathBuf,
},
JudgeSubmissionV2 {
#[arg(long)]
socket: PathBuf,
#[arg(long)]
job: PathBuf,
#[arg(long)]
artifacts: PathBuf,
#[arg(long)]
staging: PathBuf,
#[arg(long)]
admission: PathBuf,
},
JudgeOnce {
#[arg(long)]
socket: PathBuf,
@@ -114,6 +126,36 @@ async fn main() -> ExitCode {
ExitCode::from(2)
}
}
Some(Command::JudgeSubmissionV2 {
socket,
job,
artifacts,
staging,
admission,
}) => {
let result = (|| {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let m = std::fs::symlink_metadata(&admission)?;
if !m.is_file() || m.nlink() != 1 || m.permissions().mode() & 0o7777 != 0o600 {
return Err(std::io::Error::other("operator admission file refused"));
}
let policy = kf_contracts::judging::decode_wire(
&kf_sandbox::execution::read_bounded_file(&admission, 32768)?,
)
.map_err(std::io::Error::other)?;
kf_worker::pipeline_v2::judge_local(&socket, &job, &artifacts, &staging, &policy)
})();
match result {
Ok(result) => {
println!("{}", serde_json::json!(result));
ExitCode::SUCCESS
}
Err(_) => {
eprintln!("submission_failed");
ExitCode::from(2)
}
}
}
Some(Command::JudgeOnce {
socket,
job,
+9 -6
View File
@@ -61,12 +61,12 @@ impl ArtifactPort for LocalArtifacts {
Ok(data)
}
}
fn digest(data: &[u8]) -> Digest {
pub(crate) fn digest(data: &[u8]) -> Digest {
format!("{:x}", Sha256::digest(data))
.try_into()
.expect("SHA256 representation")
}
fn verify(r: &ArtifactReference, data: &[u8]) -> io::Result<()> {
pub(crate) fn verify(r: &ArtifactReference, data: &[u8]) -> io::Result<()> {
if data.len() as u64 != r.bytes || digest(data) != r.digest {
return Err(io::Error::other("artifact digest mismatch"));
}
@@ -78,7 +78,7 @@ fn stage_error(code: FailureCode, cleanup_complete: bool) -> StageFailure {
cleanup_complete,
}
}
fn limits(r: &RunLimits) -> ResourceLimits {
pub(crate) fn limits(r: &RunLimits) -> ResourceLimits {
ResourceLimits {
cpu_time_us: r.cpu_time_us,
wall_time_us: r.wall_time_us,
@@ -92,13 +92,13 @@ fn limits(r: &RunLimits) -> ResourceLimits {
workdir_bytes: r.workdir_bytes,
}
}
fn metric(m: &Measurement<u64>) -> Option<u64> {
pub(crate) fn metric(m: &Measurement<u64>) -> Option<u64> {
match m {
Measurement::Measured { value } => Some(*value),
Measurement::Unavailable { .. } => None,
}
}
fn kind(o: ExecutionOutcome) -> ExecutionKind {
pub(crate) fn kind(o: ExecutionOutcome) -> ExecutionKind {
match o {
ExecutionOutcome::Exited { code } => ExecutionKind::Exited { code },
ExecutionOutcome::Signaled { .. } => ExecutionKind::Signaled,
@@ -210,7 +210,10 @@ impl<'a, A: ArtifactPort> Attempt<'a, A> {
self.control = Some(Box::new(control));
self
}
fn execute(
pub(crate) fn staging_path(&self) -> &Path {
&self.staging
}
pub(crate) fn execute(
&mut self,
spec: ExecutionSpec,
publication: &mut StagePublication,
+414
View File
@@ -0,0 +1,414 @@
//! Ordinary multi-runtime adapter. User source is only checked/compiled/run by
//! isolated Root helpers; Worker handles bounded bytes and sealed capabilities.
use crate::{
pipeline::{ArtifactPort, Attempt, LocalArtifacts, StageEvidence, kind, metric, verify},
runtime_specs::AuthorizedJob,
};
use kf_contracts::{judging as v1, judging_v2 as v2, judging_versions::NodeAdmission};
use kf_engine::{Observation, submission as old, submission_v2 as engine};
use kf_sandbox::{
artifacts::{SnapshotFile, StagePublication},
outcome::ExecutionOutcome,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
use std::{
io::{self, Read},
path::{Path, PathBuf},
sync::atomic::AtomicBool,
};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Job {
pub manifest: v2::JobManifest,
pub binding: v1::LeaseBinding,
pub manifest_digest: v1::Digest,
pub attempt_number: u32,
}
#[derive(Serialize)]
pub struct Judged {
pub result: engine::SubmissionResult,
pub stages: Vec<StageEvidence>,
}
struct SealedProgram {
metadata: v2::CompiledProgram,
files: Vec<SnapshotFile>,
}
pub struct RuntimeAttempt<'a, A: ArtifactPort> {
authorized: AuthorizedJob,
io: Attempt<'a, A>,
program: Option<SealedProgram>,
}
fn failure(code: v1::FailureCode, cleanup_complete: bool) -> old::StageFailure {
old::StageFailure {
code,
cleanup_complete,
}
}
fn artifact_error(_: io::Error) -> old::StageFailure {
failure(v1::FailureCode::ArtifactUnavailable, true)
}
fn snapshot_reference(file: &SnapshotFile) -> Result<v1::ArtifactReference, old::StageFailure> {
let mut h = Sha256::new();
let mut reader = file.reader().map_err(artifact_error)?;
let mut buffer = [0u8; 16384];
let mut total = 0u64;
loop {
let n = reader.read(&mut buffer).map_err(artifact_error)?;
if n == 0 {
break;
}
total += n as u64;
if total > file.bytes() {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
h.update(&buffer[..n]);
}
if total != file.bytes() {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
Ok(v1::ArtifactReference {
digest: format!("{:x}", h.finalize()).try_into().expect("SHA256"),
bytes: total,
})
}
fn diagnostics(result: &kf_sandbox::rpc::Executed) -> Result<(String, bool), old::StageFailure> {
let mut raw = Vec::new();
result
.stderr
.reader()
.map_err(artifact_error)?
.take(65537)
.read_to_end(&mut raw)
.map_err(artifact_error)?;
let mut text = String::from_utf8_lossy(&raw).into_owned();
let mut truncated = result.report.stderr.truncated || text.len() > 65536;
if text.len() > 65536 {
let mut end = 65536;
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
truncated = true;
}
Ok((text, truncated))
}
impl<'a, A: ArtifactPort> RuntimeAttempt<'a, A> {
pub fn new(
authorized: AuthorizedJob,
artifacts: A,
socket: PathBuf,
staging: PathBuf,
cancellation: &'a AtomicBool,
) -> io::Result<Self> {
let io = Attempt::new(
artifacts,
socket,
staging,
authorized.manifest().job_wall_time_us,
cancellation,
)?;
Ok(Self {
authorized,
io,
program: None,
})
}
pub fn with_control(
mut self,
control: impl FnMut(v1::Stage, Option<u16>) -> Result<(), old::StageFailure> + 'a,
) -> Self {
self.io = self.io.with_control(control);
self
}
pub fn into_stages(self) -> Vec<StageEvidence> {
self.io.stages
}
}
impl<A: ArtifactPort> engine::PreparationPort for RuntimeAttempt<'_, A> {
fn prepare(
&mut self,
m: &v2::JobManifest,
binding: &v1::LeaseBinding,
run_id: &str,
) -> Result<engine::Compiled, old::StageFailure> {
if m != self.authorized.manifest()
|| binding != self.authorized.binding()
|| self.program.is_some()
{
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
let source = self.io.artifacts.read(&m.source).map_err(artifact_error)?;
verify(&m.source, &source).map_err(artifact_error)?;
let mut publication =
StagePublication::create(self.io.staging_path(), run_id).map_err(artifact_error)?;
publication
.put("source", &mut source.as_slice(), m.source.bytes)
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, false))?;
let checked_source = if m.language == v2::Language::Python313 {
let file = publication
.root()
.snapshot_artifact("source", m.source.bytes)
.map_err(artifact_error)?;
if snapshot_reference(&file)? != m.source {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
Some(file)
} else {
None
};
drop(source);
let spec = self
.authorized
.preparation_spec(run_id.into())
.map_err(|_| failure(v1::FailureCode::SandboxUnavailable, true))?;
let validated = spec
.clone()
.validate(&kf_sandbox::spec::SpecPolicy::default())
.map_err(|_| failure(v1::FailureCode::SandboxUnavailable, true))?;
let result = self.io.execute(spec, &mut publication)?;
let mut status = match result
.report
.classify(&validated)
.map_err(|_| failure(v1::FailureCode::SandboxUnavailable, false))?
{
ExecutionOutcome::Exited { code: 0 } => v1::CompilationStatus::Succeeded,
ExecutionOutcome::Exited { .. } | ExecutionOutcome::Signaled { .. } => {
v1::CompilationStatus::CompilationError
}
ExecutionOutcome::LimitExceeded { .. } => v1::CompilationStatus::CompilationLimit,
ExecutionOutcome::Cancelled => v1::CompilationStatus::Cancelled,
_ => v1::CompilationStatus::SystemError,
};
let (mut text, truncated) = diagnostics(&result)?;
let cpu_time_us = metric(&result.report.usage.cpu_time_us);
let memory_peak_bytes = metric(&result.report.usage.memory_peak_bytes);
let mut files = Vec::new();
let mut sealed = Vec::new();
if status == v1::CompilationStatus::Succeeded {
if let Some(source) = checked_source {
if !result.outputs.is_empty() {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
files.push(v2::ProgramFile {
path: "main.py".into(),
artifact: m.source.clone(),
});
sealed.push(source);
} else {
for output in result.outputs {
files.push(v2::ProgramFile {
path: output.path,
artifact: snapshot_reference(&output.file)?,
});
sealed.push(output.file);
}
}
if m.language == v2::Language::Java21 {
// No reflective loading or initialization outside the JVM. A
// successfully compiled unsupported entry is a user CE.
let mut metadata = Vec::with_capacity(files.len());
for (info, file) in files.iter().zip(&sealed) {
let mut bytes = Vec::new();
file.reader()
.map_err(artifact_error)?
.take(info.artifact.bytes + 1)
.read_to_end(&mut bytes)
.map_err(artifact_error)?;
verify(&info.artifact, &bytes).map_err(artifact_error)?;
metadata.push((info.path.clone(), bytes));
}
let has_main = if metadata.is_empty() {
false
} else {
crate::java_entry::has_main(&metadata)
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, true))?
};
if !has_main {
status = v1::CompilationStatus::CompilationError;
text="Java submission requires default-package Main with public static void main(String[]).".into();
}
}
}
let program = if status == v1::CompilationStatus::Succeeded {
let metadata = v2::CompiledProgram {
kind: m.language.contract().program_kind,
binding: binding.clone(),
source: m.source.clone(),
toolchain: m.toolchain.clone(),
files,
};
self.authorized
.validate_program(&metadata)
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, true))?;
self.program = Some(SealedProgram {
metadata: metadata.clone(),
files: sealed,
});
Some(metadata)
} else {
None
};
Ok(engine::Compiled {
report: v2::CompilationReport {
status,
program,
diagnostics: text.try_into().expect("bounded diagnostics"),
diagnostics_truncated: truncated,
cpu_time_us,
memory_peak_bytes,
},
cleanup_complete: true,
failure: (status == v1::CompilationStatus::SystemError)
.then_some(v1::FailureCode::SandboxUnavailable),
})
}
}
impl<A: ArtifactPort> engine::RunPort for RuntimeAttempt<'_, A> {
fn run(
&mut self,
program: &v2::CompiledProgram,
case: &v1::TestCase,
budget: &v1::RunLimits,
run_id: &str,
) -> Result<Observation, old::StageFailure> {
let index = self
.authorized
.manifest()
.cases
.iter()
.position(|c| c == case)
.ok_or_else(|| failure(v1::FailureCode::ArtifactUnavailable, true))?;
if budget != &self.authorized.manifest().run_limits {
return Err(failure(v1::FailureCode::SandboxUnavailable, true));
}
let spec = self
.authorized
.run_spec(program, index, run_id.into())
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, true))?;
let stored = self
.program
.as_ref()
.filter(|s| &s.metadata == program)
.ok_or_else(|| failure(v1::FailureCode::ArtifactUnavailable, true))?;
if stored.files.len() != program.files.len() {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
let input = self
.io
.artifacts
.read(&case.input)
.map_err(artifact_error)?;
verify(&case.input, &input).map_err(artifact_error)?;
let mut publication =
StagePublication::create(self.io.staging_path(), run_id).map_err(artifact_error)?;
for ((info, file), artifact) in program.files.iter().zip(&stored.files).zip(&spec.inputs) {
if snapshot_reference(file)? != info.artifact {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
publication
.put(
&artifact.artifact_id,
&mut file.reader().map_err(artifact_error)?,
info.artifact.bytes,
)
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, false))?;
}
publication
.put("stdin", &mut input.as_slice(), case.input.bytes)
.map_err(|_| failure(v1::FailureCode::ArtifactUnavailable, false))?;
drop(input);
let validated = spec
.clone()
.validate(&kf_sandbox::spec::SpecPolicy::default())
.map_err(|_| failure(v1::FailureCode::SandboxUnavailable, true))?;
let result = self.io.execute(spec, &mut publication)?;
let outcome = result
.report
.classify(&validated)
.map_err(|_| failure(v1::FailureCode::SandboxUnavailable, false))?;
let mut stdout = Vec::new();
result
.stdout
.reader()
.map_err(artifact_error)?
.take(budget.stdout_bytes + 1)
.read_to_end(&mut stdout)
.map_err(artifact_error)?;
if stdout.len() as u64 > budget.stdout_bytes {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
Ok(Observation {
kind: kind(outcome),
stdout,
cpu_time_us: metric(&result.report.usage.cpu_time_us),
memory_peak_bytes: metric(&result.report.usage.memory_peak_bytes),
})
}
}
impl<A: ArtifactPort> old::ExpectedPort for RuntimeAttempt<'_, A> {
fn expected(&mut self, case: &v1::TestCase) -> Result<Vec<u8>, old::StageFailure> {
if !self.authorized.manifest().cases.contains(case) {
return Err(failure(v1::FailureCode::ArtifactUnavailable, true));
}
let bytes = self
.io
.artifacts
.read(&case.expected)
.map_err(artifact_error)?;
verify(&case.expected, &bytes).map_err(artifact_error)?;
Ok(bytes)
}
}
impl<A: ArtifactPort> old::ControlPort for RuntimeAttempt<'_, A> {
fn elapsed_us(&self) -> u64 {
old::ControlPort::elapsed_us(&self.io)
}
fn cancelled(&self) -> bool {
old::ControlPort::cancelled(&self.io)
}
fn stage(&mut self, stage: v1::Stage, index: Option<u16>) -> Result<(), old::StageFailure> {
old::ControlPort::stage(&mut self.io, stage, index)
}
}
pub fn judge_local(
socket: &Path,
job: &Path,
artifacts: &Path,
staging: &Path,
admission: &NodeAdmission,
) -> io::Result<Judged> {
let job: Job = v1::decode_wire(&kf_sandbox::execution::read_bounded_file(
job,
v1::MAX_WIRE_BYTES as u64,
)?)
.map_err(io::Error::other)?;
let authorized = AuthorizedJob::admit(
job.manifest.clone(),
job.binding.clone(),
job.manifest_digest.clone(),
job.attempt_number,
admission,
)
.map_err(io::Error::other)?;
let cancellation = AtomicBool::new(false);
let mut attempt = RuntimeAttempt::new(
authorized,
LocalArtifacts::open(artifacts)?,
socket.into(),
staging.into(),
&cancellation,
)?;
let result = engine::judge_submission(
&mut attempt,
&job.manifest,
job.binding,
job.manifest_digest,
)
.map_err(io::Error::other)?;
Ok(Judged {
result,
stages: attempt.into_stages(),
})
}
+310
View File
@@ -0,0 +1,310 @@
//! Ordinary v2 admission and closed specifications. This module neither reads
//! user files nor executes source; every stage still goes through Root gates.
use crate::pipeline::{compiler_spec, limits};
use kf_contracts::{judging as v1, judging_v2 as v2, judging_versions as versions};
use kf_sandbox::spec::{ExecutionProfile as P, ExecutionSpec, InputArtifact, SpecPolicy};
use sha2::{Digest as _, Sha256};
/// A canonical manifest and lease checked against the operator's capability
/// registry. Private fields prevent widening budgets after admission.
pub struct AuthorizedJob {
manifest: v2::JobManifest,
binding: v1::LeaseBinding,
manifest_digest: v1::Digest,
}
impl AuthorizedJob {
pub fn admit(
manifest: v2::JobManifest,
binding: v1::LeaseBinding,
manifest_digest: v1::Digest,
attempt_number: u32,
policy: &versions::NodeAdmission,
) -> Result<Self, &'static str> {
binding.validate()?;
if binding.evaluation_id != manifest.evaluation_id {
return Err("lease differs from authorized manifest");
}
policy.admit(&versions::JobManifest::V2(manifest.clone()), attempt_number)?;
let canonical = v2::manifest_bytes(&manifest)?;
if format!("{:x}", Sha256::digest(canonical)) != manifest_digest.as_str() {
return Err("canonical manifest digest differs from lease");
}
Ok(Self {
manifest,
binding,
manifest_digest,
})
}
pub fn manifest(&self) -> &v2::JobManifest {
&self.manifest
}
pub fn binding(&self) -> &v1::LeaseBinding {
&self.binding
}
pub fn manifest_digest(&self) -> &v1::Digest {
&self.manifest_digest
}
pub fn preparation_spec(&self, run_id: String) -> Result<ExecutionSpec, &'static str> {
let m = &self.manifest;
let spec = match m.language {
v2::Language::C17 | v2::Language::Cpp20 => {
let mut spec = compiler_spec(
if m.language == v2::Language::C17 {
v1::Language::C17
} else {
v1::Language::Cpp20
},
"source".into(),
run_id,
&m.compile_limits,
);
spec.runtime_identity = Some(m.toolchain.clone());
spec
}
v2::Language::Python313 | v2::Language::Java21 => {
let profile = if m.language == v2::Language::Python313 {
P::PythonCheck
} else {
P::JavaCompile
};
let budget = limits(&m.compile_limits);
ExecutionSpec {
schema_version: kf_contracts::SchemaVersion,
runtime_identity: Some(m.toolchain.clone()),
run_id,
profile,
argv: kf_sandbox::profiles::argv(profile, &budget)?,
env: Default::default(),
cwd: ".".into(),
inputs: vec![InputArtifact {
artifact_id: "source".into(),
path: m.language.contract().source_name.into(),
}],
stdin_artifact: None,
outputs: vec![],
limits: budget,
}
}
};
checked(spec)
}
/// Metadata is only accepted for this source/runtime/attempt. The adapter
/// must separately verify every sealed file's byte count and digest.
pub fn validate_program(&self, program: &v2::CompiledProgram) -> v1::Validation {
program.validate()?;
let m = &self.manifest;
if program.binding != self.binding
|| program.source != m.source
|| program.toolchain != m.toolchain
|| program.kind != m.language.contract().program_kind
{
return Err("program differs from authorized source, runtime or lease");
}
if program.kind != v2::ProgramKind::PythonSource
&& (program
.files
.iter()
.any(|f| f.artifact.bytes > m.compile_limits.file_bytes)
|| program.bytes() > m.compile_limits.workdir_bytes)
{
return Err("program exceeds preparation output budget");
}
Ok(())
}
pub fn run_spec(
&self,
program: &v2::CompiledProgram,
case_index: usize,
run_id: String,
) -> Result<ExecutionSpec, &'static str> {
self.validate_program(program)?;
if case_index >= self.manifest.cases.len() {
return Err("test point is outside authorized manifest");
}
let profile = match self.manifest.language {
v2::Language::C17 | v2::Language::Cpp20 => P::Native,
v2::Language::Python313 => P::PythonRun,
v2::Language::Java21 => P::JavaRun,
};
let budget = limits(&self.manifest.run_limits);
checked(ExecutionSpec {
schema_version: kf_contracts::SchemaVersion,
runtime_identity: Some(self.manifest.toolchain.clone()),
run_id,
profile,
argv: if profile == P::Native {
vec!["/work/main".into()]
} else {
kf_sandbox::profiles::argv(profile, &budget)?
},
env: Default::default(),
cwd: ".".into(),
inputs: program
.files
.iter()
.enumerate()
.map(|(i, f)| InputArtifact {
artifact_id: program_artifact_id(i),
path: f.path.clone(),
})
.collect(),
stdin_artifact: Some("stdin".into()),
outputs: vec![],
limits: budget,
})
}
}
pub(crate) fn program_artifact_id(index: usize) -> String {
format!("program-{index:03}")
}
fn checked(spec: ExecutionSpec) -> Result<ExecutionSpec, &'static str> {
spec.clone()
.validate(&SpecPolicy::default())
.map_err(|_| "invalid fixed runtime specification")?;
Ok(spec)
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture<T: serde::de::DeserializeOwned>(name: &str) -> T {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../tests/fixtures/contracts-v2")
.join(format!("{name}.json"));
v1::decode_wire(&std::fs::read(path).unwrap()).unwrap()
}
fn authorized(language: &str) -> (AuthorizedJob, v2::CompiledProgram) {
let m: v2::JobManifest = fixture(&format!("judge-manifest.valid-{language}"));
let report: v2::JudgementReport = fixture(&format!("judge-result.valid-{language}"));
let policy: versions::NodeAdmission = fixture("judge-registry.valid");
let digest = format!("{:x}", Sha256::digest(v2::manifest_bytes(&m).unwrap()))
.try_into()
.unwrap();
let job = AuthorizedJob::admit(m, report.binding, digest, 1, &policy).unwrap();
(job, report.compilation.program.unwrap())
}
#[test]
fn all_four_languages_use_exact_pins_fixed_commands_and_private_points() {
for (language, prepare, run) in [
("c17", P::CompileC, P::Native),
("cpp20", P::CompileCpp, P::Native),
("python313", P::PythonCheck, P::PythonRun),
("java21", P::JavaCompile, P::JavaRun),
] {
let (j, program) = authorized(language);
let p = j.preparation_spec("prepare".into()).unwrap();
assert_eq!(p.profile, prepare);
assert_eq!(p.runtime_identity.as_ref(), Some(&j.manifest.toolchain));
assert!(p.env.is_empty());
assert!(p.stdin_artifact.is_none());
assert_eq!(p.inputs[0].path, j.manifest.language.contract().source_name);
for index in 0..j.manifest.cases.len() {
let r = j
.run_spec(&program, index, format!("point-{index}"))
.unwrap();
assert_eq!(r.profile, run);
assert_eq!(r.runtime_identity, p.runtime_identity);
assert_eq!(r.limits, limits(&j.manifest.run_limits));
assert!(r.env.is_empty() && r.outputs.is_empty());
assert_eq!(r.stdin_artifact.as_deref(), Some("stdin"));
assert_eq!(r.inputs.len(), program.files.len());
assert!(
r.inputs
.iter()
.all(|f| f.artifact_id.starts_with("program-"))
);
// Expected answers, source paths and manifest host paths are not inputs.
assert_eq!(
r.inputs.iter().map(|f| &f.path).collect::<Vec<_>>(),
program.files.iter().map(|f| &f.path).collect::<Vec<_>>()
);
}
assert!(
j.run_spec(&program, j.manifest.cases.len(), "point".into())
.is_err()
);
}
}
#[test]
fn lease_manifest_digest_runtime_and_operator_capabilities_are_fenced() {
let (j, _) = authorized("python313");
let policy: versions::NodeAdmission = fixture("judge-registry.valid");
let admit = |m, b, d, n| AuthorizedJob::admit(m, b, d, n, &policy);
let mut wrong = j.manifest.clone();
wrong.toolchain.image_digest = "f".repeat(64).try_into().unwrap();
assert!(admit(wrong, j.binding.clone(), j.manifest_digest.clone(), 1).is_err());
let mut b = j.binding.clone();
b.evaluation_id = "aaaaaaaa-aaaa-4aaa-8aaa-000000001000"
.to_string()
.try_into()
.unwrap();
assert!(admit(j.manifest.clone(), b, j.manifest_digest.clone(), 1).is_err());
assert!(
admit(
j.manifest.clone(),
j.binding.clone(),
"f".repeat(64).try_into().unwrap(),
1
)
.is_err()
);
assert!(
admit(
j.manifest.clone(),
j.binding.clone(),
j.manifest_digest.clone(),
4
)
.is_err()
);
let mut wrong = j.manifest.clone();
wrong.run_limits.memory_bytes += 1;
assert!(admit(wrong, j.binding.clone(), j.manifest_digest.clone(), 1).is_err());
let mut legacy: v2::AdmissionRegistry = fixture("judge-registry.valid");
legacy.multi_runtime = None;
assert!(
AuthorizedJob::admit(
j.manifest.clone(),
j.binding.clone(),
j.manifest_digest.clone(),
1,
&versions::NodeAdmission::Registry(legacy)
)
.is_err()
);
}
#[test]
fn sealed_program_metadata_cannot_change_source_attempt_type_or_budget() {
for language in ["c17", "python313", "java21"] {
let (j, p) = authorized(language);
let mut bad = p.clone();
bad.binding.attempt_id = "aaaaaaaa-aaaa-4aaa-8aaa-000000001000"
.to_string()
.try_into()
.unwrap();
assert!(j.validate_program(&bad).is_err());
bad = p.clone();
bad.source.digest = "f".repeat(64).try_into().unwrap();
assert!(j.validate_program(&bad).is_err());
bad = p.clone();
bad.toolchain.image_digest = "f".repeat(64).try_into().unwrap();
assert!(j.validate_program(&bad).is_err());
bad = p.clone();
bad.files[0].path = "../Main.class".into();
assert!(j.validate_program(&bad).is_err());
bad = p.clone();
bad.files[0].artifact.bytes = 0;
assert!(j.validate_program(&bad).is_err());
if language != "python313" {
bad = p;
bad.files[0].artifact.bytes = j.manifest.compile_limits.file_bytes + 1;
assert!(j.validate_program(&bad).is_err());
}
}
let (j, mut p) = authorized("java21");
p.files[0].path = "A$1.class".into();
j.validate_program(&p).unwrap();
p.files.push(p.files[0].clone());
assert!(j.validate_program(&p).is_err());
}
}
+181
View File
@@ -0,0 +1,181 @@
"""Offline GNU catalog from an explicitly pinned, verified public package closure.
No target executable, package hook, submitted source or network installer runs.
Root publication and real execution acceptance remain separate steps.
"""
import argparse
import hashlib
import json
import re
from pathlib import Path
def sha(path):
with path.open("rb") as source:
return hashlib.file_digest(source, "sha256").hexdigest()
def canonical(name):
return len(name) <= 4096 and all(
re.fullmatch(r"[A-Za-z0-9_.+\-]{1,255}", p) and p not in (".", "..")
for p in name.split("/")
)
def resolve_verified(root, records, name):
parts = name.split("/")
for _ in range(9):
changed = False
for i in range(len(parts)):
prefix = "/".join(parts[: i + 1])
record = records.get(prefix)
path = root / prefix
if record and record["kind"] == "symlink":
if not path.is_symlink() or str(path.readlink()) != record["target"]:
raise ValueError("GNU package link differs from verified manifest")
target = record["target"]
expanded = (
([] if target.startswith("/") else parts[:i])
+ target.split("/")
+ parts[i + 1 :]
)
parts = []
for p in expanded:
if p in ("", "."):
continue
if p == "..":
if not parts:
raise ValueError("GNU image-relative link escapes closure")
parts.pop()
else:
parts.append(p)
changed = True
break
if path.is_symlink():
raise ValueError("unverified GNU ancestor link")
if not changed:
relative = "/".join(parts)
record = records.get(relative)
if record is None or record["kind"] != "file":
raise ValueError("GNU link outside verified regular-file closure")
source = root / relative
source.resolve(strict=True).relative_to(root)
return source, record
raise ValueError("GNU link cycle or depth budget")
def build(args):
if sha(args.support_manifest) != args.support_manifest_sha256:
raise ValueError("verified GNU support manifest fingerprint differs")
manifest = json.loads(args.support_manifest.read_bytes())
packages = {p["name"]: p for p in manifest["packages"]}
expected = {
"gcc": (
"16.2.1+r23+gd564253eb6c8-1",
"ebac12ceef494c616a36fc6f0f792b365dc59b6ed46ca1a69f138aecc810b6ab",
),
"glibc": (
"2.44+r50+g1848099f063e-3",
"071d807f4d3bf6b921f86b9a91e83a47ecc026c5821634efa46b09d890e06785",
),
"binutils": ("2.47-2", "62efd213e87590f025cf861b646793d3cacb03e7e26187b73d33b00adaf7a977"),
}
for name, (version, digest) in expected.items():
if (packages[name]["version"], packages[name]["sha256"]) != (version, digest):
raise ValueError("GNU image requires the pinned compiler/libc/linker closure")
records = {f["path"]: f for f in manifest["files"]}
source_root = args.support_dir.resolve(strict=True)
if args.output.exists():
raise ValueError("refuse to replace an image")
args.output.mkdir(mode=0o700)
directories = {"dev", "proc", "tmp", "work", "usr", "lib64"}
files = []
def put(name, source, expected_file):
if not canonical(name) or expected_file["kind"] != "file":
raise ValueError("noncanonical verified GNU file")
if (
source.stat().st_size != expected_file["bytes"]
or sha(source) != expected_file["sha256"]
):
raise ValueError("GNU file differs from verified public package bytes")
if len(files) >= 4096 or sum(f["bytes"] for f in files) + source.stat().st_size > 1024**3:
raise ValueError("GNU image exceeds finite catalog budget")
target = args.output / name
target.parent.mkdir(parents=True, exist_ok=True)
# Copy to a fresh regular inode; never publish source links/hard links.
with source.open("rb") as reader, target.open("xb") as writer:
while data := reader.read(1024 * 1024):
writer.write(data)
executable = bool(source.stat().st_mode & 0o111)
target.chmod(0o555 if executable else 0o444)
if sha(target) != expected_file["sha256"]:
raise ValueError("GNU copy fingerprint differs")
files.append(
{
"path": name,
"bytes": target.stat().st_size,
"sha256": sha(target),
"executable": executable,
}
)
directories.update(p.as_posix() for p in Path(name).parents if p != Path("."))
for name, record in sorted(records.items()):
if not (
name.startswith(("usr/include/", "usr/lib/"))
or name in ("usr/bin/gcc", "usr/bin/g++", "usr/bin/as", "usr/bin/ld")
):
continue
if record["kind"] == "directory":
continue
source, real = resolve_verified(source_root, records, name)
put(name, source, real)
loader, loader_record = resolve_verified(source_root, records, "usr/lib/ld-linux-x86-64.so.2")
put("lib64/ld-linux-x86-64.so.2", loader, loader_record)
for name in directories:
(args.output / name).mkdir(parents=True, exist_ok=True)
catalog = dict(
format_version=2,
architecture="x86_64_v3",
image_kind="linux_gnu_cxx_x86_64_v1",
image_revision=1,
toolchain_id="gnu-cxx-v1",
runtime_version="16.2.1+r23+gd564253eb6c8",
directories=sorted(directories),
files=sorted(files, key=lambda f: f["path"]),
)
raw = (json.dumps(catalog, sort_keys=True, separators=(",", ":")) + "\n").encode()
if len(raw) > 1024 * 1024:
raise ValueError("GNU catalog transport budget")
(args.output / "manifest.json").write_bytes(raw)
(args.output / "manifest.json").chmod(0o444)
for path in sorted((p for p in args.output.rglob("*") if p.is_dir()), reverse=True):
path.chmod(0o555)
args.output.chmod(0o555)
print(
json.dumps(
dict(
image_digest=hashlib.sha256(raw).hexdigest(),
files=len(files),
bytes=sum(f["bytes"] for f in files),
support_manifest_sha256=args.support_manifest_sha256,
source_execution=False,
root_publication_and_VM_acceptance=False,
)
)
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--support-dir", required=True, type=Path)
parser.add_argument("--support-manifest", required=True, type=Path)
parser.add_argument("--support-manifest-sha256", required=True)
parser.add_argument("--output", required=True, type=Path)
build(parser.parse_args())
if __name__ == "__main__":
main()