feat(sandbox): validate bounded local execution specifications

This commit is contained in:
2026-10-05 22:04:59 +08:00
parent 8b8828d136
commit 7ef77dfb34
7 changed files with 794 additions and 0 deletions
+24
View File
@@ -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 中的执行、安全和资源验收。执行结果、限额归因与清理状态的契约将在后续阶段补齐。
+1
View File
@@ -4,3 +4,4 @@
//! Until that work is verified, the CLI cannot execute any user program.
pub mod probe;
pub mod spec;
+55
View File
@@ -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<serde_json::Value>) -> ExitCode {
eprintln!("{}", serde_json::json!({"error": code, "details": details}));
ExitCode::from(2)
}
+337
View File
@@ -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<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Canonical relative directory beneath /work; "." selects /work itself.
pub cwd: String,
#[serde(default)]
pub inputs: Vec<InputArtifact>,
pub stdin_artifact: Option<String>,
#[serde(default)]
pub outputs: Vec<String>,
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<Self, SpecError> {
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<ValidatedSpec, SpecError> {
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::<usize>() > 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))
}
}
+23
View File
@@ -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
}
}
@@ -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::<ExecutionSpec>(document).is_err(),
"{key}"
);
}
let mut document = fixture();
document["limits"]
.as_object_mut()
.unwrap()
.remove("memory_bytes");
assert!(serde_json::from_value::<ExecutionSpec>(document).is_err());
let mut document = fixture();
document["inputs"][0]["writable"] = json!(true);
assert!(serde_json::from_value::<ExecutionSpec>(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::<ExecutionSpec>(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);
}
@@ -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"})
);
}