From ee1f43a4cb9179aff0e500528f2656194eaac770 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 6 Oct 2026 01:00:04 +0800 Subject: [PATCH] feat(sandbox): model execution outcomes and limit attribution --- judge/README.md | 28 +- judge/crates/sandbox/src/lib.rs | 1 + judge/crates/sandbox/src/outcome.rs | 482 +++++++++++++++++ .../tests/fixtures/outcome.exited.json | 18 + .../crates/sandbox/tests/outcome_contract.rs | 503 ++++++++++++++++++ 5 files changed, 1031 insertions(+), 1 deletion(-) create mode 100644 judge/crates/sandbox/src/outcome.rs create mode 100644 judge/crates/sandbox/tests/fixtures/outcome.exited.json create mode 100644 judge/crates/sandbox/tests/outcome_contract.rs diff --git a/judge/README.md b/judge/README.md index 766e7b0..bd50105 100644 --- a/judge/README.md +++ b/judge/README.md @@ -50,4 +50,30 @@ cargo run --locked -p kf-sandbox -- validate --spec crates/sandbox/tests/fixture 环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。 这些检查尚未验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查, -保留独立 VM 中的执行、安全和资源验收。执行结果、限额归因与清理状态的契约将在后续阶段补齐。 +保留独立 VM 中的执行、安全和资源验收。 + +## 本地执行结果与归因 + +`outcome.rs` 定义可信监督器的本地结果记录,含退出码/信号、计量、输出工件、启动阶段错误、 +取消、限额观察及独立清理状态。`ExecutionReport::classify` 校验记录与已校验规格的一致性, +返回执行分类;退出码 0 仅代表程序正常退出,Accepted 仍由引擎比较输出后确定。 +这些类型尚未接入实际监督器,也没有改变平台跨服务协议。 + +计量必须显式区分 `measured` 与 `unavailable`,读取失败、计数器重置及非法数据不能当作零。 +CPU 和内存统计覆盖从纳入任务 cgroup 开始的初始化、线程和后代;CPU 不扣除估算的初始化费用。 +墙钟使用 exec 放行至确认任务树为空并完成最终快照之间的单调时间。 + +主归因先报告基础设施/计量/清理故障,同时保留全部限额证据;正常监督下按首次观察时间选择 +取消或限额,相同时取消优先,限额依次为内存、CPU、墙钟、进程、stdout、stderr、文件和工作目录。 +这是监督器观察顺序,不能宣称是内核事件的真实先后。重复限额、越界时间和不支持的证据拒绝分类。 + +SIGKILL、内存峰值或单独的 `oom_kill` 不会推断任务内存超限。初版内存归因要求可信采集器核对 +任务 cgroup 的配置及基线,再提供 `max`、`oom`、`oom_kill`、`oom_group_kill` 的正增量; +计数器在相同任务 cgroup 上采集,覆盖其后代。 +参见 [cgroup v2 内存事件](https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files)。 +FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜测原因。 + +输出保留字节数受限,`observed_bytes` 在截断时只是已读下界,`reached_eof` 单独记录。 +超限输出须有对应证据;缺失计量、无说明的截断或不完整读取报告基础设施失败。 +工件只能导出规格已申请的路径,单文件与已导出总量均受限;这些字段检查不能替代实际文件安全检查。 +准备失败和清理失败分别保留,真实进程树终止、资源回收与 VM 验收仍待后续阶段实现。 diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 7d50125..61f906a 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -3,5 +3,6 @@ //! JS-01 defines execution contracts; JS-02..04 add the launcher, isolation and limits. //! Until that work is verified, the CLI cannot execute any user program. +pub mod outcome; pub mod probe; pub mod spec; diff --git a/judge/crates/sandbox/src/outcome.rs b/judge/crates/sandbox/src/outcome.rs new file mode 100644 index 0000000..50a8b46 --- /dev/null +++ b/judge/crates/sandbox/src/outcome.rs @@ -0,0 +1,482 @@ +//! Local supervisor observations, not a judge verdict or execution authority. +//! Only trusted collectors may supply these records; schema validation cannot +//! authenticate a cgroup, a counter delta, or an output artifact. + +use crate::spec::{ResourceLimits, ValidatedSpec}; +use kf_contracts::SchemaVersion; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)] +pub enum Measurement { + Measured { value: T }, + Unavailable { reason: MeasurementFailure }, +} + +impl Measurement { + fn value(&self) -> Option<&T> { + match self { + Self::Measured { value } => Some(value), + Self::Unavailable { .. } => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MeasurementFailure { + NotStarted, + ReadFailed, + CounterReset, + InvalidData, +} + +/// Checked differences from the fresh task cgroup's enrollment baseline. +/// oom_kill alone includes global OOM and is deliberately not sufficient. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryEvents { + pub max: u64, + pub oom: u64, + pub oom_kill: u64, + pub oom_group_kill: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceUsage { + /// Entire task cgroup from enrollment, including sandbox initialization + /// and descendants; never subtract estimated initialization overhead. + pub cpu_time_us: Measurement, + /// Monotonic time from opening the exec gate through confirming an empty + /// task tree and taking its final accounting snapshot. + pub wall_time_us: Measurement, + pub memory_peak_bytes: Measurement, + pub memory_events: Measurement, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Termination { + NotStarted, + Exited { code: u8 }, + Signaled { signal: u8, core_dumped: bool }, + WaitFailed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionStage { + Validation, + Preparation, + Cgroup, + Namespaces, + RootFilesystem, + Identity, + Seccomp, + Exec, + Wait, + Accounting, + Output, + ArtifactExport, + Cleanup, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + CapabilityUnavailable, + OperationFailed, + ProtocolFailed, + MeasurementUnavailable, + OutputReadFailed, + ArtifactExportFailed, + CleanupIncomplete, +} + +/// Stable codes only; no host path, input content or unbounded error message. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionError { + pub stage: ExecutionStage, + pub code: ErrorCode, + pub errno: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CleanupState { + NotNeeded, + Complete, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputCapture { + pub artifact_id: Option, + /// Lower bound when collection stopped; not an invented final total. + pub observed_bytes: Measurement, + pub retained_bytes: u64, + pub truncated: bool, + pub reached_eof: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputArtifact { + pub path: String, + pub artifact_id: String, + pub bytes: u64, +} + +/// Declaration order is the tie-break for equal observation timestamps. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LimitKind { + Memory, + CpuTime, + WallTime, + Processes, + Stdout, + Stderr, + FileSize, + WorkdirSize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum LimitEvidence { + /// Requires task-local memory.max OOM and group-kill counter increments; + /// the task must have verified memory.oom.group=1 and its configured cap. + Memory, + CpuTime { + observed_us: u64, + }, + WallTime { + observed_us: u64, + }, + Processes { + max_events_delta: u64, + }, + Stdout { + observed_bytes: u64, + }, + Stderr { + observed_bytes: u64, + }, + FileSize { + observed_bytes: u64, + }, + WorkdirSize { + observed_bytes: u64, + }, +} + +impl LimitEvidence { + pub fn kind(&self) -> LimitKind { + match self { + Self::Memory => LimitKind::Memory, + Self::CpuTime { .. } => LimitKind::CpuTime, + Self::WallTime { .. } => LimitKind::WallTime, + Self::Processes { .. } => LimitKind::Processes, + Self::Stdout { .. } => LimitKind::Stdout, + Self::Stderr { .. } => LimitKind::Stderr, + Self::FileSize { .. } => LimitKind::FileSize, + Self::WorkdirSize { .. } => LimitKind::WorkdirSize, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LimitObservation { + /// First supervisor observation after exec-gate release, not estimated + /// kernel event time; post-exit counter evidence uses its read timestamp. + pub at_wall_time_us: u64, + pub evidence: LimitEvidence, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionReport { + pub schema_version: SchemaVersion, + pub run_id: String, + pub termination: Termination, + pub usage: ResourceUsage, + pub stdout: OutputCapture, + pub stderr: OutputCapture, + pub artifacts: Vec, + pub limits: Vec, + pub cancelled_at_wall_time_us: Option, + pub errors: Vec, + pub cleanup: CleanupState, +} + +/// Execution classification only. There is intentionally no Accepted variant. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExecutionOutcome { + NotStarted, + InfrastructureFailure, + Cancelled, + LimitExceeded { primary: LimitKind }, + Exited { code: u8 }, + Signaled { signal: u8, core_dumped: bool }, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ReportError(pub &'static str); + +impl std::fmt::Display for ReportError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for ReportError {} + +impl ExecutionReport { + /// Consistency checks on trusted observations. This does not verify OS + /// isolation and never changes the CLI's refusal to execute. + pub fn classify(&self, spec: &ValidatedSpec) -> Result { + let limits = &spec.spec().limits; + if self.run_id != spec.spec().run_id || self.errors.len() > 16 || self.limits.len() > 8 { + return Err(ReportError("report_identity_or_size")); + } + if self + .errors + .iter() + .any(|error| error.errno.is_some_and(|errno| errno == 0 || errno > 4095)) + { + return Err(ReportError("errno")); + } + if matches!( + self.termination, + Termination::Signaled { + signal: 0 | 65.., + .. + } + ) { + return Err(ReportError("signal")); + } + if (self.cleanup == CleanupState::Failed) + != self + .errors + .iter() + .any(|error| error.stage == ExecutionStage::Cleanup) + { + return Err(ReportError("cleanup")); + } + check_capture(&self.stdout, limits.stdout_bytes)?; + check_capture(&self.stderr, limits.stderr_bytes)?; + let mut artifact_paths = std::collections::BTreeSet::new(); + let mut artifact_bytes = 0_u64; + for artifact in &self.artifacts { + artifact_bytes = artifact_bytes + .checked_add(artifact.bytes) + .ok_or(ReportError("artifacts"))?; + if !spec.spec().outputs.contains(&artifact.path) + || !artifact_paths.insert(artifact.path.as_str()) + || !artifact_id(&artifact.artifact_id) + || artifact.bytes > limits.file_bytes + || artifact_bytes > limits.workdir_bytes + { + return Err(ReportError("artifacts")); + } + } + let mut observed_kinds = std::collections::BTreeSet::new(); + for observation in &self.limits { + if !observed_kinds.insert(observation.evidence.kind()) + || !self.within_wall_time(observation.at_wall_time_us) + || matches!(observation.evidence, LimitEvidence::WallTime { observed_us } + if observed_us > observation.at_wall_time_us) + || !self.confirmed(&observation.evidence, limits) + { + return Err(ReportError("limit_evidence")); + } + } + if self + .cancelled_at_wall_time_us + .is_some_and(|time| !self.within_wall_time(time)) + { + return Err(ReportError("cancellation_time")); + } + if self.termination == Termination::NotStarted { + if !self.limits.is_empty() + || self.cancelled_at_wall_time_us.is_some() + || !self.artifacts.is_empty() + || self.stdout.retained_bytes != 0 + || self.stderr.retained_bytes != 0 + || self + .stdout + .observed_bytes + .value() + .is_some_and(|value| *value != 0) + || self + .stderr + .observed_bytes + .value() + .is_some_and(|value| *value != 0) + { + return Err(ReportError("not_started")); + } + return if !self.errors.iter().any(|error| { + matches!( + error.stage, + ExecutionStage::Validation + | ExecutionStage::Preparation + | ExecutionStage::Cgroup + | ExecutionStage::Namespaces + | ExecutionStage::RootFilesystem + | ExecutionStage::Identity + | ExecutionStage::Seccomp + | ExecutionStage::Exec + ) + }) { + Err(ReportError("missing_start_error")) + } else if self.cleanup == CleanupState::Failed { + Ok(ExecutionOutcome::InfrastructureFailure) + } else { + Ok(ExecutionOutcome::NotStarted) + }; + } + if self.cleanup == CleanupState::NotNeeded { + return Err(ReportError("cleanup")); + } + // Collector/cleanup failure takes precedence, while the report retains + // all observed limits, termination and outputs for diagnosis. + if !self.errors.is_empty() + || self.termination == Termination::WaitFailed + || self.usage.cpu_time_us.value().is_none() + || self.usage.wall_time_us.value().is_none() + || self.usage.memory_peak_bytes.value().is_none() + || self.usage.memory_events.value().is_none() + || self.stdout.observed_bytes.value().is_none() + || self.stderr.observed_bytes.value().is_none() + || !self.stdout.reached_eof && !self.stdout.truncated + || !self.stderr.reached_eof && !self.stderr.truncated + || (self.stdout.truncated || self.stderr.truncated) + && self.limits.is_empty() + && self.cancelled_at_wall_time_us.is_none() + || self + .stdout + .observed_bytes + .value() + .is_some_and(|bytes| *bytes > limits.stdout_bytes) + && !observed_kinds.contains(&LimitKind::Stdout) + || self + .stderr + .observed_bytes + .value() + .is_some_and(|bytes| *bytes > limits.stderr_bytes) + && !observed_kinds.contains(&LimitKind::Stderr) + { + return Ok(ExecutionOutcome::InfrastructureFailure); + } + let primary = self + .limits + .iter() + .min_by_key(|observation| (observation.at_wall_time_us, observation.evidence.kind())); + // Cancellation wins an exact-time tie with a limit. No later cleanup + // kill signal may replace the reason first observed by supervision. + if self + .cancelled_at_wall_time_us + .is_some_and(|cancelled| primary.is_none_or(|limit| cancelled <= limit.at_wall_time_us)) + { + return Ok(ExecutionOutcome::Cancelled); + } + if let Some(primary) = primary { + return Ok(ExecutionOutcome::LimitExceeded { + primary: primary.evidence.kind(), + }); + } + Ok(match self.termination { + Termination::Exited { code } => ExecutionOutcome::Exited { code }, + Termination::Signaled { + signal, + core_dumped, + } => ExecutionOutcome::Signaled { + signal, + core_dumped, + }, + Termination::NotStarted | Termination::WaitFailed => unreachable!(), + }) + } + + fn within_wall_time(&self, time: u64) -> bool { + self.usage + .wall_time_us + .value() + .is_some_and(|wall| time <= *wall) + } + + fn confirmed(&self, evidence: &LimitEvidence, limits: &ResourceLimits) -> bool { + match *evidence { + LimitEvidence::Memory => self.usage.memory_events.value().is_some_and(|events| { + events.max > 0 && events.oom > 0 && events.oom_group_kill > 0 && events.oom_kill > 0 + }), + LimitEvidence::CpuTime { observed_us } => { + observed_us >= limits.cpu_time_us + && self + .usage + .cpu_time_us + .value() + .is_some_and(|total| observed_us <= *total) + } + LimitEvidence::WallTime { observed_us } => { + observed_us >= limits.wall_time_us + && self + .usage + .wall_time_us + .value() + .is_some_and(|total| observed_us <= *total) + } + LimitEvidence::Processes { max_events_delta } => max_events_delta > 0, + LimitEvidence::Stdout { observed_bytes } => { + observed_bytes > limits.stdout_bytes + && self + .stdout + .observed_bytes + .value() + .is_some_and(|total| observed_bytes <= *total) + } + LimitEvidence::Stderr { observed_bytes } => { + observed_bytes > limits.stderr_bytes + && self + .stderr + .observed_bytes + .value() + .is_some_and(|total| observed_bytes <= *total) + } + LimitEvidence::FileSize { observed_bytes } => observed_bytes > limits.file_bytes, + LimitEvidence::WorkdirSize { observed_bytes } => observed_bytes > limits.workdir_bytes, + } + } +} + +fn artifact_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn check_capture(capture: &OutputCapture, limit: u64) -> Result<(), ReportError> { + if capture.retained_bytes > limit + || capture + .artifact_id + .as_deref() + .is_some_and(|id| !artifact_id(id)) + || capture.retained_bytes > 0 && capture.artifact_id.is_none() + || capture.observed_bytes.value().is_some_and(|observed| { + capture.retained_bytes > *observed + || (capture.retained_bytes < *observed && !capture.truncated) + || (*observed > limit && !capture.truncated) + }) + { + return Err(ReportError("output_capture")); + } + Ok(()) +} diff --git a/judge/crates/sandbox/tests/fixtures/outcome.exited.json b/judge/crates/sandbox/tests/fixtures/outcome.exited.json new file mode 100644 index 0000000..eebc0b0 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/outcome.exited.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "run_id": "run-001", + "termination": {"kind": "exited", "code": 0}, + "usage": { + "cpu_time_us": {"state": "measured", "value": 1000}, + "wall_time_us": {"state": "measured", "value": 2000}, + "memory_peak_bytes": {"state": "measured", "value": 4096}, + "memory_events": {"state": "measured", "value": {"max": 0, "oom": 0, "oom_kill": 0, "oom_group_kill": 0}} + }, + "stdout": {"artifact_id": null, "observed_bytes": {"state": "measured", "value": 0}, "retained_bytes": 0, "truncated": false, "reached_eof": true}, + "stderr": {"artifact_id": null, "observed_bytes": {"state": "measured", "value": 0}, "retained_bytes": 0, "truncated": false, "reached_eof": true}, + "artifacts": [], + "limits": [], + "cancelled_at_wall_time_us": null, + "errors": [], + "cleanup": "complete" +} diff --git a/judge/crates/sandbox/tests/outcome_contract.rs b/judge/crates/sandbox/tests/outcome_contract.rs new file mode 100644 index 0000000..95816ee --- /dev/null +++ b/judge/crates/sandbox/tests/outcome_contract.rs @@ -0,0 +1,503 @@ +use kf_sandbox::outcome::*; +use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec}; + +fn spec() -> ValidatedSpec { + serde_json::from_str::(include_str!("fixtures/spec.valid.json")) + .unwrap() + .validate(&SpecPolicy::default()) + .unwrap() +} + +fn report() -> ExecutionReport { + serde_json::from_str(include_str!("fixtures/outcome.exited.json")).unwrap() +} + +fn measured(value: T) -> Measurement { + Measurement::Measured { value } +} + +fn unavailable() -> Measurement { + Measurement::Unavailable { + reason: MeasurementFailure::ReadFailed, + } +} + +fn observation(time: u64, evidence: LimitEvidence) -> LimitObservation { + LimitObservation { + at_wall_time_us: time, + evidence, + } +} + +fn error(stage: ExecutionStage) -> ExecutionError { + ExecutionError { + stage, + code: ErrorCode::OperationFailed, + errno: Some(5), + } +} + +fn cpu_limited() -> ExecutionReport { + let mut result = report(); + result.usage.cpu_time_us = measured(spec().spec().limits.cpu_time_us); + result.limits.push(observation( + 1000, + LimitEvidence::CpuTime { + observed_us: spec().spec().limits.cpu_time_us, + }, + )); + result.termination = Termination::Signaled { + signal: 9, + core_dumped: false, + }; + result +} + +#[test] +fn exited_fixture_roundtrips_without_a_judge_verdict() { + let mut result = report(); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::Exited { code: 0 }) + ); + let json = serde_json::to_string(&result).unwrap(); + assert!(!json.contains("accepted")); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + result + ); + result.termination = Termination::Exited { code: 23 }; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::Exited { code: 23 }) + ); + result.termination = Termination::Signaled { + signal: 11, + core_dumped: true, + }; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::Signaled { + signal: 11, + core_dumped: true + }) + ); +} + +#[test] +fn unknown_fields_versions_states_and_out_of_range_exit_codes_are_rejected() { + let valid = serde_json::to_value(report()).unwrap(); + for (pointer, value) in [ + ("/schema_version", serde_json::json!(2)), + ("/termination/kind", serde_json::json!("accepted")), + ("/termination/code", serde_json::json!(256)), + ("/usage/cpu_time_us/state", serde_json::json!("estimated")), + ("/usage/memory_peak_bytes/value", serde_json::json!(-1)), + ] { + let mut invalid = valid.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!( + serde_json::from_value::(invalid).is_err(), + "{pointer}" + ); + } + let mut invalid = valid.clone(); + invalid["usage"]["host_path"] = serde_json::json!("/secret"); + assert!(serde_json::from_value::(invalid).is_err()); + let mut invalid = valid; + invalid["stdout"] + .as_object_mut() + .unwrap() + .remove("observed_bytes"); + assert!(serde_json::from_value::(invalid).is_err()); +} + +#[test] +fn missing_measurements_are_infrastructure_failures_instead_of_zero_usage() { + for field in 0..6 { + let mut result = report(); + match field { + 0 => result.usage.cpu_time_us = unavailable(), + 1 => result.usage.wall_time_us = unavailable(), + 2 => result.usage.memory_peak_bytes = unavailable(), + 3 => { + result.usage.memory_events = Measurement::Unavailable { + reason: MeasurementFailure::CounterReset, + } + } + 4 => result.stdout.observed_bytes = unavailable(), + _ => result.stderr.observed_bytes = unavailable(), + } + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + assert!( + serde_json::to_string(&result) + .unwrap() + .contains("unavailable") + ); + } +} + +#[test] +fn sigkill_and_global_oom_counter_alone_do_not_prove_task_memory_limit() { + let mut result = report(); + result.termination = Termination::Signaled { + signal: 9, + core_dumped: false, + }; + result.usage.memory_events = measured(MemoryEvents { + max: 0, + oom: 0, + oom_kill: 1, + oom_group_kill: 0, + }); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::Signaled { + signal: 9, + core_dumped: false + }) + ); + result.limits.push(observation(1000, LimitEvidence::Memory)); + assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence"))); + result.usage.memory_events = measured(MemoryEvents { + max: 1, + oom: 1, + oom_kill: 1, + oom_group_kill: 1, + }); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::Memory + }) + ); +} + +#[test] +fn multiple_limits_use_first_observation_then_stable_tie_break_and_keep_evidence() { + let mut result = cpu_limited(); + let wall_limit = spec().spec().limits.wall_time_us; + result.usage.wall_time_us = measured(wall_limit + 2); + result.limits[0].at_wall_time_us = wall_limit + 1; + result.limits.push(observation( + wall_limit, + LimitEvidence::WallTime { + observed_us: spec().spec().limits.wall_time_us, + }, + )); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::WallTime + }) + ); + result.limits[1].at_wall_time_us = wall_limit + 1; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::CpuTime + }) + ); + result.limits.reverse(); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::CpuTime + }) + ); + assert_eq!(result.limits.len(), 2); + assert!(matches!( + result.termination, + Termination::Signaled { signal: 9, .. } + )); +} + +#[test] +fn cancellation_wins_before_or_at_limit_but_cannot_replace_an_earlier_limit() { + let mut result = cpu_limited(); + for (time, expected) in [ + (999, ExecutionOutcome::Cancelled), + (1000, ExecutionOutcome::Cancelled), + ( + 1001, + ExecutionOutcome::LimitExceeded { + primary: LimitKind::CpuTime, + }, + ), + ] { + result.cancelled_at_wall_time_us = Some(time); + assert_eq!(result.classify(&spec()), Ok(expected)); + } + result.cancelled_at_wall_time_us = Some(2001); + assert_eq!( + result.classify(&spec()), + Err(ReportError("cancellation_time")) + ); +} + +#[test] +fn cleanup_and_wait_failures_cannot_be_reported_as_success_or_hide_observed_limits() { + let mut result = cpu_limited(); + result.cleanup = CleanupState::Failed; + assert_eq!(result.classify(&spec()), Err(ReportError("cleanup"))); + result.errors.push(error(ExecutionStage::Cleanup)); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + assert_eq!(result.limits.len(), 1); + let mut result = report(); + result.termination = Termination::WaitFailed; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); +} + +#[test] +fn startup_failure_requires_a_stage_error_and_separately_preserves_cleanup_failure() { + let mut result = report(); + result.termination = Termination::NotStarted; + result.cleanup = CleanupState::NotNeeded; + assert_eq!( + result.classify(&spec()), + Err(ReportError("missing_start_error")) + ); + result.errors.push(error(ExecutionStage::Seccomp)); + assert_eq!(result.classify(&spec()), Ok(ExecutionOutcome::NotStarted)); + result.cleanup = CleanupState::Failed; + result.errors.push(error(ExecutionStage::Cleanup)); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + result.limits.push(observation( + 1, + LimitEvidence::Processes { + max_events_delta: 1, + }, + )); + assert_eq!(result.classify(&spec()), Err(ReportError("not_started"))); +} + +#[test] +fn bounded_truncated_output_is_a_lower_bound_and_requires_matching_limit_evidence() { + let mut result = report(); + let limit = spec().spec().limits.stdout_bytes; + result.stdout = OutputCapture { + artifact_id: Some("stdout-001".into()), + observed_bytes: measured(limit + 1), + retained_bytes: limit, + truncated: true, + reached_eof: false, + }; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + result.limits.push(observation( + 1000, + LimitEvidence::Stdout { + observed_bytes: limit + 1, + }, + )); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::Stdout + }) + ); + result.stdout.truncated = false; + assert_eq!(result.classify(&spec()), Err(ReportError("output_capture"))); + result.stdout.truncated = true; + result.stdout.retained_bytes = limit + 1; + assert_eq!(result.classify(&spec()), Err(ReportError("output_capture"))); +} + +#[test] +fn incomplete_streams_and_silent_truncation_are_not_successful_execution() { + let mut result = report(); + result.stderr.reached_eof = false; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + result.stderr.truncated = true; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::InfrastructureFailure) + ); + result.cancelled_at_wall_time_us = Some(1000); + assert_eq!(result.classify(&spec()), Ok(ExecutionOutcome::Cancelled)); +} + +#[test] +fn artifact_exports_must_match_requested_paths_and_cannot_exceed_file_caps() { + let mut request = + serde_json::from_str::(include_str!("fixtures/spec.valid.json")).unwrap(); + request.outputs = vec!["main.out".into()]; + let validated = request.validate(&SpecPolicy::default()).unwrap(); + let mut result = report(); + result.artifacts.push(OutputArtifact { + path: "main.out".into(), + artifact_id: "file-001".into(), + bytes: 7, + }); + assert!(result.classify(&validated).is_ok()); + for invalid in [ + OutputArtifact { + path: "../main.out".into(), + ..result.artifacts[0].clone() + }, + OutputArtifact { + artifact_id: "/host/file".into(), + ..result.artifacts[0].clone() + }, + OutputArtifact { + bytes: validated.spec().limits.file_bytes + 1, + ..result.artifacts[0].clone() + }, + ] { + result.artifacts[0] = invalid; + assert_eq!(result.classify(&validated), Err(ReportError("artifacts"))); + result.artifacts[0] = OutputArtifact { + path: "main.out".into(), + artifact_id: "file-001".into(), + bytes: 7, + }; + } + result.artifacts.push(result.artifacts[0].clone()); + assert_eq!(result.classify(&validated), Err(ReportError("artifacts"))); +} + +#[test] +fn invalid_observation_values_times_duplicates_identity_and_signals_are_rejected() { + for evidence in [ + LimitEvidence::CpuTime { observed_us: 1 }, + LimitEvidence::WallTime { observed_us: 1 }, + LimitEvidence::Processes { + max_events_delta: 0, + }, + LimitEvidence::FileSize { observed_bytes: 0 }, + LimitEvidence::WorkdirSize { observed_bytes: 0 }, + LimitEvidence::Stderr { observed_bytes: 0 }, + ] { + let mut result = report(); + result.limits.push(observation(1000, evidence)); + assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence"))); + } + let mut result = cpu_limited(); + result.limits[0].at_wall_time_us = u64::MAX; + assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence"))); + let mut result = cpu_limited(); + result.limits.push(result.limits[0].clone()); + assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence"))); + result = report(); + result.run_id = "other-run".into(); + assert_eq!( + result.classify(&spec()), + Err(ReportError("report_identity_or_size")) + ); + result = report(); + result.termination = Termination::Signaled { + signal: 0, + core_dumped: false, + }; + assert_eq!(result.classify(&spec()), Err(ReportError("signal"))); +} + +#[test] +fn resource_denial_and_measured_capacity_evidence_are_classified_without_signal_guessing() { + let limits = &spec().spec().limits.clone(); + for (evidence, primary) in [ + ( + LimitEvidence::Processes { + max_events_delta: 1, + }, + LimitKind::Processes, + ), + ( + LimitEvidence::FileSize { + observed_bytes: limits.file_bytes + 1, + }, + LimitKind::FileSize, + ), + ( + LimitEvidence::WorkdirSize { + observed_bytes: limits.workdir_bytes + 1, + }, + LimitKind::WorkdirSize, + ), + ] { + let mut result = report(); + result.limits.push(observation(1000, evidence)); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { primary }) + ); + } + let mut result = report(); + result.stderr = OutputCapture { + artifact_id: Some("stderr-001".into()), + observed_bytes: measured(limits.stderr_bytes + 1), + retained_bytes: limits.stderr_bytes, + truncated: true, + reached_eof: true, + }; + result.limits.push(observation( + 1000, + LimitEvidence::Stderr { + observed_bytes: limits.stderr_bytes + 1, + }, + )); + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::Stderr + }) + ); +} + +#[test] +fn wall_evidence_cannot_claim_a_threshold_before_its_observation_time() { + let mut result = report(); + let limit = spec().spec().limits.wall_time_us; + result.usage.wall_time_us = measured(limit); + result.limits.push(observation( + limit - 1, + LimitEvidence::WallTime { observed_us: limit }, + )); + assert_eq!(result.classify(&spec()), Err(ReportError("limit_evidence"))); + result.limits[0].at_wall_time_us = limit; + assert_eq!( + result.classify(&spec()), + Ok(ExecutionOutcome::LimitExceeded { + primary: LimitKind::WallTime + }) + ); +} + +#[test] +fn exported_files_together_cannot_exceed_workdir_budget() { + let mut request = + serde_json::from_str::(include_str!("fixtures/spec.valid.json")).unwrap(); + request.outputs = (0..5).map(|index| format!("file-{index}")).collect(); + let validated = request.validate(&SpecPolicy::default()).unwrap(); + let mut result = report(); + for index in 0..5 { + result.artifacts.push(OutputArtifact { + path: format!("file-{index}"), + artifact_id: format!("artifact-{index}"), + bytes: validated.spec().limits.file_bytes, + }); + } + assert_eq!(result.classify(&validated), Err(ReportError("artifacts"))); + result.artifacts.pop(); + assert_eq!( + result.classify(&validated), + Ok(ExecutionOutcome::Exited { code: 0 }) + ); +}