feat(contracts): validate fenced judging jobs and bounded results
This commit is contained in:
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
//! Validated judge wire values. No execution, authorization service or storage.
|
||||
use crate::SchemaVersion;
|
||||
use serde::{Deserialize, Serialize, de::Error};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const MIB: u64 = 1024 * 1024;
|
||||
pub const I64_MAX: u64 = i64::MAX as u64;
|
||||
pub const MAX_SOURCE_BYTES: u64 = 256 * 1024;
|
||||
pub const MAX_PACKAGE_BYTES: u64 = 64 * MIB;
|
||||
pub const MAX_CASES: usize = 256;
|
||||
pub const MAX_JOB_WALL_US: u64 = 3_600_000_000;
|
||||
pub const MAX_WIRE_BYTES: usize = 256 * 1024;
|
||||
pub type Validation = Result<(), &'static str>;
|
||||
|
||||
/// Bound before parsing; hide serde diagnostics because they can contain input values.
|
||||
pub fn decode_wire<T: serde::de::DeserializeOwned>(payload: &[u8]) -> Result<T, &'static str> {
|
||||
if payload.len() > MAX_WIRE_BYTES {
|
||||
return Err("wire payload exceeds byte budget");
|
||||
}
|
||||
serde_json::from_slice(payload).map_err(|_| "invalid wire payload")
|
||||
}
|
||||
|
||||
fn bounded(value: u64, min: u64, max: u64) -> Validation {
|
||||
if (min..=max).contains(&value) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("value outside policy")
|
||||
}
|
||||
}
|
||||
fn measure(value: Option<u64>) -> Validation {
|
||||
value.map_or(Ok(()), |v| bounded(v, 0, I64_MAX))
|
||||
}
|
||||
fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
T: Deserialize<'de>,
|
||||
{
|
||||
Option::<T>::deserialize(deserializer)
|
||||
}
|
||||
|
||||
// All decoded nested values validate before becoming public wire models.
|
||||
macro_rules! validated_struct {
|
||||
($name:ident { $($(#[$attr:meta])* $field:ident: $ty:ty),* $(,)? }) => {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct $name { $($(#[$attr])* pub $field: $ty),* }
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Wire { $($(#[$attr])* $field: $ty),* }
|
||||
let raw = Wire::deserialize(d)?;
|
||||
let value = Self { $($field: raw.$field),* };
|
||||
value.validate().map_err(D::Error::custom)?;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
macro_rules! kind {
|
||||
($name:ident, $wire:literal) => {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum $name {
|
||||
#[serde(rename = $wire)]
|
||||
Value,
|
||||
}
|
||||
};
|
||||
}
|
||||
kind!(ManifestKind, "judge.manifest");
|
||||
kind!(LeaseKind, "judge.lease");
|
||||
kind!(StageKind, "judge.stage");
|
||||
kind!(ResultKind, "judge.result");
|
||||
kind!(ImageKind, "linux_gnu_cxx_x86_64_v1");
|
||||
kind!(Comparison, "exact_bytes");
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct Identifier(Uuid);
|
||||
impl TryFrom<String> for Identifier {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
let id = Uuid::parse_str(&value).map_err(|_| "invalid identifier")?;
|
||||
if id.is_nil() || id.to_string() != value {
|
||||
return Err("identifier must be non-nil and canonical");
|
||||
}
|
||||
Ok(Self(id))
|
||||
}
|
||||
}
|
||||
impl Identifier {
|
||||
pub fn as_uuid(self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct Digest(String);
|
||||
impl TryFrom<String> for Digest {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
if value.len() != 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return Err("invalid digest");
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
impl Digest {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LeaseToken(Digest);
|
||||
impl std::fmt::Debug for LeaseToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("LeaseToken([redacted])")
|
||||
}
|
||||
}
|
||||
impl LeaseToken {
|
||||
pub fn expose_secret(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct CompilerDiagnostics(String);
|
||||
impl TryFrom<String> for CompilerDiagnostics {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
if value.len() > 65536 {
|
||||
return Err("compiler diagnostics exceed byte budget");
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for CompilerDiagnostics {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("CompilerDiagnostics([redacted])")
|
||||
}
|
||||
}
|
||||
impl CompilerDiagnostics {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
pub struct Label(String);
|
||||
impl TryFrom<String> for Label {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
let first = value
|
||||
.bytes()
|
||||
.next()
|
||||
.is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
|
||||
if !first
|
||||
|| value.len() > 64
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Err("invalid label");
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
impl Label {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Language {
|
||||
C17,
|
||||
Cpp20,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
Accepted,
|
||||
WrongAnswer,
|
||||
CompilationError,
|
||||
RuntimeError,
|
||||
TimeLimitExceeded,
|
||||
MemoryLimitExceeded,
|
||||
OutputLimitExceeded,
|
||||
Cancelled,
|
||||
SystemError,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompilationStatus {
|
||||
Succeeded,
|
||||
CompilationError,
|
||||
CompilationLimit,
|
||||
Cancelled,
|
||||
SystemError,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FailureCode {
|
||||
ArtifactUnavailable,
|
||||
SandboxUnavailable,
|
||||
CleanupFailed,
|
||||
LeaseLost,
|
||||
JobDeadline,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Stage {
|
||||
Compiling,
|
||||
Running,
|
||||
}
|
||||
|
||||
validated_struct!(ArtifactReference {
|
||||
digest: Digest,
|
||||
bytes: u64
|
||||
});
|
||||
impl ArtifactReference {
|
||||
pub fn validate(&self) -> Validation {
|
||||
bounded(self.bytes, 0, MAX_PACKAGE_BYTES)
|
||||
}
|
||||
}
|
||||
validated_struct!(Toolchain {
|
||||
toolchain_id: Label,
|
||||
image_kind: ImageKind,
|
||||
image_revision: u32
|
||||
});
|
||||
impl Toolchain {
|
||||
pub fn validate(&self) -> Validation {
|
||||
bounded(u64::from(self.image_revision), 1, u64::from(u32::MAX))
|
||||
}
|
||||
}
|
||||
validated_struct!(RunLimits {
|
||||
cpu_time_us: u64,
|
||||
wall_time_us: u64,
|
||||
memory_bytes: u64,
|
||||
stack_bytes: u64,
|
||||
processes: u32,
|
||||
open_files: u32,
|
||||
stdout_bytes: u64,
|
||||
stderr_bytes: u64,
|
||||
file_bytes: u64,
|
||||
workdir_bytes: u64,
|
||||
});
|
||||
impl RunLimits {
|
||||
pub fn validate(&self) -> Validation {
|
||||
for (value, min, max) in [
|
||||
(self.cpu_time_us, 1000, 60_000_000),
|
||||
(self.wall_time_us, 1000, 120_000_000),
|
||||
(self.memory_bytes, 1, 2048 * MIB),
|
||||
(self.stack_bytes, 1, 256 * MIB),
|
||||
(u64::from(self.processes), 1, 128),
|
||||
(u64::from(self.open_files), 3, 1024),
|
||||
(self.stdout_bytes, 1, 16 * MIB),
|
||||
(self.stderr_bytes, 1, 16 * MIB),
|
||||
(self.file_bytes, 1, 64 * MIB),
|
||||
(self.workdir_bytes, 1, 256 * MIB),
|
||||
] {
|
||||
bounded(value, min, max)?;
|
||||
}
|
||||
if self.stack_bytes > self.memory_bytes || self.file_bytes > self.workdir_bytes {
|
||||
return Err("inconsistent resource limits");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn fits(&self, ceiling: &Self) -> bool {
|
||||
self.cpu_time_us <= ceiling.cpu_time_us
|
||||
&& self.wall_time_us <= ceiling.wall_time_us
|
||||
&& self.memory_bytes <= ceiling.memory_bytes
|
||||
&& self.stack_bytes <= ceiling.stack_bytes
|
||||
&& self.processes <= ceiling.processes
|
||||
&& self.open_files <= ceiling.open_files
|
||||
&& self.stdout_bytes <= ceiling.stdout_bytes
|
||||
&& self.stderr_bytes <= ceiling.stderr_bytes
|
||||
&& self.file_bytes <= ceiling.file_bytes
|
||||
&& self.workdir_bytes <= ceiling.workdir_bytes
|
||||
}
|
||||
}
|
||||
validated_struct!(TestCase {
|
||||
case_id: Identifier,
|
||||
input: ArtifactReference,
|
||||
expected: ArtifactReference
|
||||
});
|
||||
impl TestCase {
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.input.validate()?;
|
||||
self.expected.validate()
|
||||
}
|
||||
}
|
||||
validated_struct!(JobManifest {
|
||||
schema_version: SchemaVersion, kind: ManifestKind, evaluation_id: Identifier,
|
||||
submission_id: Identifier, problem_version_id: Identifier, language: Language,
|
||||
toolchain: Toolchain, source: ArtifactReference, cases: Vec<TestCase>,
|
||||
comparison: Comparison, compile_limits: RunLimits, run_limits: RunLimits, job_wall_time_us: u64,
|
||||
});
|
||||
impl JobManifest {
|
||||
pub fn package_bytes(&self) -> u64 {
|
||||
self.source.bytes
|
||||
+ self
|
||||
.cases
|
||||
.iter()
|
||||
.map(|c| c.input.bytes + c.expected.bytes)
|
||||
.sum::<u64>()
|
||||
}
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.source.validate()?;
|
||||
self.toolchain.validate()?;
|
||||
self.compile_limits.validate()?;
|
||||
self.run_limits.validate()?;
|
||||
bounded(self.source.bytes, 1, MAX_SOURCE_BYTES)?;
|
||||
bounded(self.job_wall_time_us, 1000, MAX_JOB_WALL_US)?;
|
||||
if self.cases.is_empty() || self.cases.len() > MAX_CASES {
|
||||
return Err("testcase count outside policy");
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for c in &self.cases {
|
||||
c.validate()?;
|
||||
if !seen.insert(c.case_id) {
|
||||
return Err("duplicate testcase identifiers");
|
||||
}
|
||||
if c.expected.bytes > self.run_limits.stdout_bytes {
|
||||
return Err("expected output exceeds output budget");
|
||||
}
|
||||
}
|
||||
if self.package_bytes() > MAX_PACKAGE_BYTES {
|
||||
return Err("test package outside policy");
|
||||
}
|
||||
let required = self.compile_limits.wall_time_us
|
||||
+ self.cases.len() as u64 * self.run_limits.wall_time_us;
|
||||
if required > self.job_wall_time_us {
|
||||
return Err("job deadline cannot cover execution budgets");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
validated_struct!(LeaseBinding {
|
||||
evaluation_id: Identifier,
|
||||
attempt_id: Identifier,
|
||||
worker_id: Identifier,
|
||||
fencing_version: u64
|
||||
});
|
||||
impl LeaseBinding {
|
||||
pub fn validate(&self) -> Validation {
|
||||
bounded(self.fencing_version, 1, I64_MAX)
|
||||
}
|
||||
}
|
||||
validated_struct!(LeaseGrant {
|
||||
schema_version: SchemaVersion,
|
||||
kind: LeaseKind,
|
||||
binding: LeaseBinding,
|
||||
lease_token: LeaseToken,
|
||||
attempt_number: u32,
|
||||
expires_at_unix_ms: u64,
|
||||
manifest_digest: Digest,
|
||||
});
|
||||
impl LeaseGrant {
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.binding.validate()?;
|
||||
bounded(u64::from(self.attempt_number), 1, 5)?;
|
||||
bounded(self.expires_at_unix_ms, 1, I64_MAX)
|
||||
}
|
||||
}
|
||||
validated_struct!(StageReport {
|
||||
schema_version: SchemaVersion, kind: StageKind, binding: LeaseBinding, sequence: u64,
|
||||
stage: Stage,
|
||||
#[serde(deserialize_with = "required_option")] case_index: Option<u16>,
|
||||
});
|
||||
impl StageReport {
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.binding.validate()?;
|
||||
bounded(self.sequence, 1, I64_MAX)?;
|
||||
if (self.stage == Stage::Running) != self.case_index.is_some()
|
||||
|| self.case_index.is_some_and(|i| usize::from(i) >= MAX_CASES)
|
||||
{
|
||||
return Err("testcase index only belongs to running phase");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
validated_struct!(CompilationReport {
|
||||
status: CompilationStatus,
|
||||
#[serde(deserialize_with = "required_option")] program: Option<ArtifactReference>,
|
||||
diagnostics: CompilerDiagnostics, diagnostics_truncated: bool,
|
||||
#[serde(deserialize_with = "required_option")] cpu_time_us: Option<u64>,
|
||||
#[serde(deserialize_with = "required_option")] memory_peak_bytes: Option<u64>,
|
||||
});
|
||||
impl CompilationReport {
|
||||
pub fn validate(&self) -> Validation {
|
||||
measure(self.cpu_time_us)?;
|
||||
measure(self.memory_peak_bytes)?;
|
||||
if self.diagnostics.as_str().len() > 65536 {
|
||||
return Err("compiler diagnostics exceed byte budget");
|
||||
}
|
||||
if (self.status == CompilationStatus::Succeeded) != self.program.is_some() {
|
||||
return Err("compiled artifact only belongs to successful compilation");
|
||||
}
|
||||
if let Some(program) = &self.program {
|
||||
program.validate()?;
|
||||
bounded(program.bytes, 1, MAX_PACKAGE_BYTES)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
validated_struct!(CaseReport {
|
||||
case_id: Identifier, verdict: Verdict,
|
||||
#[serde(deserialize_with = "required_option")] cpu_time_us: Option<u64>,
|
||||
#[serde(deserialize_with = "required_option")] memory_peak_bytes: Option<u64>,
|
||||
});
|
||||
impl CaseReport {
|
||||
pub fn validate(&self) -> Validation {
|
||||
measure(self.cpu_time_us)?;
|
||||
measure(self.memory_peak_bytes)?;
|
||||
if self.verdict == Verdict::CompilationError {
|
||||
return Err("compilation failure is not a testcase outcome");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
validated_struct!(JudgementReport {
|
||||
schema_version: SchemaVersion, kind: ResultKind, binding: LeaseBinding, manifest_digest: Digest,
|
||||
compilation: CompilationReport, cases: Vec<CaseReport>, verdict: Verdict, cleanup_complete: bool,
|
||||
#[serde(deserialize_with = "required_option")] failure_code: Option<FailureCode>,
|
||||
#[serde(deserialize_with = "required_option")] run_cpu_time_us: Option<u64>,
|
||||
#[serde(deserialize_with = "required_option")] run_memory_peak_bytes: Option<u64>,
|
||||
});
|
||||
impl JudgementReport {
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.binding.validate()?;
|
||||
self.compilation.validate()?;
|
||||
measure(self.run_cpu_time_us)?;
|
||||
measure(self.run_memory_peak_bytes)?;
|
||||
if self.cases.len() > MAX_CASES {
|
||||
return Err("testcase count outside policy");
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (i, c) in self.cases.iter().enumerate() {
|
||||
c.validate()?;
|
||||
if !seen.insert(c.case_id) {
|
||||
return Err("duplicate testcase results");
|
||||
}
|
||||
if matches!(c.verdict, Verdict::SystemError | Verdict::Cancelled)
|
||||
&& i != self.cases.len() - 1
|
||||
{
|
||||
return Err("results after fatal testcase outcome");
|
||||
}
|
||||
}
|
||||
if !self.cleanup_complete && self.failure_code != Some(FailureCode::CleanupFailed) {
|
||||
return Err("incomplete cleanup requires its own failure evidence");
|
||||
}
|
||||
if self.cleanup_complete && self.failure_code == Some(FailureCode::CleanupFailed) {
|
||||
return Err("cleanup failure cannot claim completed cleanup");
|
||||
}
|
||||
if (self.verdict == Verdict::SystemError) != self.failure_code.is_some() {
|
||||
return Err("infrastructure failure and verdict must agree");
|
||||
}
|
||||
if self.compilation.status != CompilationStatus::Succeeded {
|
||||
let mapped = match self.compilation.status {
|
||||
CompilationStatus::CompilationError | CompilationStatus::CompilationLimit => {
|
||||
Verdict::CompilationError
|
||||
}
|
||||
CompilationStatus::Cancelled => Verdict::Cancelled,
|
||||
CompilationStatus::SystemError => Verdict::SystemError,
|
||||
CompilationStatus::Succeeded => unreachable!(),
|
||||
};
|
||||
if !self.cases.is_empty() || self.verdict != mapped {
|
||||
return Err("failed compilation cannot have runtime results");
|
||||
}
|
||||
} else if self.failure_code.is_none() {
|
||||
if self.cases.iter().any(|c| c.verdict == Verdict::SystemError) {
|
||||
return Err("infrastructure failure requires failure code");
|
||||
}
|
||||
if self.cases.is_empty() && self.verdict != Verdict::Cancelled {
|
||||
return Err("missing testcase results cannot be a completed judgement");
|
||||
}
|
||||
if self.verdict != Verdict::Cancelled && !self.cases.is_empty() {
|
||||
let summary = self
|
||||
.cases
|
||||
.iter()
|
||||
.find(|c| c.verdict == Verdict::Cancelled)
|
||||
.or_else(|| self.cases.iter().find(|c| c.verdict != Verdict::Accepted))
|
||||
.map_or(Verdict::Accepted, |c| c.verdict);
|
||||
if self.verdict != summary {
|
||||
return Err("testcase summary disagrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
let cpu = if !self.cases.is_empty() && self.cases.iter().all(|c| c.cpu_time_us.is_some()) {
|
||||
Some(
|
||||
self.cases
|
||||
.iter()
|
||||
.try_fold(0_u64, |total, c| {
|
||||
total.checked_add(c.cpu_time_us.unwrap_or(0))
|
||||
})
|
||||
.ok_or("measurement sum overflow")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let memory =
|
||||
if !self.cases.is_empty() && self.cases.iter().all(|c| c.memory_peak_bytes.is_some()) {
|
||||
self.cases.iter().filter_map(|c| c.memory_peak_bytes).max()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.run_cpu_time_us != cpu || self.run_memory_peak_bytes != memory {
|
||||
return Err("runtime measurement summary disagrees");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate_for_manifest(&self, manifest: &JobManifest, digest: &Digest) -> Validation {
|
||||
self.validate()?;
|
||||
manifest.validate()?;
|
||||
if self.binding.evaluation_id != manifest.evaluation_id || &self.manifest_digest != digest {
|
||||
return Err("report does not belong to authorized manifest");
|
||||
}
|
||||
if self.cases.len() > manifest.cases.len()
|
||||
|| !self
|
||||
.cases
|
||||
.iter()
|
||||
.zip(&manifest.cases)
|
||||
.all(|(a, b)| a.case_id == b.case_id)
|
||||
{
|
||||
return Err("testcase results differ from authorized order");
|
||||
}
|
||||
if !matches!(
|
||||
self.verdict,
|
||||
Verdict::CompilationError | Verdict::Cancelled | Verdict::SystemError
|
||||
) && self.cases.len() != manifest.cases.len()
|
||||
{
|
||||
return Err("incomplete ordinary judgement");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
validated_struct!(AdmissionPolicy {
|
||||
#[serde(default = "one")]
|
||||
max_concurrent_jobs: u32,
|
||||
#[serde(default = "one")]
|
||||
prefetch: u32,
|
||||
#[serde(default = "two")]
|
||||
max_retries: u32,
|
||||
max_job_wall_time_us: u64,
|
||||
max_package_bytes: u64,
|
||||
toolchain: Toolchain,
|
||||
compile_ceiling: RunLimits,
|
||||
run_ceiling: RunLimits,
|
||||
});
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
fn two() -> u32 {
|
||||
2
|
||||
}
|
||||
impl AdmissionPolicy {
|
||||
pub fn validate(&self) -> Validation {
|
||||
self.compile_ceiling.validate()?;
|
||||
self.run_ceiling.validate()?;
|
||||
self.toolchain.validate()?;
|
||||
bounded(u64::from(self.max_concurrent_jobs), 1, 64)?;
|
||||
bounded(u64::from(self.prefetch), 1, 64)?;
|
||||
bounded(u64::from(self.max_retries), 0, 4)?;
|
||||
bounded(self.max_job_wall_time_us, 1000, MAX_JOB_WALL_US)?;
|
||||
bounded(self.max_package_bytes, 1, MAX_PACKAGE_BYTES)?;
|
||||
if self.prefetch > self.max_concurrent_jobs {
|
||||
return Err("prefetch exceeds available job slots");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn admit(&self, manifest: &JobManifest, attempt_number: u32) -> Validation {
|
||||
self.validate()?;
|
||||
manifest.validate()?;
|
||||
bounded(
|
||||
u64::from(attempt_number),
|
||||
1,
|
||||
u64::from(self.max_retries) + 1,
|
||||
)?;
|
||||
if manifest.toolchain != self.toolchain
|
||||
|| !manifest.compile_limits.fits(&self.compile_ceiling)
|
||||
|| !manifest.run_limits.fits(&self.run_ceiling)
|
||||
|| manifest.job_wall_time_us > self.max_job_wall_time_us
|
||||
|| manifest.package_bytes() > self.max_package_bytes
|
||||
{
|
||||
return Err("job exceeds trusted node budget");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod judging;
|
||||
pub mod state;
|
||||
|
||||
pub const SCHEMA_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Pure transition and lease fencing; repositories must apply these under lock.
|
||||
use crate::judging::{LeaseBinding, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EvaluationState {
|
||||
Queued,
|
||||
Claimed,
|
||||
Compiling,
|
||||
Running,
|
||||
Cancelling,
|
||||
Finished,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
impl EvaluationState {
|
||||
pub fn allows(self, target: Self) -> bool {
|
||||
use EvaluationState::*;
|
||||
matches!(
|
||||
(self, target),
|
||||
(Queued, Claimed | Cancelled)
|
||||
| (Claimed, Compiling | Cancelling | Failed | Queued)
|
||||
| (Compiling, Running | Finished | Cancelling | Failed | Queued)
|
||||
| (Running, Finished | Cancelling | Failed | Queued)
|
||||
| (Cancelling, Cancelled | Failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
pub fn require_current_lease(
|
||||
expected: &LeaseBinding,
|
||||
supplied: &LeaseBinding,
|
||||
expires_at_unix_ms: u64,
|
||||
now_unix_ms: u64,
|
||||
) -> Validation {
|
||||
expected.validate()?;
|
||||
supplied.validate()?;
|
||||
if expected != supplied || now_unix_ms >= expires_at_unix_ms {
|
||||
return Err("stale or foreign execution authority");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn require_transition(current: EvaluationState, target: EvaluationState) -> Validation {
|
||||
if current.allows(target) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("invalid evaluation transition")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use kf_contracts::{judging::*, state::*};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn fixtures() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../tests/fixtures/contracts")
|
||||
}
|
||||
fn fixture<T: DeserializeOwned>(name: &str) -> T {
|
||||
decode_wire(&fs::read(fixtures().join(format!("{name}.valid.json"))).unwrap()).unwrap()
|
||||
}
|
||||
fn roundtrip<T: DeserializeOwned + serde::Serialize>(bytes: &[u8], invalid: bool, name: &str) {
|
||||
let result = decode_wire::<T>(bytes);
|
||||
if invalid {
|
||||
assert!(result.is_err(), "must reject {name}");
|
||||
} else {
|
||||
let decoded =
|
||||
serde_json::to_value(result.unwrap_or_else(|e| panic!("{name}: {e}"))).unwrap();
|
||||
let mut original: Value = serde_json::from_slice(bytes).unwrap();
|
||||
if name.contains("admission") && name.contains("defaults") {
|
||||
let object = original.as_object_mut().unwrap();
|
||||
object.insert("max_concurrent_jobs".into(), 1.into());
|
||||
object.insert("prefetch".into(), 1.into());
|
||||
object.insert("max_retries".into(), 2.into());
|
||||
}
|
||||
assert_eq!(decoded, original, "roundtrip {name}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn shared_judging_examples_match_python() {
|
||||
let mut count = 0;
|
||||
for entry in fs::read_dir(fixtures()).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let invalid = name.contains(".invalid-");
|
||||
if name.starts_with("judge-manifest.") {
|
||||
roundtrip::<JobManifest>(&bytes, invalid, &name);
|
||||
} else if name.starts_with("judge-lease.") {
|
||||
roundtrip::<LeaseGrant>(&bytes, invalid, &name);
|
||||
} else if name.starts_with("judge-stage.") {
|
||||
roundtrip::<StageReport>(&bytes, invalid, &name);
|
||||
} else if name.starts_with("judge-result.") {
|
||||
roundtrip::<JudgementReport>(&bytes, invalid, &name);
|
||||
} else if name.starts_with("judge-admission.") {
|
||||
roundtrip::<AdmissionPolicy>(&bytes, invalid, &name);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 50,
|
||||
"judging acceptance/rejection cases must not disappear"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn every_state_transition_matches_shared_table() {
|
||||
let table: Value =
|
||||
serde_json::from_slice(&fs::read(fixtures().join("judge-state.transitions.json")).unwrap())
|
||||
.unwrap();
|
||||
let states = [
|
||||
EvaluationState::Queued,
|
||||
EvaluationState::Claimed,
|
||||
EvaluationState::Compiling,
|
||||
EvaluationState::Running,
|
||||
EvaluationState::Cancelling,
|
||||
EvaluationState::Finished,
|
||||
EvaluationState::Cancelled,
|
||||
EvaluationState::Failed,
|
||||
];
|
||||
assert_eq!(table.as_object().unwrap().len(), states.len());
|
||||
for current in states {
|
||||
for target in states {
|
||||
let name = serde_json::to_value(current).unwrap();
|
||||
let allowed = table[name.as_str().unwrap()]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::to_value(target).unwrap());
|
||||
assert_eq!(current.allows(target), allowed);
|
||||
assert_eq!(require_transition(current, target).is_ok(), allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn lease_fencing_identity_and_deadline_are_strict() {
|
||||
let grant: LeaseGrant = fixture("judge-lease");
|
||||
let binding = &grant.binding;
|
||||
assert!(
|
||||
require_current_lease(
|
||||
binding,
|
||||
binding,
|
||||
grant.expires_at_unix_ms,
|
||||
grant.expires_at_unix_ms - 1
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
for now in [grant.expires_at_unix_ms, grant.expires_at_unix_ms + 1] {
|
||||
assert!(require_current_lease(binding, binding, grant.expires_at_unix_ms, now).is_err());
|
||||
}
|
||||
let foreign: Identifier = "aaaaaaaa-aaaa-4aaa-8aaa-000000000009"
|
||||
.to_owned()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
for i in 0..4 {
|
||||
let mut supplied = binding.clone();
|
||||
match i {
|
||||
0 => supplied.fencing_version += 1,
|
||||
1 => supplied.attempt_id = foreign,
|
||||
2 => supplied.worker_id = foreign,
|
||||
_ => supplied.evaluation_id = foreign,
|
||||
}
|
||||
assert!(require_current_lease(binding, &supplied, grant.expires_at_unix_ms, 1).is_err());
|
||||
}
|
||||
assert!(!format!("{grant:?}").contains(grant.lease_token.expose_secret()));
|
||||
}
|
||||
#[test]
|
||||
fn trusted_admission_caps_and_retry_limit_cannot_be_relaxed_by_job() {
|
||||
let manifest: JobManifest = fixture("judge-manifest");
|
||||
let policy: AdmissionPolicy = fixture("judge-admission");
|
||||
for attempt in [1, 2, 3] {
|
||||
assert!(policy.admit(&manifest, attempt).is_ok());
|
||||
}
|
||||
for attempt in [0, 4] {
|
||||
assert!(policy.admit(&manifest, attempt).is_err());
|
||||
}
|
||||
let mut other = manifest.clone();
|
||||
other.toolchain.image_revision += 1;
|
||||
assert!(policy.admit(&other, 1).is_err());
|
||||
let mut lower = policy.clone();
|
||||
lower.max_package_bytes = 1;
|
||||
assert!(lower.admit(&manifest, 1).is_err());
|
||||
let mut lower = policy.clone();
|
||||
lower.max_job_wall_time_us = 1000;
|
||||
assert!(lower.admit(&manifest, 1).is_err());
|
||||
let mut lower = policy;
|
||||
lower.run_ceiling.memory_bytes = 32 * MIB;
|
||||
assert!(lower.admit(&manifest, 1).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn results_require_authorized_manifest_and_complete_case_order() {
|
||||
let manifest: JobManifest = fixture("judge-manifest");
|
||||
let result: JudgementReport = fixture("judge-result");
|
||||
assert!(
|
||||
result
|
||||
.validate_for_manifest(&manifest, &result.manifest_digest)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.validate_for_manifest(&manifest, &"f".repeat(64).try_into().unwrap())
|
||||
.is_err()
|
||||
);
|
||||
let mut short = result.clone();
|
||||
short.cases.truncate(1);
|
||||
short.run_cpu_time_us = Some(100);
|
||||
short.run_memory_peak_bytes = Some(256 * 1024);
|
||||
assert!(short.validate().is_ok());
|
||||
assert!(
|
||||
short
|
||||
.validate_for_manifest(&manifest, &result.manifest_digest)
|
||||
.is_err()
|
||||
);
|
||||
let mut wrong_order = result;
|
||||
wrong_order.cases.reverse();
|
||||
assert!(wrong_order.validate().is_ok());
|
||||
assert!(
|
||||
wrong_order
|
||||
.validate_for_manifest(&manifest, &wrong_order.manifest_digest)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn wire_decoder_bounds_ambiguity_and_private_diagnostics() {
|
||||
let bytes = fs::read(fixtures().join("judge-lease.valid.json")).unwrap();
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
let duplicate = text.replace(
|
||||
"\"schema_version\": 1",
|
||||
"\"schema_version\": 1, \"schema_version\": 1",
|
||||
);
|
||||
assert!(decode_wire::<LeaseGrant>(duplicate.as_bytes()).is_err());
|
||||
assert!(decode_wire::<LeaseGrant>(&vec![b' '; MAX_WIRE_BYTES + 1]).is_err());
|
||||
let private = text.replace(&"0".repeat(64), "do-not-echo-private-token");
|
||||
let error = decode_wire::<LeaseGrant>(private.as_bytes()).unwrap_err();
|
||||
assert!(!error.contains("do-not-echo"));
|
||||
let ce: JudgementReport =
|
||||
decode_wire(&fs::read(fixtures().join("judge-result.valid-ce.json")).unwrap()).unwrap();
|
||||
assert!(!format!("{ce:?}").contains(ce.compilation.diagnostics.as_str()));
|
||||
}
|
||||
Reference in New Issue
Block a user