feat(sandbox): model execution outcomes and limit attribution

This commit is contained in:
2026-10-06 01:00:04 +08:00
parent 7ef77dfb34
commit ee1f43a4cb
5 changed files with 1031 additions and 1 deletions
+27 -1
View File
@@ -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 验收仍待后续阶段实现。
+1
View File
@@ -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;
+482
View File
@@ -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<T> {
Measured { value: T },
Unavailable { reason: MeasurementFailure },
}
impl<T> Measurement<T> {
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<u64>,
/// Monotonic time from opening the exec gate through confirming an empty
/// task tree and taking its final accounting snapshot.
pub wall_time_us: Measurement<u64>,
pub memory_peak_bytes: Measurement<u64>,
pub memory_events: Measurement<MemoryEvents>,
}
#[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<u16>,
}
#[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<String>,
/// Lower bound when collection stopped; not an invented final total.
pub observed_bytes: Measurement<u64>,
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<OutputArtifact>,
pub limits: Vec<LimitObservation>,
pub cancelled_at_wall_time_us: Option<u64>,
pub errors: Vec<ExecutionError>,
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<ExecutionOutcome, ReportError> {
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(())
}
+18
View File
@@ -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"
}
@@ -0,0 +1,503 @@
use kf_sandbox::outcome::*;
use kf_sandbox::spec::{ExecutionSpec, SpecPolicy, ValidatedSpec};
fn spec() -> ValidatedSpec {
serde_json::from_str::<ExecutionSpec>(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<T>(value: T) -> Measurement<T> {
Measurement::Measured { value }
}
fn unavailable<T>() -> Measurement<T> {
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::<ExecutionReport>(&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::<ExecutionReport>(invalid).is_err(),
"{pointer}"
);
}
let mut invalid = valid.clone();
invalid["usage"]["host_path"] = serde_json::json!("/secret");
assert!(serde_json::from_value::<ExecutionReport>(invalid).is_err());
let mut invalid = valid;
invalid["stdout"]
.as_object_mut()
.unwrap()
.remove("observed_bytes");
assert!(serde_json::from_value::<ExecutionReport>(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::<ExecutionSpec>(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::<ExecutionSpec>(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 })
);
}