From 957135a11207188330287243edf87ff2ebed9a2a Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 6 Oct 2026 11:02:55 +0800 Subject: [PATCH] feat(sandbox): enforce launcher lifecycle ordering --- judge/README.md | 16 ++ .../crates/sandbox/src/launcher/lifecycle.rs | 244 +++++++++++++++++ judge/crates/sandbox/src/launcher/mod.rs | 7 + judge/crates/sandbox/src/lib.rs | 3 +- .../sandbox/tests/launcher_lifecycle.rs | 257 ++++++++++++++++++ 5 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 judge/crates/sandbox/src/launcher/lifecycle.rs create mode 100644 judge/crates/sandbox/src/launcher/mod.rs create mode 100644 judge/crates/sandbox/tests/launcher_lifecycle.rs diff --git a/judge/README.md b/judge/README.md index 84107e9..5171b60 100644 --- a/judge/README.md +++ b/judge/README.md @@ -78,6 +78,22 @@ FD、栈和其他信号仍按原退出状态记录,缺少专门证据时不猜 工件只能导出规格已申请的路径,单文件与已导出总量均受限;这些字段检查不能替代实际文件安全检查。 准备失败和清理失败分别保留,真实进程树终止、资源回收与 VM 验收仍待后续阶段实现。 +## 启动生命周期顺序 + +`launcher/lifecycle.rs` 提供独立的状态约束:已校验 → 准备 → cgroup/限额确认 → namespace +确认 → 运行根确认 → 身份确认 → seccomp 确认 → exec 放行 → exec 确认 → 等待。 +放行与 exec 成功确认分别记录;主进程退出只进入终止阶段,直到整个任务树为空且启动器/init +子进程已回收,才能进行最终计量、输出/工件收集和清理。清理成功后状态才结束。 + +取消、限额或操作失败停止推进初始化;乱序/重复阶段消息记录协议错误并进入终止流程。 +即使调用方忽略错误,也无法继续这次执行的 exec 放行。计量阶段的错误不恢复已回收的任务, +也不跳过剩余收集;启动/监督故障与清理故障独立保留,重试清理成功不抹掉原始错误。 +最先停止的原因只用于调度诊断,最终执行分类仍由结果模型依据全部观察确定。 + +事件仅由可信监督器在核验同一任务的实际操作后产生,不可从执行请求直接反序列化。 +该模块不执行系统调用、不证明隔离、不产生执行许可;有界 IPC、单线程启动器、真实终止与 +清理仍待实现和独立 VM 验证。当前 CLI `run` 保持拒绝执行。 + ## 可信运行配置与启动前检查 ```bash diff --git a/judge/crates/sandbox/src/launcher/lifecycle.rs b/judge/crates/sandbox/src/launcher/lifecycle.rs new file mode 100644 index 0000000..045c985 --- /dev/null +++ b/judge/crates/sandbox/src/launcher/lifecycle.rs @@ -0,0 +1,244 @@ +//! Monotone lifecycle ordering for the future trusted supervisor. +//! +//! Callers must produce events from verified operations on the same task. These +//! in-memory types cannot authenticate OS observations and are deliberately not +//! deserializable. In particular, reaching FilterReady is no execution permit. + +use crate::outcome::{ErrorCode, ExecutionError, ExecutionStage, LimitKind}; +use crate::spec::ValidatedSpec; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Validated, + Preparing, + CgroupReady, + NamespacesReady, + RootReady, + IdentityReady, + FilterReady, + ExecReleased, + Waiting, + Terminating, + Accounting, + Cleaning, + Finished, +} + +impl State { + /// Operation currently outstanding; cleanup failures have their own record. + pub fn stage(self) -> Option { + match self { + Self::Validated => Some(ExecutionStage::Preparation), + Self::Preparing => Some(ExecutionStage::Cgroup), + Self::CgroupReady => Some(ExecutionStage::Namespaces), + Self::NamespacesReady => Some(ExecutionStage::RootFilesystem), + Self::RootReady => Some(ExecutionStage::Identity), + Self::IdentityReady => Some(ExecutionStage::Seccomp), + Self::FilterReady | Self::ExecReleased => Some(ExecutionStage::Exec), + Self::Waiting | Self::Terminating => Some(ExecutionStage::Wait), + Self::Accounting => Some(ExecutionStage::Accounting), + Self::Cleaning => Some(ExecutionStage::Cleanup), + Self::Finished => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Event { + BeginPreparation, + /// Child remains blocked; task enrollment and limits are verified. + CgroupConfirmed, + NamespacesConfirmed, + RootConfirmed, + /// UID/GID, supplementary groups, capabilities and no_new_privs checked. + IdentityConfirmed, + FilterConfirmed, + /// Trusted control transport has released the exec gate, not exec success. + ExecGateReleased, + /// The exec error channel confirmed success, distinct from gate release. + ExecConfirmed, + /// Does not imply that descendants have exited or output reached EOF. + MainExited, + Cancel, + LimitObserved(LimitKind), + Failed { + code: ErrorCode, + errno: Option, + }, + /// Entire task tree empty and launcher/init children reaped. + TreeReaped, + /// Final accounting and bounded output/artifact collection settled, + /// including explicit unavailable/error records when collection failed. + CollectionFinished, + CleanupSucceeded, + CleanupFailed { + errno: Option, + }, +} + +/// First reason to stop scheduling execution, not a judge/result classifier. +/// Later cancellation and faults remain independently observable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopReason { + MainExited, + Cancelled, + Limit(LimitKind), + Failure, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TransitionError { + pub state: State, + pub event: Event, +} + +impl std::fmt::Display for TransitionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "invalid lifecycle event {:?} in {:?}", + self.event, self.state + ) + } +} + +impl std::error::Error for TransitionError {} + +/// Private fields and no Clone/Deserialize keep each attempt's transitions monotone. +/// Retains at most one primary fault and one independent cleanup fault. +#[derive(Debug)] +pub struct Lifecycle { + run_id: String, + state: State, + stop_reason: Option, + exec_released: bool, + cancel_requested: bool, + failure: Option, + cleanup_failure: Option, +} + +impl Lifecycle { + pub fn new(spec: &ValidatedSpec) -> Self { + Self { + run_id: spec.spec().run_id.clone(), + state: State::Validated, + stop_reason: None, + exec_released: false, + cancel_requested: false, + failure: None, + cleanup_failure: None, + } + } + + pub fn run_id(&self) -> &str { + &self.run_id + } + pub fn state(&self) -> State { + self.state + } + pub fn stop_reason(&self) -> Option { + self.stop_reason + } + pub fn exec_released(&self) -> bool { + self.exec_released + } + pub fn cancel_requested(&self) -> bool { + self.cancel_requested + } + pub fn failure(&self) -> Option<&ExecutionError> { + self.failure.as_ref() + } + pub fn cleanup_failure(&self) -> Option<&ExecutionError> { + self.cleanup_failure.as_ref() + } + + /// An invalid live event latches a protocol fault and requests termination; + /// ignoring the returned error cannot subsequently advance to exec. + /// This only records ordering: the caller must still perform OS termination. + pub fn apply(&mut self, event: Event) -> Result<(), TransitionError> { + let previous = self.state; + if previous == State::Finished { + return Err(TransitionError { + state: previous, + event, + }); + } + match event { + Event::Cancel => { + self.cancel_requested = true; + self.request_stop(StopReason::Cancelled); + return Ok(()); + } + Event::LimitObserved(kind) => { + self.request_stop(StopReason::Limit(kind)); + return Ok(()); + } + Event::Failed { code, errno } if valid_errno(errno) => { + self.record_failure(code, errno); + return Ok(()); + } + Event::CleanupFailed { errno } if previous == State::Cleaning && valid_errno(errno) => { + self.record_failure(ErrorCode::CleanupIncomplete, errno); + return Ok(()); + } + _ => {} + } + let next = match (previous, event) { + (State::Validated, Event::BeginPreparation) => Some(State::Preparing), + (State::Preparing, Event::CgroupConfirmed) => Some(State::CgroupReady), + (State::CgroupReady, Event::NamespacesConfirmed) => Some(State::NamespacesReady), + (State::NamespacesReady, Event::RootConfirmed) => Some(State::RootReady), + (State::RootReady, Event::IdentityConfirmed) => Some(State::IdentityReady), + (State::IdentityReady, Event::FilterConfirmed) => Some(State::FilterReady), + (State::FilterReady, Event::ExecGateReleased) => Some(State::ExecReleased), + (State::ExecReleased, Event::ExecConfirmed) => Some(State::Waiting), + (State::Waiting, Event::MainExited) => { + self.request_stop(StopReason::MainExited); + return Ok(()); + } + (State::Terminating, Event::TreeReaped) => Some(State::Accounting), + (State::Accounting, Event::CollectionFinished) => Some(State::Cleaning), + (State::Cleaning, Event::CleanupSucceeded) => Some(State::Finished), + _ => None, + }; + if let Some(next) = next { + self.state = next; + if next == State::ExecReleased { + self.exec_released = true; + } + Ok(()) + } else { + self.record_failure(ErrorCode::ProtocolFailed, None); + Err(TransitionError { + state: previous, + event, + }) + } + } + + fn request_stop(&mut self, reason: StopReason) { + self.stop_reason.get_or_insert(reason); + match self.state { + State::Terminating | State::Accounting | State::Cleaning | State::Finished => {} + _ => self.state = State::Terminating, + } + } + + fn record_failure(&mut self, code: ErrorCode, errno: Option) { + if let Some(stage) = self.state.stage() { + let error = ExecutionError { stage, code, errno }; + if self.state == State::Cleaning { + self.cleanup_failure.get_or_insert(error); + } else { + self.failure.get_or_insert(error); + } + // Accounting failures do not skip remaining collection. Once the + // tree is reaped, later faults must never restore a live-task phase. + self.request_stop(StopReason::Failure); + } + } +} + +fn valid_errno(errno: Option) -> bool { + errno.is_none_or(|value| (1..=4095).contains(&value)) +} diff --git a/judge/crates/sandbox/src/launcher/mod.rs b/judge/crates/sandbox/src/launcher/mod.rs new file mode 100644 index 0000000..ed7e251 --- /dev/null +++ b/judge/crates/sandbox/src/launcher/mod.rs @@ -0,0 +1,7 @@ +//! Trusted supervisor/launcher coordination, separate from user stdio. +//! +//! Lifecycle events describe completed operations, not commands received from +//! an untrusted request. OS operations and authenticated bounded IPC are pending; +//! this module grants no execution authority and does not change CLI refusal. + +pub mod lifecycle; diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 7a2abb8..7e5657b 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -1,8 +1,9 @@ //! Linux execution boundary: policy/contract checks and read-only observations. //! -//! JS-01 defines execution contracts; JS-02..04 add the launcher, isolation and limits. +//! Execution contracts and lifecycle ordering precede OS launcher/isolation work. //! Until that work is verified, the CLI cannot execute any user program. +pub mod launcher; pub mod outcome; pub mod probe; pub mod runtime; diff --git a/judge/crates/sandbox/tests/launcher_lifecycle.rs b/judge/crates/sandbox/tests/launcher_lifecycle.rs new file mode 100644 index 0000000..0f71186 --- /dev/null +++ b/judge/crates/sandbox/tests/launcher_lifecycle.rs @@ -0,0 +1,257 @@ +use kf_sandbox::launcher::lifecycle::{Event, Lifecycle, State, StopReason}; +use kf_sandbox::outcome::{ErrorCode, ExecutionStage, LimitKind}; +use kf_sandbox::spec::{ExecutionSpec, SpecPolicy}; + +const STARTUP: [Event; 8] = [ + Event::BeginPreparation, + Event::CgroupConfirmed, + Event::NamespacesConfirmed, + Event::RootConfirmed, + Event::IdentityConfirmed, + Event::FilterConfirmed, + Event::ExecGateReleased, + Event::ExecConfirmed, +]; + +fn lifecycle(prefix: usize) -> Lifecycle { + let spec: ExecutionSpec = + serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap(); + let spec = spec.validate(&SpecPolicy::default()).unwrap(); + let mut task = Lifecycle::new(&spec); + assert_eq!(task.run_id(), spec.spec().run_id); + for event in &STARTUP[..prefix] { + task.apply(*event).unwrap(); + } + task +} + +fn settle(task: &mut Lifecycle) { + task.apply(Event::TreeReaped).unwrap(); + task.apply(Event::CollectionFinished).unwrap(); + task.apply(Event::CleanupSucceeded).unwrap(); + assert_eq!(task.state(), State::Finished); +} + +#[test] +fn initialization_exec_confirmation_and_tree_reaping_are_distinct() { + let mut task = lifecycle(6); + assert_eq!(task.state(), State::FilterReady); + assert!(!task.exec_released()); + task.apply(Event::ExecGateReleased).unwrap(); + assert_eq!(task.state(), State::ExecReleased); + assert!(task.exec_released()); + task.apply(Event::ExecConfirmed).unwrap(); + assert_eq!(task.state(), State::Waiting); + task.apply(Event::MainExited).unwrap(); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.stop_reason(), Some(StopReason::MainExited)); + settle(&mut task); + assert!(task.failure().is_none()); + assert!(task.cleanup_failure().is_none()); +} + +#[test] +fn every_incomplete_startup_prefix_rejects_and_latches_early_exec() { + for prefix in 0..6 { + let mut task = lifecycle(prefix); + assert!(task.apply(Event::ExecGateReleased).is_err()); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.failure().unwrap().code, ErrorCode::ProtocolFailed); + // Ignoring the error and submitting the omitted confirmations cannot + // revive this attempt or open its exec gate. + for event in &STARTUP[prefix..] { + assert!(task.apply(*event).is_err()); + } + assert!(!task.exec_released()); + settle(&mut task); + } +} + +#[test] +fn duplicated_or_out_of_order_security_confirmation_aborts() { + for (prefix, event) in [ + (1, Event::FilterConfirmed), + (2, Event::CgroupConfirmed), + (3, Event::IdentityConfirmed), + (4, Event::RootConfirmed), + (6, Event::FilterConfirmed), + ] { + let mut task = lifecycle(prefix); + assert!(task.apply(event).is_err()); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.stop_reason(), Some(StopReason::Failure)); + assert!(!task.exec_released()); + } +} + +#[test] +fn faults_preserve_the_outstanding_stage_and_cannot_restart_execution() { + let stages = [ + ExecutionStage::Preparation, + ExecutionStage::Cgroup, + ExecutionStage::Namespaces, + ExecutionStage::RootFilesystem, + ExecutionStage::Identity, + ExecutionStage::Seccomp, + ExecutionStage::Exec, + ExecutionStage::Exec, + ExecutionStage::Wait, + ]; + for (prefix, stage) in stages.into_iter().enumerate() { + let mut task = lifecycle(prefix); + task.apply(Event::Failed { + code: ErrorCode::OperationFailed, + errno: Some(13), + }) + .unwrap(); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.failure().unwrap().stage, stage); + assert_eq!(task.failure().unwrap().errno, Some(13)); + assert!(task.apply(Event::ExecGateReleased).is_err()); + assert_eq!(task.failure().unwrap().code, ErrorCode::OperationFailed); + settle(&mut task); + } +} + +#[test] +fn cancellation_at_every_pre_exec_boundary_is_idempotent_and_never_execs() { + for prefix in 0..=6 { + let mut task = lifecycle(prefix); + task.apply(Event::Cancel).unwrap(); + task.apply(Event::Cancel).unwrap(); + assert_eq!(task.state(), State::Terminating); + assert!(task.cancel_requested()); + assert_eq!(task.stop_reason(), Some(StopReason::Cancelled)); + assert!(!task.exec_released()); + settle(&mut task); + assert!(task.failure().is_none()); + } +} + +#[test] +fn cancellation_after_release_still_requires_tree_reaping() { + for prefix in [7, 8] { + let mut task = lifecycle(prefix); + task.apply(Event::Cancel).unwrap(); + assert!(task.exec_released()); + assert_eq!(task.state(), State::Terminating); + settle(&mut task); + } +} + +#[test] +fn main_exit_does_not_authorize_early_accounting_or_cleanup() { + for event in [Event::CollectionFinished, Event::CleanupSucceeded] { + let mut task = lifecycle(8); + task.apply(Event::MainExited).unwrap(); + assert!(task.apply(event).is_err()); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.failure().unwrap().stage, ExecutionStage::Wait); + settle(&mut task); + } +} + +#[test] +fn cleanup_fault_is_independent_and_retry_does_not_erase_diagnostics() { + let mut task = lifecycle(3); + task.apply(Event::Failed { + code: ErrorCode::OperationFailed, + errno: Some(1), + }) + .unwrap(); + task.apply(Event::TreeReaped).unwrap(); + task.apply(Event::CollectionFinished).unwrap(); + task.apply(Event::CleanupFailed { errno: Some(16) }) + .unwrap(); + assert_eq!(task.state(), State::Cleaning); + assert_eq!( + task.failure().unwrap().stage, + ExecutionStage::RootFilesystem + ); + assert_eq!(task.failure().unwrap().errno, Some(1)); + assert_eq!( + task.cleanup_failure().unwrap().stage, + ExecutionStage::Cleanup + ); + assert_eq!( + task.cleanup_failure().unwrap().code, + ErrorCode::CleanupIncomplete + ); + assert_eq!(task.cleanup_failure().unwrap().errno, Some(16)); + task.apply(Event::CleanupSucceeded).unwrap(); + assert_eq!(task.state(), State::Finished); + assert!(task.cleanup_failure().is_some()); +} + +#[test] +fn accounting_faults_cannot_restore_live_task_phases_or_skip_collection() { + let mut task = lifecycle(8); + task.apply(Event::MainExited).unwrap(); + task.apply(Event::TreeReaped).unwrap(); + task.apply(Event::Failed { + code: ErrorCode::MeasurementUnavailable, + errno: None, + }) + .unwrap(); + task.apply(Event::Cancel).unwrap(); + assert_eq!(task.state(), State::Accounting); + assert_eq!(task.failure().unwrap().stage, ExecutionStage::Accounting); + assert!(task.cancel_requested()); + assert_eq!(task.stop_reason(), Some(StopReason::MainExited)); + assert!(task.apply(Event::CleanupSucceeded).is_err()); + assert_eq!(task.state(), State::Accounting); + task.apply(Event::CollectionFinished).unwrap(); + task.apply(Event::CleanupSucceeded).unwrap(); +} + +#[test] +fn malformed_errno_becomes_a_protocol_fault_without_storing_invalid_values() { + for errno in [0, 4096, u16::MAX] { + let mut task = lifecycle(1); + assert!( + task.apply(Event::Failed { + code: ErrorCode::OperationFailed, + errno: Some(errno), + }) + .is_err() + ); + assert_eq!(task.state(), State::Terminating); + assert_eq!(task.failure().unwrap().code, ErrorCode::ProtocolFailed); + assert_eq!(task.failure().unwrap().errno, None); + } +} + +#[test] +fn limit_stop_and_late_cancel_preserve_the_first_stop_reason() { + let mut task = lifecycle(8); + task.apply(Event::LimitObserved(LimitKind::CpuTime)) + .unwrap(); + task.apply(Event::Cancel).unwrap(); + task.apply(Event::LimitObserved(LimitKind::Memory)).unwrap(); + assert_eq!( + task.stop_reason(), + Some(StopReason::Limit(LimitKind::CpuTime)) + ); + assert!(task.cancel_requested()); + settle(&mut task); +} + +#[test] +fn finished_tasks_reject_all_events_without_reopening_or_mutating_results() { + let mut task = lifecycle(0); + task.apply(Event::Cancel).unwrap(); + settle(&mut task); + for event in STARTUP.into_iter().chain([ + Event::Cancel, + Event::LimitObserved(LimitKind::Memory), + Event::CleanupFailed { errno: Some(1) }, + Event::CleanupSucceeded, + ]) { + assert!(task.apply(event).is_err()); + assert_eq!(task.state(), State::Finished); + assert_eq!(task.stop_reason(), Some(StopReason::Cancelled)); + assert!(!task.exec_released()); + assert!(task.failure().is_none()); + assert!(task.cleanup_failure().is_none()); + } +}