diff --git a/judge/README.md b/judge/README.md index 1aec378..766e7b0 100644 --- a/judge/README.md +++ b/judge/README.md @@ -27,3 +27,27 @@ make coordinator 不代表已经验证创建权限、委派或安全隔离。`run` 返回结构化拒绝和退出码 2。 真正的权限操作和 `unsafe` 代码只能落在沙箱模块,实施时逐项记录安全理由与 Linux 验证。 当前统一禁止 `unsafe`,需要时在对应模块明确缩小例外范围。 + +## 本地执行规格校验 + +```bash +cd judge +cargo run --locked -p kf-sandbox -- validate --spec crates/sandbox/tests/fixtures/spec.valid.json +``` + +`validate` 最多读取 64 KiB JSON,返回校验结果和 `execution_allowed: false`。 +`run` 继续拒绝执行。当前规格仅用于本地 Worker/启动器边界,未改变平台 API 或跨服务任务通知。 + +规格使用 `native`、`compile_c`、`compile_cpp` profile;原生可执行文件必须引用输入工件在 +`/work` 下的路径,编译器入口固定为运行镜像内的 `/usr/bin/gcc` 或 `/usr/bin/g++`。 +输入工件只声明逻辑 ID 和只读文件相对路径,cwd 与导出路径也位于 `/work`; +请求不能提供宿主挂载、运行根、UID/GID 或策略上限。路径采用规范 ASCII 文件名, +不接受 `..`、绝对路径、空分段、反斜杠或隐式规范化。 + +限额须逐项显式提供:时间单位为微秒,内存、栈、输出、文件与工作目录容量单位为字节, +进程和 FD 为个数。`ResourceLimits::for_profile` 提供调用方模板,可信 `SpecPolicy` 可进一步收紧上限; +缺失或为零的限额不会变成无限制。CPU 总时间和墙钟时间分别校验,不假设多线程 CPU 时间小于墙钟。 +环境变量仅接受 `LANG`/`LC_ALL` 的 `C`、`C.UTF-8` 和 `TZ=UTC`。 + +这些检查尚未验证工件归属、实际文件类型、符号链接、身份与隔离能力;初始化器必须继续完成这些检查, +保留独立 VM 中的执行、安全和资源验收。执行结果、限额归因与清理状态的契约将在后续阶段补齐。 diff --git a/judge/crates/sandbox/src/lib.rs b/judge/crates/sandbox/src/lib.rs index 2181421..7d50125 100644 --- a/judge/crates/sandbox/src/lib.rs +++ b/judge/crates/sandbox/src/lib.rs @@ -4,3 +4,4 @@ //! Until that work is verified, the CLI cannot execute any user program. pub mod probe; +pub mod spec; diff --git a/judge/crates/sandbox/src/main.rs b/judge/crates/sandbox/src/main.rs index 05f26e8..46e1f9e 100644 --- a/judge/crates/sandbox/src/main.rs +++ b/judge/crates/sandbox/src/main.rs @@ -1,4 +1,8 @@ use clap::{Parser, Subcommand}; +use kf_sandbox::spec::{ExecutionSpec, MAX_SPEC_BYTES, SpecPolicy}; +use std::fs::File; +use std::io::Read; +use std::path::PathBuf; use std::process::ExitCode; #[derive(Parser)] @@ -12,6 +16,11 @@ struct Cli { enum Command { /// Observe the current Linux environment without changing it. Probe, + /// Validate a local execution specification without running any program. + Validate { + #[arg(long)] + spec: PathBuf, + }, /// Reserved execution entrypoint; refuses execution until isolation is implemented. Run, } @@ -34,5 +43,51 @@ fn main() -> ExitCode { ); ExitCode::from(2) } + Command::Validate { spec } => validate_spec(spec), } } + +fn validate_spec(path: PathBuf) -> ExitCode { + let Ok(file) = File::open(path) else { + return spec_error("cannot_read_spec", None); + }; + let mut bytes = Vec::new(); + if file + .take(MAX_SPEC_BYTES + 1) + .read_to_end(&mut bytes) + .is_err() + { + return spec_error("cannot_read_spec", None); + } + if bytes.len() as u64 > MAX_SPEC_BYTES { + return spec_error("spec_too_large", None); + } + let request: ExecutionSpec = match serde_json::from_slice(&bytes) { + Ok(request) => request, + Err(error) => { + return spec_error( + "invalid_spec_json", + Some(serde_json::json!({"line": error.line(), "column": error.column()})), + ); + } + }; + match request.validate(&SpecPolicy::default()) { + Ok(validated) => { + println!( + "{}", + serde_json::json!({ + "valid": true, + "execution_allowed": false, + "run_id": validated.spec().run_id.as_str() + }) + ); + ExitCode::SUCCESS + } + Err(error) => spec_error("invalid_execution_spec", Some(serde_json::json!(error))), + } +} + +fn spec_error(code: &str, details: Option) -> ExitCode { + eprintln!("{}", serde_json::json!({"error": code, "details": details})); + ExitCode::from(2) +} diff --git a/judge/crates/sandbox/src/spec.rs b/judge/crates/sandbox/src/spec.rs new file mode 100644 index 0000000..a5629b5 --- /dev/null +++ b/judge/crates/sandbox/src/spec.rs @@ -0,0 +1,337 @@ +//! Local Worker-to-launcher specification; no execution authority or host paths. +//! +//! Validation is lexical only. The launcher must subsequently resolve artifacts +//! within their task, enforce filesystem containment, and apply all isolation. + +use kf_contracts::SchemaVersion; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +pub const MAX_SPEC_BYTES: u64 = 65_536; +const MIB: u64 = 1024 * 1024; +const MAX_PATH_BYTES: usize = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionProfile { + Native, + CompileC, + CompileCpp, +} + +/// Every field is explicit: omission cannot silently remove a resource limit. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceLimits { + pub cpu_time_us: u64, + pub wall_time_us: u64, + pub memory_bytes: u64, + pub stack_bytes: u64, + pub processes: u32, + pub open_files: u32, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub file_bytes: u64, + pub workdir_bytes: u64, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + cpu_time_us: 1_000_000, + wall_time_us: 3_000_000, + memory_bytes: 64 * MIB, + stack_bytes: 8 * MIB, + processes: 8, + open_files: 64, + stdout_bytes: MIB, + stderr_bytes: MIB, + file_bytes: 16 * MIB, + workdir_bytes: 64 * MIB, + } + } +} + +impl ResourceLimits { + pub fn for_profile(profile: ExecutionProfile) -> Self { + match profile { + ExecutionProfile::Native => Self::default(), + ExecutionProfile::CompileC | ExecutionProfile::CompileCpp => Self { + cpu_time_us: 10_000_000, + wall_time_us: 30_000_000, + memory_bytes: 512 * MIB, + stack_bytes: 16 * MIB, + processes: 32, + open_files: 128, + file_bytes: 64 * MIB, + workdir_bytes: 256 * MIB, + ..Self::default() + }, + } + } + + fn ceilings() -> Self { + Self { + cpu_time_us: 60_000_000, + wall_time_us: 120_000_000, + memory_bytes: 2048 * MIB, + stack_bytes: 256 * MIB, + processes: 128, + open_files: 1024, + stdout_bytes: 16 * MIB, + stderr_bytes: 16 * MIB, + file_bytes: 64 * MIB, + workdir_bytes: 256 * MIB, + } + } +} + +/// Read-only input at a canonical relative path beneath /work. +/// The artifact provider must independently check ownership and file type. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InputArtifact { + pub artifact_id: String, + pub path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionSpec { + pub schema_version: SchemaVersion, + pub run_id: String, + pub profile: ExecutionProfile, + pub argv: Vec, + #[serde(default)] + pub env: BTreeMap, + /// Canonical relative directory beneath /work; "." selects /work itself. + pub cwd: String, + #[serde(default)] + pub inputs: Vec, + pub stdin_artifact: Option, + #[serde(default)] + pub outputs: Vec, + pub limits: ResourceLimits, +} + +/// Trusted operator policy, deliberately absent from the request schema. +#[derive(Clone, Debug)] +pub struct SpecPolicy { + limits_ceiling: ResourceLimits, +} + +impl Default for SpecPolicy { + fn default() -> Self { + Self { + limits_ceiling: ResourceLimits::ceilings(), + } + } +} + +impl SpecPolicy { + pub fn with_limits_ceiling(limits: ResourceLimits) -> Result { + validate_limits(&limits, &ResourceLimits::ceilings())?; + Ok(Self { + limits_ceiling: limits, + }) + } +} + +/// An immutable validated request, not proof that the environment is safe. +#[derive(Debug)] +pub struct ValidatedSpec(ExecutionSpec); + +impl ValidatedSpec { + pub fn spec(&self) -> &ExecutionSpec { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ValidationCode { + InvalidIdentifier, + InvalidArgv, + UnsupportedExecutable, + InvalidEnvironment, + UnsafePath, + ConflictingPath, + TooManyArtifacts, + InvalidLimit, + LimitExceedsPolicy, + InconsistentLimits, +} + +/// Reports the field, never untrusted values or source contents. +#[derive(Debug, PartialEq, Eq, Serialize)] +pub struct SpecError { + pub field: &'static str, + pub code: ValidationCode, +} + +impl fmt::Display for SpecError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {:?}", self.field, self.code) + } +} + +impl std::error::Error for SpecError {} + +fn error(field: &'static str, code: ValidationCode) -> SpecError { + SpecError { field, code } +} + +fn identifier(value: &str, field: &'static str) -> Result<(), SpecError> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(error(field, ValidationCode::InvalidIdentifier)); + } + Ok(()) +} + +fn path(value: &str, field: &'static str) -> Result<(), SpecError> { + if value.is_empty() + || value.len() > MAX_PATH_BYTES + || value.split('/').any(|part| { + part.is_empty() + || part == "." + || part == ".." + || part.len() > 255 + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + }) + { + return Err(error(field, ValidationCode::UnsafePath)); + } + Ok(()) +} + +fn overlaps(left: &str, right: &str) -> bool { + left == right + || left + .strip_prefix(right) + .is_some_and(|tail| tail.starts_with('/')) + || right + .strip_prefix(left) + .is_some_and(|tail| tail.starts_with('/')) +} + +fn validate_limits(limits: &ResourceLimits, cap: &ResourceLimits) -> Result<(), SpecError> { + let bounds = [ + ("cpu_time_us", limits.cpu_time_us, 1000, cap.cpu_time_us), + ("wall_time_us", limits.wall_time_us, 1000, cap.wall_time_us), + ("memory_bytes", limits.memory_bytes, 1, cap.memory_bytes), + ("stack_bytes", limits.stack_bytes, 1, cap.stack_bytes), + ( + "processes", + u64::from(limits.processes), + 1, + u64::from(cap.processes), + ), + ( + "open_files", + u64::from(limits.open_files), + 3, + u64::from(cap.open_files), + ), + ("stdout_bytes", limits.stdout_bytes, 1, cap.stdout_bytes), + ("stderr_bytes", limits.stderr_bytes, 1, cap.stderr_bytes), + ("file_bytes", limits.file_bytes, 1, cap.file_bytes), + ("workdir_bytes", limits.workdir_bytes, 1, cap.workdir_bytes), + ]; + for (field, value, minimum, maximum) in bounds { + if value < minimum { + return Err(error(field, ValidationCode::InvalidLimit)); + } + if value > maximum { + return Err(error(field, ValidationCode::LimitExceedsPolicy)); + } + } + if limits.stack_bytes > limits.memory_bytes || limits.file_bytes > limits.workdir_bytes { + return Err(error("limits", ValidationCode::InconsistentLimits)); + } + Ok(()) +} + +impl ExecutionSpec { + pub fn validate(self, policy: &SpecPolicy) -> Result { + identifier(&self.run_id, "run_id")?; + if self.argv.is_empty() + || self.argv.len() > 128 + || self + .argv + .iter() + .any(|arg| arg.len() > 4096 || arg.contains('\0')) + || self.argv.iter().map(String::len).sum::() > 32_768 + { + return Err(error("argv", ValidationCode::InvalidArgv)); + } + for (key, value) in &self.env { + let allowed = match key.as_str() { + "LANG" | "LC_ALL" => matches!(value.as_str(), "C" | "C.UTF-8"), + "TZ" => value == "UTC", + _ => false, + }; + if !allowed { + return Err(error("env", ValidationCode::InvalidEnvironment)); + } + } + if self.cwd != "." { + path(&self.cwd, "cwd")?; + } + if self.inputs.len() > 64 || self.outputs.len() > 16 { + return Err(error("artifacts", ValidationCode::TooManyArtifacts)); + } + let mut input_paths = BTreeSet::new(); + for input in &self.inputs { + identifier(&input.artifact_id, "inputs.artifact_id")?; + path(&input.path, "inputs.path")?; + if input_paths + .iter() + .any(|other: &&str| overlaps(other, &input.path)) + || self.cwd == input.path + || self + .cwd + .strip_prefix(&input.path) + .is_some_and(|tail| tail.starts_with('/')) + { + return Err(error("inputs.path", ValidationCode::ConflictingPath)); + } + input_paths.insert(input.path.as_str()); + } + if let Some(stdin) = &self.stdin_artifact { + identifier(stdin, "stdin_artifact")?; + } + let mut output_paths = BTreeSet::new(); + for output in &self.outputs { + path(output, "outputs")?; + if input_paths.iter().any(|input| overlaps(input, output)) + || output_paths + .iter() + .any(|other: &&str| overlaps(other, output)) + { + return Err(error("outputs", ValidationCode::ConflictingPath)); + } + output_paths.insert(output.as_str()); + } + let executable = &self.argv[0]; + let approved = match self.profile { + ExecutionProfile::Native => executable + .strip_prefix("/work/") + .is_some_and(|relative| input_paths.contains(relative)), + ExecutionProfile::CompileC => executable == "/usr/bin/gcc", + ExecutionProfile::CompileCpp => executable == "/usr/bin/g++", + }; + if !approved { + return Err(error("argv[0]", ValidationCode::UnsupportedExecutable)); + } + validate_limits(&self.limits, &policy.limits_ceiling)?; + Ok(ValidatedSpec(self)) + } +} diff --git a/judge/crates/sandbox/tests/fixtures/spec.valid.json b/judge/crates/sandbox/tests/fixtures/spec.valid.json new file mode 100644 index 0000000..94e47c0 --- /dev/null +++ b/judge/crates/sandbox/tests/fixtures/spec.valid.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "run_id": "run-001", + "profile": "native", + "argv": ["/work/main"], + "env": {"LANG": "C.UTF-8", "TZ": "UTC"}, + "cwd": ".", + "inputs": [{"artifact_id": "program-001", "path": "main"}], + "stdin_artifact": "input-001", + "outputs": [], + "limits": { + "cpu_time_us": 1000000, + "wall_time_us": 3000000, + "memory_bytes": 67108864, + "stack_bytes": 8388608, + "processes": 8, + "open_files": 64, + "stdout_bytes": 1048576, + "stderr_bytes": 1048576, + "file_bytes": 16777216, + "workdir_bytes": 67108864 + } +} diff --git a/judge/crates/sandbox/tests/spec_validation.rs b/judge/crates/sandbox/tests/spec_validation.rs new file mode 100644 index 0000000..d186f8b --- /dev/null +++ b/judge/crates/sandbox/tests/spec_validation.rs @@ -0,0 +1,258 @@ +use kf_sandbox::spec::{ + ExecutionProfile, ExecutionSpec, InputArtifact, ResourceLimits, SpecPolicy, ValidationCode, +}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap() +} + +fn spec() -> ExecutionSpec { + serde_json::from_value(fixture()).unwrap() +} + +fn rejected(request: ExecutionSpec, field: &str, code: ValidationCode) { + let error = request.validate(&SpecPolicy::default()).unwrap_err(); + assert_eq!(error.field, field); + assert_eq!(error.code, code); +} + +#[test] +fn native_fixture_roundtrips_and_defaults_are_explicit() { + let request = spec().validate(&SpecPolicy::default()).unwrap(); + assert_eq!(serde_json::to_value(request.spec()).unwrap(), fixture()); + assert_eq!(request.spec().limits, ResourceLimits::default()); +} + +#[test] +fn compiler_profiles_require_fixed_executables_and_accept_profile_defaults() { + for (profile, executable) in [ + (ExecutionProfile::CompileC, "/usr/bin/gcc"), + (ExecutionProfile::CompileCpp, "/usr/bin/g++"), + ] { + let mut request = spec(); + request.profile = profile; + request.argv = vec![ + executable.into(), + "source/main.c".into(), + "-o".into(), + "main".into(), + ]; + request.inputs[0].path = "source/main.c".into(); + request.outputs = vec!["main".into()]; + request.limits = ResourceLimits::for_profile(profile); + request.validate(&SpecPolicy::default()).unwrap(); + let mut invalid = spec(); + invalid.profile = profile; + rejected(invalid, "argv[0]", ValidationCode::UnsupportedExecutable); + } +} + +#[test] +fn json_rejects_unknown_profiles_versions_host_mounts_and_missing_limits() { + for (key, value) in [ + ("profile", json!("shell")), + ("schema_version", json!(2)), + ("mounts", json!([{"source": "/etc", "target": "/work"}])), + ] { + let mut document = fixture(); + document[key] = value; + assert!( + serde_json::from_value::(document).is_err(), + "{key}" + ); + } + let mut document = fixture(); + document["limits"] + .as_object_mut() + .unwrap() + .remove("memory_bytes"); + assert!(serde_json::from_value::(document).is_err()); + let mut document = fixture(); + document["inputs"][0]["writable"] = json!(true); + assert!(serde_json::from_value::(document).is_err()); +} + +#[test] +fn argument_limits_reject_nul_empty_commands_and_excessive_size() { + for argv in [ + vec![], + vec!["/work/main".into(), "a\0b".into()], + vec!["/work/main".into(), "x".repeat(4097)], + vec!["x".into(); 129], + std::iter::once("/work/main".into()) + .chain(std::iter::repeat_n("x".repeat(4096), 8)) + .collect(), + ] { + let mut request = spec(); + request.argv = argv; + rejected(request, "argv", ValidationCode::InvalidArgv); + } + let mut request = spec(); + request.argv.push("参数 with spaces; $(not-a-shell)".into()); + request.validate(&SpecPolicy::default()).unwrap(); +} + +#[test] +fn native_executable_must_be_a_bound_work_artifact() { + for executable in [ + "/bin/sh", + "/work/../bin/sh", + "/work/missing", + "/work//main", + "main", + "", + ] { + let mut request = spec(); + request.argv[0] = executable.into(); + rejected(request, "argv[0]", ValidationCode::UnsupportedExecutable); + } +} + +#[test] +fn environment_does_not_accept_loader_overrides_or_arbitrary_values() { + for (key, value) in [ + ("LD_PRELOAD", "lib.so"), + ("PATH", "/host/bin"), + ("LANG", "a\0b"), + ("TZ", "Europe/London"), + ] { + let mut request = spec(); + request.env.insert(key.into(), value.into()); + rejected(request, "env", ValidationCode::InvalidEnvironment); + } +} + +#[test] +fn paths_reject_traversal_absolute_windows_ambiguous_and_long_components() { + let mut invalid_paths = vec![ + "".into(), + "/etc/passwd".into(), + "../secret".into(), + "a/../b".into(), + "a//b".into(), + "a/./b".into(), + "a/".into(), + "C:\\secret".into(), + "%2e%2e/secret".into(), + "a\0b".into(), + ]; + invalid_paths.push("x".repeat(256)); + for path in invalid_paths { + let mut request = spec(); + request.inputs[0].path = path; + rejected(request, "inputs.path", ValidationCode::UnsafePath); + } + let mut request = spec(); + request.cwd = "../host".into(); + rejected(request, "cwd", ValidationCode::UnsafePath); +} + +#[test] +fn identifiers_cannot_be_host_paths_or_empty_and_artifact_counts_are_bounded() { + for value in ["", "../other", "/host/input", "a.b", "a\0b"] { + let mut request = spec(); + request.run_id = value.into(); + rejected(request, "run_id", ValidationCode::InvalidIdentifier); + let mut request = spec(); + request.stdin_artifact = Some(value.into()); + rejected(request, "stdin_artifact", ValidationCode::InvalidIdentifier); + } + let mut request = spec(); + request.inputs = vec![request.inputs[0].clone(); 65]; + rejected(request, "artifacts", ValidationCode::TooManyArtifacts); + let mut request = spec(); + request.outputs = vec!["output".into(); 17]; + rejected(request, "artifacts", ValidationCode::TooManyArtifacts); +} + +#[test] +fn conflicting_bindings_and_exports_cannot_replace_read_only_inputs() { + for path in ["main", "main/child"] { + let mut request = spec(); + request.inputs.push(InputArtifact { + artifact_id: "other".into(), + path: path.into(), + }); + rejected(request, "inputs.path", ValidationCode::ConflictingPath); + } + let mut request = spec(); + request.outputs.push("main".into()); + rejected(request, "outputs", ValidationCode::ConflictingPath); + let mut request = spec(); + request.outputs = vec!["out".into(), "out/file".into()]; + rejected(request, "outputs", ValidationCode::ConflictingPath); +} + +#[test] +fn cwd_can_contain_an_input_but_cannot_be_or_descend_from_an_input_file() { + let mut request = spec(); + request.inputs[0].path = "bin/main".into(); + request.argv[0] = "/work/bin/main".into(); + request.cwd = "bin".into(); + request.validate(&SpecPolicy::default()).unwrap(); + for cwd in ["main", "main/subdirectory"] { + let mut request = spec(); + request.cwd = cwd.into(); + rejected(request, "inputs.path", ValidationCode::ConflictingPath); + } +} + +#[test] +fn every_resource_rejects_zero_and_exceeding_its_policy_ceiling() { + for field in [ + "cpu_time_us", + "wall_time_us", + "memory_bytes", + "stack_bytes", + "processes", + "open_files", + "stdout_bytes", + "stderr_bytes", + "file_bytes", + "workdir_bytes", + ] { + let mut document = fixture(); + document["limits"][field] = json!(0); + let request = serde_json::from_value(document).unwrap(); + rejected(request, field, ValidationCode::InvalidLimit); + let mut document = fixture(); + document["limits"][field] = json!(u32::MAX); + let request = serde_json::from_value(document).unwrap(); + rejected(request, field, ValidationCode::LimitExceedsPolicy); + } + let mut document = fixture(); + document["limits"]["memory_bytes"] = json!(u64::MAX); + rejected( + serde_json::from_value(document).unwrap(), + "memory_bytes", + ValidationCode::LimitExceedsPolicy, + ); + let mut document = fixture(); + document["limits"]["processes"] = json!(u64::MAX); + assert!(serde_json::from_value::(document).is_err()); +} + +#[test] +fn trusted_policy_can_tighten_limits_and_rejects_inconsistent_caps() { + let limits = ResourceLimits::default(); + let policy = SpecPolicy::with_limits_ceiling(limits.clone()).unwrap(); + let mut request = spec(); + request.limits.cpu_time_us += 1; + assert_eq!( + request.validate(&policy).unwrap_err().code, + ValidationCode::LimitExceedsPolicy + ); + let mut bad = limits; + bad.stack_bytes = bad.memory_bytes + 1; + assert_eq!( + SpecPolicy::with_limits_ceiling(bad).unwrap_err().code, + ValidationCode::InconsistentLimits + ); + let mut request = spec(); + request.limits.file_bytes = request.limits.workdir_bytes + 1; + rejected(request, "file_bytes", ValidationCode::LimitExceedsPolicy); + let mut request = spec(); + request.limits.workdir_bytes = request.limits.file_bytes - 1; + rejected(request, "limits", ValidationCode::InconsistentLimits); +} diff --git a/judge/crates/sandbox/tests/validate_cli.rs b/judge/crates/sandbox/tests/validate_cli.rs new file mode 100644 index 0000000..ae2d152 --- /dev/null +++ b/judge/crates/sandbox/tests/validate_cli.rs @@ -0,0 +1,96 @@ +use kf_sandbox::spec::MAX_SPEC_BYTES; +use serde_json::{Value, json}; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +struct SpecFile { + directory: PathBuf, + path: PathBuf, +} + +impl SpecFile { + fn new(bytes: &[u8]) -> Self { + let directory = std::env::temp_dir().join(format!( + "kf-spec-test-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&directory).unwrap(); + let path = directory.join("spec.json"); + fs::write(&path, bytes).unwrap(); + Self { directory, path } + } + + fn validate(&self) -> Output { + Command::new(env!("CARGO_BIN_EXE_kf-sandbox")) + .arg("validate") + .arg("--spec") + .arg(&self.path) + .output() + .unwrap() + } +} + +impl Drop for SpecFile { + fn drop(&mut self) { + fs::remove_dir_all(&self.directory).unwrap(); + } +} + +fn refused(output: Output, code: &str) -> Value { + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"], code); + error +} + +#[test] +fn valid_request_at_size_boundary_is_checked_without_execution() { + let mut bytes = include_bytes!("fixtures/spec.valid.json").to_vec(); + bytes.resize(MAX_SPEC_BYTES as usize, b' '); + let output = SpecFile::new(&bytes).validate(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + report, + json!({"valid": true, "execution_allowed": false, "run_id": "run-001"}) + ); +} + +#[test] +fn oversized_or_unreadable_spec_is_refused() { + refused( + SpecFile::new(&vec![b' '; MAX_SPEC_BYTES as usize + 1]).validate(), + "spec_too_large", + ); + let file = SpecFile::new(b"{}"); + fs::remove_file(&file.path).unwrap(); + refused(file.validate(), "cannot_read_spec"); +} + +#[test] +fn parse_error_does_not_echo_untrusted_request_contents() { + let file = SpecFile::new(br#"{"profile":"SECRET-UNSUPPORTED-PROFILE"}"#); + let output = file.validate(); + assert!(!String::from_utf8_lossy(&output.stderr).contains("SECRET")); + refused(output, "invalid_spec_json"); +} + +#[test] +fn invalid_limits_produce_field_specific_validation_error() { + let mut document: Value = + serde_json::from_str(include_str!("fixtures/spec.valid.json")).unwrap(); + document["limits"]["memory_bytes"] = json!(0); + let file = SpecFile::new(&serde_json::to_vec(&document).unwrap()); + let report = refused(file.validate(), "invalid_execution_spec"); + assert_eq!( + report["details"], + json!({"field": "memory_bytes", "code": "invalid_limit"}) + ); +}