feat(sandbox): add immutable Python and Java execution profiles
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
"""Image construction data tests; actual publication/execution needs the independent VM."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
MODULE = Path(__file__).resolve().parents[2] / "scripts/build_judging_images.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("judging_image_builder", MODULE)
|
||||||
|
builder = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(builder)
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_uses_wire_path_order_including_dot_and_directory_boundaries(tmp_path):
|
||||||
|
image = tmp_path / "image"
|
||||||
|
image.mkdir()
|
||||||
|
builder.put(image, "usr/lib/module/a.py", b"data")
|
||||||
|
builder.put(image, "usr/lib/module.py", b"module")
|
||||||
|
support = tmp_path / "support"
|
||||||
|
library = support / "usr/lib/ld-linux-x86-64.so.2"
|
||||||
|
library.parent.mkdir(parents=True)
|
||||||
|
library.write_bytes(b"synthetic ordinary loader data")
|
||||||
|
args = SimpleNamespace(support_dir=support, support_manifest_sha256="a" * 64)
|
||||||
|
builder.finish(
|
||||||
|
args,
|
||||||
|
builder.PINS["python313"],
|
||||||
|
image,
|
||||||
|
{"usr/lib/ld-linux-x86-64.so.2": {"kind": "file", "sha256": builder.sha(library)}},
|
||||||
|
)
|
||||||
|
manifest = json.loads((image / "manifest.json").read_bytes())
|
||||||
|
paths = [item["path"] for item in manifest["files"]]
|
||||||
|
assert paths == sorted(paths)
|
||||||
|
assert paths.index("usr/lib/module.py") < paths.index("usr/lib/module/a.py")
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_archive_links_cannot_escape_or_cycle():
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||||
|
for name, target in [("python/outside", "../../secret"), ("python/cycle", "cycle")]:
|
||||||
|
item = tarfile.TarInfo(name)
|
||||||
|
item.type = tarfile.SYMTYPE
|
||||||
|
item.linkname = target
|
||||||
|
archive.addfile(item)
|
||||||
|
buffer.seek(0)
|
||||||
|
with tarfile.open(fileobj=buffer, mode="r:") as archive:
|
||||||
|
members = {item.name: item for item in archive.getmembers()}
|
||||||
|
for name in members:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
builder.member_bytes(archive, members, name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_archive_is_refused_before_output_creation(tmp_path):
|
||||||
|
archive = tmp_path / "public.tar.gz"
|
||||||
|
archive.write_bytes(b"untrusted replacement")
|
||||||
|
output = tmp_path / "image"
|
||||||
|
args = SimpleNamespace(language="python313", archive=archive, output=output)
|
||||||
|
with pytest.raises(ValueError, match="fixed upstream digest"):
|
||||||
|
builder.build(args)
|
||||||
|
assert not output.exists()
|
||||||
@@ -311,7 +311,7 @@ impl CompiledProgram {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn class_name(path: &str) -> bool {
|
pub fn class_name(path: &str) -> bool {
|
||||||
let Some(stem) = path.strip_suffix(".class") else {
|
let Some(stem) = path.strip_suffix(".class") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ kf-contracts.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2 = "=0.10.9"
|
||||||
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event"] }
|
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event"] }
|
||||||
|
|
||||||
# Fixed BPF compilation/installation through safe APIs; libc supplies syscall
|
# Fixed BPF compilation/installation through safe APIs; libc supplies syscall
|
||||||
@@ -29,6 +30,3 @@ workspace = true
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "identity_probe"
|
name = "identity_probe"
|
||||||
test = false
|
test = false
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
sha2 = "=0.10.9"
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Trusted, isolated syntax checker; never evaluates submitted code or writes pyc."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
with open("/work/main.py", "rb") as source:
|
||||||
|
text = source.read(262145)
|
||||||
|
if len(text) > 262144:
|
||||||
|
print("source exceeds syntax checker byte ceiling", file=sys.stderr)
|
||||||
|
return 65
|
||||||
|
# compile(bytes) honors Python's BOM/encoding declaration without
|
||||||
|
# interpreting any top-level statement or importing user modules.
|
||||||
|
compile(text, "/work/main.py", "exec", dont_inherit=True)
|
||||||
|
except (SyntaxError, UnicodeError, ValueError) as error:
|
||||||
|
# Source belongs to the submitting user. Hidden test input is never
|
||||||
|
# supplied to this stage; diagnostic capture remains OS bounded.
|
||||||
|
print(f"{type(error).__name__}: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
print("trusted syntax checker input unavailable", file=sys.stderr)
|
||||||
|
return 70
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -133,6 +133,7 @@ fn matrix_case(case: &str) -> io::Result<serde_json::Value> {
|
|||||||
ExecutionProfile::Native => "native",
|
ExecutionProfile::Native => "native",
|
||||||
ExecutionProfile::CompileC => "compile_c",
|
ExecutionProfile::CompileC => "compile_c",
|
||||||
ExecutionProfile::CompileCpp => "compile_cpp",
|
ExecutionProfile::CompileCpp => "compile_cpp",
|
||||||
|
_ => return Err(io::Error::other("GNU isolation probe profile")),
|
||||||
};
|
};
|
||||||
if profile == ExecutionProfile::Native {
|
if profile == ExecutionProfile::Native {
|
||||||
request.argv.extend([mode.into(), label.into()]);
|
request.argv.extend([mode.into(), label.into()]);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! Outputs are resolved only through a verified, frozen target's work FD.
|
//! Outputs are resolved only through a verified, frozen target's work FD.
|
||||||
//! Snapshotting is not publication, input mounting, stdio wiring or an exec permit.
|
//! Snapshotting is not publication, input mounting, stdio wiring or an exec permit.
|
||||||
|
|
||||||
use crate::spec::{ValidatedSpec, path};
|
use crate::spec::{ValidatedSpec, artifact_path};
|
||||||
use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||||
use rustix::fs::{FileType, MemfdFlags, Mode, OFlags, ResolveFlags, SealFlags, Stat};
|
use rustix::fs::{FileType, MemfdFlags, Mode, OFlags, ResolveFlags, SealFlags, Stat};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
@@ -215,6 +215,7 @@ impl ArtifactRoot {
|
|||||||
pub(crate) struct OutputPlan {
|
pub(crate) struct OutputPlan {
|
||||||
paths: Vec<String>,
|
paths: Vec<String>,
|
||||||
budget: SnapshotBudget,
|
budget: SnapshotBudget,
|
||||||
|
java_classes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutputPlan {
|
impl OutputPlan {
|
||||||
@@ -222,6 +223,7 @@ impl OutputPlan {
|
|||||||
Self {
|
Self {
|
||||||
paths: spec.spec().outputs.clone(),
|
paths: spec.spec().outputs.clone(),
|
||||||
budget: SnapshotBudget::for_spec(spec),
|
budget: SnapshotBudget::for_spec(spec),
|
||||||
|
java_classes: spec.spec().profile == crate::spec::ExecutionProfile::JavaCompile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,12 +233,43 @@ impl OutputPlan {
|
|||||||
{
|
{
|
||||||
return Err(refused("invalid output directory or count"));
|
return Err(refused("invalid output directory or count"));
|
||||||
}
|
}
|
||||||
|
let paths = if self.java_classes {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
let mut count = 0;
|
||||||
|
// The entire group is frozen and drained. Discover only bounded,
|
||||||
|
// flat compiler outputs through the held private work directory.
|
||||||
|
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", work.as_raw_fd()))? {
|
||||||
|
count += 1;
|
||||||
|
if count > kf_contracts::judging_v2::MAX_CLASS_FILES + 1 {
|
||||||
|
return Err(refused("class export count"));
|
||||||
|
}
|
||||||
|
let name = entry?
|
||||||
|
.file_name()
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| refused("class export name"))?;
|
||||||
|
if name == "Main.java" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !kf_contracts::judging_v2::class_name(&name) {
|
||||||
|
return Err(refused("class export path/type"));
|
||||||
|
}
|
||||||
|
paths.push(name);
|
||||||
|
}
|
||||||
|
paths.sort();
|
||||||
|
paths
|
||||||
|
} else {
|
||||||
|
self.paths.clone()
|
||||||
|
};
|
||||||
let mut budget = self.budget;
|
let mut budget = self.budget;
|
||||||
let mut files = Vec::with_capacity(self.paths.len());
|
let mut files = Vec::with_capacity(paths.len());
|
||||||
for path in &self.paths {
|
for path in &paths {
|
||||||
|
let file = snapshot(work, path, &mut budget, None, false)?;
|
||||||
|
if self.java_classes && file.bytes() == 0 {
|
||||||
|
return Err(refused("empty class export"));
|
||||||
|
}
|
||||||
files.push(OutputSnapshot {
|
files.push(OutputSnapshot {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
file: snapshot(work, path, &mut budget, None, false)?,
|
file,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Errors drop every partial FD; there is no partially published batch.
|
// Errors drop every partial FD; there is no partially published batch.
|
||||||
@@ -309,7 +342,7 @@ fn snapshot(
|
|||||||
owner: Option<u32>,
|
owner: Option<u32>,
|
||||||
executable: bool,
|
executable: bool,
|
||||||
) -> io::Result<SnapshotFile> {
|
) -> io::Result<SnapshotFile> {
|
||||||
path(relative, "artifact").map_err(|_| refused("noncanonical artifact path"))?;
|
artifact_path(relative, "artifact").map_err(|_| refused("noncanonical artifact path"))?;
|
||||||
// Atomic containment; reject unsupported openat2 or races, no fallback or
|
// Atomic containment; reject unsupported openat2 or races, no fallback or
|
||||||
// retry. O_PATH inspects special files without opening their device/pipe.
|
// retry. O_PATH inspects special files without opening their device/pipe.
|
||||||
let fd = rustix::fs::openat2(
|
let fd = rustix::fs::openat2(
|
||||||
|
|||||||
@@ -154,11 +154,16 @@ pub(crate) fn authorize(config: &ValidatedRuntimeConfig) -> io::Result<()> {
|
|||||||
if st.st_uid != 0 || st.st_gid != 0 || st.st_mode & 0o7022 != 0 {
|
if st.st_uid != 0 || st.st_gid != 0 || st.st_mode & 0o7022 != 0 {
|
||||||
return Err(invalid("image manifest ownership"));
|
return Err(invalid("image manifest ownership"));
|
||||||
}
|
}
|
||||||
let m: serde_json::Value = serde_json::from_slice(&read_pinned(manifest, 8192)?)
|
let bytes = read_pinned(manifest, crate::image::MAX_CATALOG_BYTES)?;
|
||||||
.map_err(|_| invalid("image manifest JSON"))?;
|
if let Some(identity) = &c.runtime_identity {
|
||||||
if m != serde_json::json!({"image_kind":"linux_gnu_cxx_x86_64_v1","architecture":"x86_64","revision":1})
|
crate::image::verify(&image, identity, &bytes)?;
|
||||||
{
|
} else {
|
||||||
return Err(invalid("unsupported image manifest"));
|
let m: serde_json::Value =
|
||||||
|
serde_json::from_slice(&bytes).map_err(|_| invalid("image manifest JSON"))?;
|
||||||
|
if m != serde_json::json!({"image_kind":"linux_gnu_cxx_x86_64_v1","architecture":"x86_64","revision":1})
|
||||||
|
{
|
||||||
|
return Err(invalid("unsupported image manifest"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let own = std::fs::read_to_string("/proc/self/cgroup")?;
|
let own = std::fs::read_to_string("/proc/self/cgroup")?;
|
||||||
let path = own
|
let path = own
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! Root-owned, immutable directory images pinned by their complete catalog.
|
||||||
|
//! All descriptor walking, ownership and byte hashing stays in Sandbox.
|
||||||
|
use kf_contracts::{
|
||||||
|
judging::{Digest, Label},
|
||||||
|
judging_v2::{ImageKind, RuntimeIdentity, RuntimeVersion},
|
||||||
|
};
|
||||||
|
use rustix::{
|
||||||
|
fd::{AsRawFd, OwnedFd},
|
||||||
|
fs::{FileType, Mode, OFlags},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs::File,
|
||||||
|
io::{self, Read},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
||||||
|
const MAX_FILES: usize = 4096;
|
||||||
|
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ImageFile {
|
||||||
|
pub path: String,
|
||||||
|
pub bytes: u64,
|
||||||
|
pub sha256: Digest,
|
||||||
|
pub executable: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ImageCatalog {
|
||||||
|
pub format_version: u16,
|
||||||
|
pub architecture: String,
|
||||||
|
pub image_kind: ImageKind,
|
||||||
|
pub image_revision: u32,
|
||||||
|
pub toolchain_id: Label,
|
||||||
|
pub runtime_version: RuntimeVersion,
|
||||||
|
pub directories: Vec<String>,
|
||||||
|
pub files: Vec<ImageFile>,
|
||||||
|
}
|
||||||
|
fn refused() -> io::Error {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
"image catalog/integrity refused",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub fn catalog_digest(data: &[u8]) -> Digest {
|
||||||
|
format!("{:x}", Sha256::digest(data))
|
||||||
|
.try_into()
|
||||||
|
.expect("SHA256")
|
||||||
|
}
|
||||||
|
impl ImageCatalog {
|
||||||
|
pub fn validate(&self, identity: &RuntimeIdentity) -> io::Result<()> {
|
||||||
|
let language = match self.image_kind {
|
||||||
|
ImageKind::GnuCxx => kf_contracts::judging_v2::Language::C17,
|
||||||
|
ImageKind::CPython313 => kf_contracts::judging_v2::Language::Python313,
|
||||||
|
ImageKind::OpenJdk21 => kf_contracts::judging_v2::Language::Java21,
|
||||||
|
};
|
||||||
|
identity
|
||||||
|
.validate_for_language(language)
|
||||||
|
.map_err(|_| refused())?;
|
||||||
|
if self.format_version != 2
|
||||||
|
|| self.architecture != "x86_64"
|
||||||
|
|| self.image_kind != identity.image_kind
|
||||||
|
|| self.image_revision != identity.image_revision
|
||||||
|
|| self.toolchain_id != identity.toolchain_id
|
||||||
|
|| self.runtime_version != identity.runtime_version
|
||||||
|
|| self.files.is_empty()
|
||||||
|
|| self.files.len() > MAX_FILES
|
||||||
|
|| self.directories.len() > MAX_FILES
|
||||||
|
|| self.directories.windows(2).any(|v| v[0] >= v[1])
|
||||||
|
|| self.files.windows(2).any(|v| v[0].path >= v[1].path)
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let mut total = 0_u64;
|
||||||
|
for name in self
|
||||||
|
.directories
|
||||||
|
.iter()
|
||||||
|
.chain(self.files.iter().map(|v| &v.path))
|
||||||
|
{
|
||||||
|
crate::spec::path(name, "image").map_err(|_| refused())?;
|
||||||
|
if name == "manifest.json"
|
||||||
|
|| name
|
||||||
|
.split('/')
|
||||||
|
.any(|v| matches!(v, "site-packages" | "__pycache__"))
|
||||||
|
|| name.ends_with(".pth")
|
||||||
|
|| name.ends_with("/sitecustomize.py")
|
||||||
|
|| name.ends_with("/usercustomize.py")
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for file in &self.files {
|
||||||
|
total = total
|
||||||
|
.checked_add(file.bytes)
|
||||||
|
.filter(|v| *v <= MAX_IMAGE_BYTES)
|
||||||
|
.ok_or_else(refused)?;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.any(|f| self.directories.contains(&f.path))
|
||||||
|
|| ["dev", "proc", "tmp", "work"]
|
||||||
|
.iter()
|
||||||
|
.any(|p| !self.directories.iter().any(|d| d == p))
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let required: &[&str] = match self.image_kind {
|
||||||
|
ImageKind::CPython313 => &[
|
||||||
|
"usr/bin/python3.13",
|
||||||
|
"usr/lib/kf/python_check.py",
|
||||||
|
"usr/lib/python3.13/os.py",
|
||||||
|
"lib64/ld-linux-x86-64.so.2",
|
||||||
|
],
|
||||||
|
ImageKind::OpenJdk21 => &[
|
||||||
|
"usr/lib/jvm/bin/java",
|
||||||
|
"usr/lib/jvm/bin/javac",
|
||||||
|
"usr/lib/jvm/lib/modules",
|
||||||
|
"usr/lib/jvm/lib/ct.sym",
|
||||||
|
"lib64/ld-linux-x86-64.so.2",
|
||||||
|
],
|
||||||
|
ImageKind::GnuCxx => &["usr/bin/gcc", "usr/bin/g++", "lib64/ld-linux-x86-64.so.2"],
|
||||||
|
};
|
||||||
|
if required
|
||||||
|
.iter()
|
||||||
|
.any(|p| !self.files.iter().any(|f| f.path == *p && f.bytes != 0))
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every inode is checked, including unexpected entries and empty directories.
|
||||||
|
/// No symlinks, hard links, writable files, special files or mount crossings.
|
||||||
|
pub(crate) fn verify(image: &OwnedFd, identity: &RuntimeIdentity, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
if bytes.len() as u64 > MAX_CATALOG_BYTES || catalog_digest(bytes) != identity.image_digest {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let catalog: ImageCatalog = serde_json::from_slice(bytes).map_err(|_| refused())?;
|
||||||
|
catalog.validate(identity)?;
|
||||||
|
let mut files: BTreeMap<_, _> = catalog.files.iter().map(|f| (f.path.as_str(), f)).collect();
|
||||||
|
let mut dirs: std::collections::BTreeSet<_> =
|
||||||
|
catalog.directories.iter().map(String::as_str).collect();
|
||||||
|
let root = rustix::fs::fstat(image)?;
|
||||||
|
if root.st_uid != 0 || root.st_gid != 0 || root.st_mode & 0o7777 != 0o555 {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
walk(image, "", root.st_dev, &mut files, &mut dirs)?;
|
||||||
|
if !files.is_empty() || !dirs.is_empty() {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn walk<'a>(
|
||||||
|
directory: &OwnedFd,
|
||||||
|
prefix: &str,
|
||||||
|
device: u64,
|
||||||
|
files: &mut BTreeMap<&'a str, &'a ImageFile>,
|
||||||
|
dirs: &mut std::collections::BTreeSet<&'a str>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
if prefix.split('/').count() > 32 {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
for entry in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? {
|
||||||
|
let name = entry?.file_name();
|
||||||
|
let name = name.to_str().ok_or_else(refused)?;
|
||||||
|
let path = if prefix.is_empty() {
|
||||||
|
name.into()
|
||||||
|
} else {
|
||||||
|
format!("{prefix}/{name}")
|
||||||
|
};
|
||||||
|
let fd = rustix::fs::openat(
|
||||||
|
directory,
|
||||||
|
name,
|
||||||
|
OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?;
|
||||||
|
let st = rustix::fs::fstat(&fd)?;
|
||||||
|
if st.st_uid != 0 || st.st_gid != 0 || st.st_dev != device {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
match FileType::from_raw_mode(st.st_mode) {
|
||||||
|
FileType::Directory => {
|
||||||
|
if st.st_mode & 0o7777 != 0o555 || !dirs.remove(path.as_str()) {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let child = rustix::fs::openat(
|
||||||
|
directory,
|
||||||
|
name,
|
||||||
|
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
||||||
|
Mode::empty(),
|
||||||
|
)?;
|
||||||
|
let after = rustix::fs::fstat(&child)?;
|
||||||
|
if st.st_dev != after.st_dev || st.st_ino != after.st_ino {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
walk(&child, &path, device, files, dirs)?;
|
||||||
|
}
|
||||||
|
FileType::RegularFile => {
|
||||||
|
if st.st_nlink != 1 || st.st_size < 0 {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
if path == "manifest.json" {
|
||||||
|
if st.st_mode & 0o7777 != 0o444 {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let expected = files.remove(path.as_str()).ok_or_else(refused)?;
|
||||||
|
if st.st_size as u64 != expected.bytes
|
||||||
|
|| st.st_mode & 0o7777 != if expected.executable { 0o555 } else { 0o444 }
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let read = rustix::fs::open(
|
||||||
|
format!("/proc/self/fd/{}", fd.as_raw_fd()),
|
||||||
|
OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NONBLOCK | OFlags::NOCTTY,
|
||||||
|
Mode::empty(),
|
||||||
|
)?;
|
||||||
|
let after = rustix::fs::fstat(&read)?;
|
||||||
|
if st.st_dev != after.st_dev || st.st_ino != after.st_ino {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let mut reader = File::from(read);
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
let mut count = 0_u64;
|
||||||
|
let mut buffer = [0_u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = reader.read(&mut buffer)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
count += n as u64;
|
||||||
|
if count > expected.bytes {
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
hash.update(&buffer[..n]);
|
||||||
|
}
|
||||||
|
if count != expected.bytes
|
||||||
|
|| format!("{:x}", hash.finalize()) != expected.sha256.as_str()
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
let final_stat = reader.metadata()?;
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
if final_stat.dev() != st.st_dev
|
||||||
|
|| final_stat.ino() != st.st_ino
|
||||||
|
|| final_stat.nlink() != 1
|
||||||
|
|| final_stat.len() != expected.bytes
|
||||||
|
|| final_stat.mode() & 0o7777 != st.st_mode & 0o7777
|
||||||
|
|| final_stat.uid() != 0
|
||||||
|
|| final_stat.gid() != 0
|
||||||
|
{
|
||||||
|
return Err(refused());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(refused()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Fixed pre-exec read-only bindings. No task code exists during this stage.
|
//! Fixed pre-exec read-only bindings. No task code exists during this stage.
|
||||||
use crate::spec::path;
|
use crate::spec::artifact_path;
|
||||||
use rustix::fd::OwnedFd;
|
use rustix::fd::OwnedFd;
|
||||||
use rustix::fs::{FileType, Mode, OFlags, ResolveFlags, SealFlags, StatVfsMountFlags};
|
use rustix::fs::{FileType, Mode, OFlags, ResolveFlags, SealFlags, StatVfsMountFlags};
|
||||||
use rustix::mount::MountFlags;
|
use rustix::mount::MountFlags;
|
||||||
@@ -37,7 +37,7 @@ pub(crate) fn bind(relative: &str, fd: &OwnedFd, bytes: u64, executable: bool) -
|
|||||||
if rustix::process::getpid().as_raw_pid() != 1 || !rustix::process::geteuid().is_root() {
|
if rustix::process::getpid().as_raw_pid() != 1 || !rustix::process::geteuid().is_root() {
|
||||||
return Err(denied("input binding requires privileged namespace init"));
|
return Err(denied("input binding requires privileged namespace init"));
|
||||||
}
|
}
|
||||||
path(relative, "input").map_err(|_| denied("input binding path"))?;
|
artifact_path(relative, "input").map_err(|_| denied("input binding path"))?;
|
||||||
let source = rustix::fs::fstat(fd)?;
|
let source = rustix::fs::fstat(fd)?;
|
||||||
let seals =
|
let seals =
|
||||||
SealFlags::WRITE | SealFlags::GROW | SealFlags::SHRINK | SealFlags::EXEC | SealFlags::SEAL;
|
SealFlags::WRITE | SealFlags::GROW | SealFlags::SHRINK | SealFlags::EXEC | SealFlags::SEAL;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
|||||||
thread_required,
|
thread_required,
|
||||||
)?],
|
)?],
|
||||||
];
|
];
|
||||||
if profile != ExecutionProfile::Native {
|
if profile.gnu_compiler() {
|
||||||
clones.push(vec![condition(
|
clones.push(vec![condition(
|
||||||
0,
|
0,
|
||||||
SeccompCmpOp::Eq,
|
SeccompCmpOp::Eq,
|
||||||
@@ -252,11 +252,20 @@ pub(crate) fn compile(profile: ExecutionProfile) -> io::Result<BpfProgram> {
|
|||||||
condition(2, SeccompCmpOp::MaskedEq(!flags), 0)?,
|
condition(2, SeccompCmpOp::MaskedEq(!flags), 0)?,
|
||||||
]);
|
]);
|
||||||
allowed.insert(libc::SYS_fcntl, rules(fcntl)?);
|
allowed.insert(libc::SYS_fcntl, rules(fcntl)?);
|
||||||
// Only non-mutating tty/pipe queries; no terminal injection/device control.
|
// Fixed tty/pipe queries. CPython's actual fopen path also needs FIOCLEX:
|
||||||
|
// it only sets close-on-exec on a descriptor already owned by this task.
|
||||||
|
// FIONCLEX, terminal injection and arbitrary device ioctls stay denied.
|
||||||
|
let mut ioctls = vec![libc::TCGETS, libc::FIONREAD];
|
||||||
|
if matches!(
|
||||||
|
profile,
|
||||||
|
ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun
|
||||||
|
) {
|
||||||
|
ioctls.extend([libc::TCGETS2, libc::FIOCLEX]);
|
||||||
|
}
|
||||||
allowed.insert(
|
allowed.insert(
|
||||||
libc::SYS_ioctl,
|
libc::SYS_ioctl,
|
||||||
rules(
|
rules(
|
||||||
[libc::TCGETS, libc::FIONREAD]
|
ioctls
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, op)?]))
|
.map(|op| Ok(vec![condition(1, SeccompCmpOp::Eq, op)?]))
|
||||||
.collect::<io::Result<_>>()?,
|
.collect::<io::Result<_>>()?,
|
||||||
@@ -403,6 +412,15 @@ pub(crate) fn install(profile: ExecutionProfile) -> io::Result<()> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
const PROFILES: [ExecutionProfile; 7] = [
|
||||||
|
ExecutionProfile::Native,
|
||||||
|
ExecutionProfile::CompileC,
|
||||||
|
ExecutionProfile::CompileCpp,
|
||||||
|
ExecutionProfile::PythonCheck,
|
||||||
|
ExecutionProfile::PythonRun,
|
||||||
|
ExecutionProfile::JavaCompile,
|
||||||
|
ExecutionProfile::JavaRun,
|
||||||
|
];
|
||||||
// Evaluate generated classic BPF against synthetic seccomp_data; this is
|
// Evaluate generated classic BPF against synthetic seccomp_data; this is
|
||||||
// an ordinary policy regression, not evidence of kernel installation.
|
// an ordinary policy regression, not evidence of kernel installation.
|
||||||
fn evaluate(p: &[sock_filter], arch: u32, nr: u32, args: [u64; 6]) -> u32 {
|
fn evaluate(p: &[sock_filter], arch: u32, nr: u32, args: [u64; 6]) -> u32 {
|
||||||
@@ -435,11 +453,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn abi_unknown_calls_and_clone_flags_fail_closed() {
|
fn abi_unknown_calls_and_clone_flags_fail_closed() {
|
||||||
for profile in [
|
for profile in PROFILES {
|
||||||
ExecutionProfile::Native,
|
|
||||||
ExecutionProfile::CompileC,
|
|
||||||
ExecutionProfile::CompileCpp,
|
|
||||||
] {
|
|
||||||
let p = compile(profile).unwrap();
|
let p = compile(profile).unwrap();
|
||||||
let deny = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32;
|
let deny = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -476,16 +490,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn compile_profile_exception_does_not_widen_native() {
|
fn compile_profile_exception_does_not_widen_native() {
|
||||||
let flags = (libc::CLONE_VM | libc::CLONE_VFORK | libc::SIGCHLD) as u64;
|
let flags = (libc::CLONE_VM | libc::CLONE_VFORK | libc::SIGCHLD) as u64;
|
||||||
for profile in [
|
for profile in PROFILES {
|
||||||
ExecutionProfile::Native,
|
|
||||||
ExecutionProfile::CompileC,
|
|
||||||
ExecutionProfile::CompileCpp,
|
|
||||||
] {
|
|
||||||
let p = compile(profile).unwrap();
|
let p = compile(profile).unwrap();
|
||||||
let expected = if profile == ExecutionProfile::Native {
|
let expected = if profile.gnu_compiler() {
|
||||||
libc::SECCOMP_RET_ERRNO | libc::EPERM as u32
|
|
||||||
} else {
|
|
||||||
libc::SECCOMP_RET_ALLOW
|
libc::SECCOMP_RET_ALLOW
|
||||||
|
} else {
|
||||||
|
libc::SECCOMP_RET_ERRNO | libc::EPERM as u32
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
evaluate(
|
evaluate(
|
||||||
@@ -505,11 +515,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dangerous_calls_and_pointer_or_high_bit_argument_bypasses_are_denied() {
|
fn dangerous_calls_and_pointer_or_high_bit_argument_bypasses_are_denied() {
|
||||||
for profile in [
|
for profile in PROFILES {
|
||||||
ExecutionProfile::Native,
|
|
||||||
ExecutionProfile::CompileC,
|
|
||||||
ExecutionProfile::CompileCpp,
|
|
||||||
] {
|
|
||||||
let p = compile(profile).unwrap();
|
let p = compile(profile).unwrap();
|
||||||
let deny = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32;
|
let deny = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32;
|
||||||
for nr in [
|
for nr in [
|
||||||
@@ -574,4 +580,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn python_descriptor_compatibility_never_grants_other_ioctl_operations() {
|
||||||
|
for profile in PROFILES {
|
||||||
|
let program = compile(profile).unwrap();
|
||||||
|
let denied = libc::SECCOMP_RET_ERRNO | libc::EPERM as u32;
|
||||||
|
let expected = if matches!(
|
||||||
|
profile,
|
||||||
|
ExecutionProfile::PythonCheck | ExecutionProfile::PythonRun
|
||||||
|
) {
|
||||||
|
libc::SECCOMP_RET_ALLOW
|
||||||
|
} else {
|
||||||
|
denied
|
||||||
|
};
|
||||||
|
for request in [libc::FIOCLEX, libc::TCGETS2] {
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(
|
||||||
|
&program,
|
||||||
|
AUDIT_X86_64,
|
||||||
|
libc::SYS_ioctl as u32,
|
||||||
|
[3, request, 0, 0, 0, 0]
|
||||||
|
),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(
|
||||||
|
&program,
|
||||||
|
AUDIT_X86_64,
|
||||||
|
libc::SYS_ioctl as u32,
|
||||||
|
[3, (1 << 32) | request, 0, 0, 0, 0]
|
||||||
|
),
|
||||||
|
denied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for request in [libc::FIONCLEX, libc::TIOCSTI] {
|
||||||
|
assert_eq!(
|
||||||
|
evaluate(
|
||||||
|
&program,
|
||||||
|
AUDIT_X86_64,
|
||||||
|
libc::SYS_ioctl as u32,
|
||||||
|
[3, request, 0, 0, 0, 0]
|
||||||
|
),
|
||||||
|
denied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,10 +130,11 @@ impl WireMessage for NamespaceCommand {
|
|||||||
}
|
}
|
||||||
.valid(),
|
.valid(),
|
||||||
Self::PrepareInputs { count, bytes } => {
|
Self::PrepareInputs { count, bytes } => {
|
||||||
*count <= 64 && *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
|
*count <= kf_contracts::judging_v2::MAX_CLASS_FILES as u32
|
||||||
|
&& *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
|
||||||
}
|
}
|
||||||
Self::AttachInput { path, bytes, .. } => {
|
Self::AttachInput { path, bytes, .. } => {
|
||||||
crate::spec::path(path, "input").is_ok()
|
crate::spec::artifact_path(path, "input").is_ok()
|
||||||
&& *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
|
&& *bytes <= crate::artifacts::MAX_SNAPSHOT_BYTES
|
||||||
}
|
}
|
||||||
Self::PrepareIdentity { uid, gid } => crate::isolation::identity::valid(*uid, *gid),
|
Self::PrepareIdentity { uid, gid } => crate::isolation::identity::valid(*uid, *gid),
|
||||||
@@ -518,6 +519,53 @@ impl Write for FixedBuffer {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn class_bindings_allow_flat_inner_names_and_a_bounded_complete_set() {
|
||||||
|
for name in [
|
||||||
|
"Main.class",
|
||||||
|
"Main$1.class",
|
||||||
|
"Main$Inner.class",
|
||||||
|
"Helper.class",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
NamespaceCommand::AttachInput {
|
||||||
|
path: name.into(),
|
||||||
|
bytes: 1,
|
||||||
|
executable: false,
|
||||||
|
}
|
||||||
|
.valid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for name in [
|
||||||
|
"../Main.class",
|
||||||
|
"pkg/Main$Inner.class",
|
||||||
|
"Main$1.class/child",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!NamespaceCommand::AttachInput {
|
||||||
|
path: name.into(),
|
||||||
|
bytes: 1,
|
||||||
|
executable: false,
|
||||||
|
}
|
||||||
|
.valid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
NamespaceCommand::PrepareInputs {
|
||||||
|
count: 256,
|
||||||
|
bytes: 1024
|
||||||
|
}
|
||||||
|
.valid()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!NamespaceCommand::PrepareInputs {
|
||||||
|
count: 257,
|
||||||
|
bytes: 1024
|
||||||
|
}
|
||||||
|
.valid()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn pair() -> (ControlChannel, ControlChannel) {
|
fn pair() -> (ControlChannel, ControlChannel) {
|
||||||
let (a, b) = UnixStream::pair().unwrap();
|
let (a, b) = UnixStream::pair().unwrap();
|
||||||
let peer = ExpectedPeer {
|
let peer = ExpectedPeer {
|
||||||
|
|||||||
@@ -510,13 +510,11 @@ pub(crate) fn execute(
|
|||||||
}
|
}
|
||||||
let stdout = SnapshotFile::from_capture(pump.stdout());
|
let stdout = SnapshotFile::from_capture(pump.stdout());
|
||||||
let stderr = SnapshotFile::from_capture(pump.stderr());
|
let stderr = SnapshotFile::from_capture(pump.stderr());
|
||||||
let compile_failed = matches!(
|
let compile_failed = spec.spec().profile.prepares_program()
|
||||||
spec.spec().profile,
|
&& (main != Some(Termination::Exited { code: 0 })
|
||||||
crate::spec::ExecutionProfile::CompileC | crate::spec::ExecutionProfile::CompileCpp
|
|| cancelled.is_some()
|
||||||
) && (main != Some(Termination::Exited { code: 0 })
|
|| !limits.is_empty()
|
||||||
|| cancelled.is_some()
|
|| !errors.is_empty());
|
||||||
|| !limits.is_empty()
|
|
||||||
|| !errors.is_empty());
|
|
||||||
let outputs = if compile_failed || errors.iter().any(|e| e.stage == ExecutionStage::Cleanup) {
|
let outputs = if compile_failed || errors.iter().any(|e| e.stage == ExecutionStage::Cleanup) {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
//! Linux execution boundary: policy, kernel gates, bounded supervision and recovery.
|
//! Linux execution boundary: policy, kernel gates, bounded supervision and recovery.
|
||||||
//! Ordinary operator/Worker entries require trusted authority and active isolation.
|
//! Ordinary operator/Worker entries require trusted authority and active isolation.
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub mod image;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
pub mod outcome;
|
pub mod outcome;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod profiles;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod spec;
|
pub mod spec;
|
||||||
|
|
||||||
|
|||||||
@@ -281,14 +281,28 @@ impl ExecutionReport {
|
|||||||
check_capture(&self.stderr, limits.stderr_bytes)?;
|
check_capture(&self.stderr, limits.stderr_bytes)?;
|
||||||
let mut artifact_paths = std::collections::BTreeSet::new();
|
let mut artifact_paths = std::collections::BTreeSet::new();
|
||||||
let mut artifact_bytes = 0_u64;
|
let mut artifact_bytes = 0_u64;
|
||||||
|
let java_classes = spec.spec().profile == crate::spec::ExecutionProfile::JavaCompile;
|
||||||
|
if self.artifacts.len()
|
||||||
|
> if java_classes {
|
||||||
|
kf_contracts::judging_v2::MAX_CLASS_FILES
|
||||||
|
} else {
|
||||||
|
16
|
||||||
|
}
|
||||||
|
|| (java_classes && self.artifacts.windows(2).any(|v| v[0].path >= v[1].path))
|
||||||
|
{
|
||||||
|
return Err(ReportError("artifact_count_or_order"));
|
||||||
|
}
|
||||||
for artifact in &self.artifacts {
|
for artifact in &self.artifacts {
|
||||||
artifact_bytes = artifact_bytes
|
artifact_bytes = artifact_bytes
|
||||||
.checked_add(artifact.bytes)
|
.checked_add(artifact.bytes)
|
||||||
.ok_or(ReportError("artifacts"))?;
|
.ok_or(ReportError("artifacts"))?;
|
||||||
if !spec.spec().outputs.contains(&artifact.path)
|
if !(spec.spec().outputs.contains(&artifact.path)
|
||||||
|
|| (spec.spec().profile == crate::spec::ExecutionProfile::JavaCompile
|
||||||
|
&& kf_contracts::judging_v2::class_name(&artifact.path)))
|
||||||
|| !artifact_paths.insert(artifact.path.as_str())
|
|| !artifact_paths.insert(artifact.path.as_str())
|
||||||
|| !artifact_id(&artifact.artifact_id)
|
|| !artifact_id(&artifact.artifact_id)
|
||||||
|| artifact.bytes > limits.file_bytes
|
|| artifact.bytes > limits.file_bytes
|
||||||
|
|| (java_classes && artifact.bytes == 0)
|
||||||
|| artifact_bytes > limits.workdir_bytes
|
|| artifact_bytes > limits.workdir_bytes
|
||||||
{
|
{
|
||||||
return Err(ReportError("artifacts"));
|
return Err(ReportError("artifacts"));
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Closed interpreter/JDK commands. Operator image selection remains separate;
|
||||||
|
//! validation alone neither acquires authority nor proves OS isolation.
|
||||||
|
use crate::spec::{ExecutionProfile as P, ExecutionSpec, ResourceLimits};
|
||||||
|
use kf_contracts::{judging::RunLimits, judging_v2 as v2};
|
||||||
|
|
||||||
|
fn contract_limits(r: &ResourceLimits) -> RunLimits {
|
||||||
|
RunLimits {
|
||||||
|
cpu_time_us: r.cpu_time_us,
|
||||||
|
wall_time_us: r.wall_time_us,
|
||||||
|
memory_bytes: r.memory_bytes,
|
||||||
|
stack_bytes: r.stack_bytes,
|
||||||
|
processes: r.processes,
|
||||||
|
open_files: r.open_files,
|
||||||
|
stdout_bytes: r.stdout_bytes,
|
||||||
|
stderr_bytes: r.stderr_bytes,
|
||||||
|
file_bytes: r.file_bytes,
|
||||||
|
workdir_bytes: r.workdir_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit heap, native overhead and fixed CPU/thread ergonomics. The kernel
|
||||||
|
/// still bounds the complete JVM; printed OOM text is never kernel evidence.
|
||||||
|
fn jvm_options(limits: &ResourceLimits) -> Result<Vec<String>, &'static str> {
|
||||||
|
let heap = v2::jvm_heap_bytes(&contract_limits(limits))? / (1024 * 1024);
|
||||||
|
Ok(vec![
|
||||||
|
"-XX:+UseSerialGC".into(),
|
||||||
|
"-XX:ActiveProcessorCount=1".into(),
|
||||||
|
"-XX:-UseContainerSupport".into(),
|
||||||
|
"-XX:CICompilerCount=2".into(),
|
||||||
|
"-XX:-UsePerfData".into(),
|
||||||
|
"-XX:+DisableAttachMechanism".into(),
|
||||||
|
"-XX:MaxMetaspaceSize=64m".into(),
|
||||||
|
"-XX:CompressedClassSpaceSize=32m".into(),
|
||||||
|
"-XX:ReservedCodeCacheSize=32m".into(),
|
||||||
|
"-Xss256k".into(),
|
||||||
|
"-Xms16m".into(),
|
||||||
|
format!("-Xmx{heap}m"),
|
||||||
|
"-Djava.io.tmpdir=/tmp".into(),
|
||||||
|
"-Dfile.encoding=UTF-8".into(),
|
||||||
|
"-Duser.language=en".into(),
|
||||||
|
"-Duser.country=US".into(),
|
||||||
|
"-Duser.timezone=UTC".into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn argv(profile: P, limits: &ResourceLimits) -> Result<Vec<String>, &'static str> {
|
||||||
|
let (language, preparing) = match profile {
|
||||||
|
P::PythonCheck => (v2::Language::Python313, true),
|
||||||
|
P::PythonRun => (v2::Language::Python313, false),
|
||||||
|
P::JavaCompile => (v2::Language::Java21, true),
|
||||||
|
P::JavaRun => (v2::Language::Java21, false),
|
||||||
|
_ => return Err("profile has no managed command"),
|
||||||
|
};
|
||||||
|
v2::validate_startup_budget(language, &contract_limits(limits), preparing)?;
|
||||||
|
match profile {
|
||||||
|
P::PythonCheck | P::PythonRun => Ok([
|
||||||
|
"/usr/bin/python3.13",
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
|
"-B",
|
||||||
|
if profile == P::PythonCheck {
|
||||||
|
"/usr/lib/kf/python_check.py"
|
||||||
|
} else {
|
||||||
|
"/work/main.py"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.map(String::from)
|
||||||
|
.to_vec()),
|
||||||
|
P::JavaCompile => {
|
||||||
|
let mut args = vec!["/usr/lib/jvm/bin/javac".into()];
|
||||||
|
args.extend(jvm_options(limits)?.into_iter().map(|a| format!("-J{a}")));
|
||||||
|
args.extend(
|
||||||
|
[
|
||||||
|
"-proc:none",
|
||||||
|
"-implicit:none",
|
||||||
|
"-encoding",
|
||||||
|
"UTF-8",
|
||||||
|
"--release",
|
||||||
|
"21",
|
||||||
|
"-classpath",
|
||||||
|
"/work",
|
||||||
|
"-sourcepath",
|
||||||
|
"/work",
|
||||||
|
"-d",
|
||||||
|
"/work",
|
||||||
|
"/work/Main.java",
|
||||||
|
]
|
||||||
|
.map(String::from),
|
||||||
|
);
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
P::JavaRun => {
|
||||||
|
let mut args = vec!["/usr/lib/jvm/bin/java".into()];
|
||||||
|
args.extend(jvm_options(limits)?);
|
||||||
|
args.extend(["-cp", "/work", "Main"].map(String::from));
|
||||||
|
Ok(args)
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate(spec: &ExecutionSpec) -> Result<(), &'static str> {
|
||||||
|
if spec.argv != argv(spec.profile, &spec.limits)?
|
||||||
|
|| !spec.env.is_empty()
|
||||||
|
|| spec.cwd != "."
|
||||||
|
|| !spec.outputs.is_empty()
|
||||||
|
{
|
||||||
|
return Err("managed profile differs from trusted command");
|
||||||
|
}
|
||||||
|
if spec.profile == P::JavaRun {
|
||||||
|
if spec.inputs.is_empty()
|
||||||
|
|| spec.inputs.len() > v2::MAX_CLASS_FILES
|
||||||
|
|| !spec.inputs.iter().any(|f| f.path == "Main.class")
|
||||||
|
|| spec.inputs.iter().any(|f| !v2::class_name(&f.path))
|
||||||
|
|| spec.inputs.windows(2).any(|f| f[0].path >= f[1].path)
|
||||||
|
{
|
||||||
|
return Err("invalid Java class input set");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let expected = if spec.profile == P::JavaCompile {
|
||||||
|
"Main.java"
|
||||||
|
} else {
|
||||||
|
"main.py"
|
||||||
|
};
|
||||||
|
if spec.inputs.len() != 1 || spec.inputs[0].path != expected {
|
||||||
|
return Err("managed profile requires fixed source");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.profile.prepares_program() && spec.stdin_artifact.is_some() {
|
||||||
|
return Err("preparation does not consume test input");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ impl WireMessage for Reply {
|
|||||||
fn valid(&self) -> bool {
|
fn valid(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Self::Completed { report } => {
|
Self::Completed { report } => {
|
||||||
report.artifacts.len() <= 16
|
report.artifacts.len() <= kf_contracts::judging_v2::MAX_CLASS_FILES
|
||||||
&& report.errors.len() <= 16
|
&& report.errors.len() <= 16
|
||||||
&& report.limits.len() <= 8
|
&& report.limits.len() <= 8
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,40 @@ pub const MAX_CONFIG_BYTES: u64 = 65_536;
|
|||||||
pub enum ImageKind {
|
pub enum ImageKind {
|
||||||
#[serde(rename = "linux_gnu_cxx_x86_64_v1")]
|
#[serde(rename = "linux_gnu_cxx_x86_64_v1")]
|
||||||
LinuxGnuCxxX86_64V1,
|
LinuxGnuCxxX86_64V1,
|
||||||
|
#[serde(rename = "linux_cpython313_x86_64_v1")]
|
||||||
|
LinuxCPython313X86_64V1,
|
||||||
|
#[serde(rename = "linux_openjdk21_x86_64_v1")]
|
||||||
|
LinuxOpenJdk21X86_64V1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageKind {
|
||||||
|
pub fn supports(self, profile: crate::spec::ExecutionProfile) -> bool {
|
||||||
|
use crate::spec::ExecutionProfile as P;
|
||||||
|
matches!(
|
||||||
|
(self, profile),
|
||||||
|
(
|
||||||
|
Self::LinuxGnuCxxX86_64V1,
|
||||||
|
P::Native | P::CompileC | P::CompileCpp
|
||||||
|
) | (Self::LinuxCPython313X86_64V1, P::PythonCheck | P::PythonRun)
|
||||||
|
| (Self::LinuxOpenJdk21X86_64V1, P::JavaCompile | P::JavaRun)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub fn contract_kind(self) -> kf_contracts::judging_v2::ImageKind {
|
||||||
|
use kf_contracts::judging_v2::ImageKind as K;
|
||||||
|
match self {
|
||||||
|
Self::LinuxGnuCxxX86_64V1 => K::GnuCxx,
|
||||||
|
Self::LinuxCPython313X86_64V1 => K::CPython313,
|
||||||
|
Self::LinuxOpenJdk21X86_64V1 => K::OpenJdk21,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn language(self) -> kf_contracts::judging_v2::Language {
|
||||||
|
use kf_contracts::judging_v2::Language as L;
|
||||||
|
match self {
|
||||||
|
Self::LinuxGnuCxxX86_64V1 => L::C17,
|
||||||
|
Self::LinuxCPython313X86_64V1 => L::Python313,
|
||||||
|
Self::LinuxOpenJdk21X86_64V1 => L::Java21,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -51,6 +85,9 @@ pub struct RuntimeConfig {
|
|||||||
pub tasks_root: String,
|
pub tasks_root: String,
|
||||||
pub image_root: String,
|
pub image_root: String,
|
||||||
pub image_kind: ImageKind,
|
pub image_kind: ImageKind,
|
||||||
|
/// Explicit catalog fingerprint; absent only for the original GNU image.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub runtime_identity: Option<kf_contracts::judging_v2::RuntimeIdentity>,
|
||||||
pub delegated_cgroup_root: String,
|
pub delegated_cgroup_root: String,
|
||||||
pub identities: IdentityPool,
|
pub identities: IdentityPool,
|
||||||
pub max_concurrent_runs: u32,
|
pub max_concurrent_runs: u32,
|
||||||
@@ -88,6 +125,7 @@ impl ValidatedRuntimeConfig {
|
|||||||
pub fn spec_policy(&self) -> SpecPolicy {
|
pub fn spec_policy(&self) -> SpecPolicy {
|
||||||
SpecPolicy::with_limits_ceiling(self.0.per_task_ceiling.clone())
|
SpecPolicy::with_limits_ceiling(self.0.per_task_ceiling.clone())
|
||||||
.expect("validated immutable limits ceiling")
|
.expect("validated immutable limits ceiling")
|
||||||
|
.for_image(self.0.image_kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Slot mapping only; the supervisor must lease slots until the old task
|
/// Slot mapping only; the supervisor must lease slots until the old task
|
||||||
@@ -129,6 +167,18 @@ fn fits(each: u64, slots: u32, reserve: u64, total: u64) -> bool {
|
|||||||
|
|
||||||
impl RuntimeConfig {
|
impl RuntimeConfig {
|
||||||
pub fn validate(self) -> Result<ValidatedRuntimeConfig, ConfigError> {
|
pub fn validate(self) -> Result<ValidatedRuntimeConfig, ConfigError> {
|
||||||
|
match &self.runtime_identity {
|
||||||
|
Some(identity) if identity.image_kind == self.image_kind.contract_kind() => identity
|
||||||
|
.validate_for_language(self.image_kind.language())
|
||||||
|
.map_err(|_| invalid("runtime_identity", "invalid_fixed_identity"))?,
|
||||||
|
None if self.image_kind == ImageKind::LinuxGnuCxxX86_64V1 => {}
|
||||||
|
_ => {
|
||||||
|
return Err(invalid(
|
||||||
|
"runtime_identity",
|
||||||
|
"missing_or_mismatched_identity",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
for (field, value) in [
|
for (field, value) in [
|
||||||
("state_root", &self.state_root),
|
("state_root", &self.state_root),
|
||||||
("tasks_root", &self.tasks_root),
|
("tasks_root", &self.tasks_root),
|
||||||
@@ -343,12 +393,21 @@ pub fn preflight(
|
|||||||
}
|
}
|
||||||
// A controlled directory image includes a GNU C/C++ toolchain, runtime
|
// A controlled directory image includes a GNU C/C++ toolchain, runtime
|
||||||
// loader and immutable manifest. Content/ownership checks come later.
|
// loader and immutable manifest. Content/ownership checks come later.
|
||||||
for (subject, relative) in [
|
let binaries: &[(&str, &str)] = match policy.image_kind {
|
||||||
|
ImageKind::LinuxGnuCxxX86_64V1 => &[("gcc", "usr/bin/gcc"), ("g++", "usr/bin/g++")],
|
||||||
|
ImageKind::LinuxCPython313X86_64V1 => &[
|
||||||
|
("python", "usr/bin/python3.13"),
|
||||||
|
("python_checker", "usr/lib/kf/python_check.py"),
|
||||||
|
],
|
||||||
|
ImageKind::LinuxOpenJdk21X86_64V1 => &[
|
||||||
|
("java", "usr/lib/jvm/bin/java"),
|
||||||
|
("javac", "usr/lib/jvm/bin/javac"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
for &(subject, relative) in binaries.iter().chain(&[
|
||||||
("image_manifest", "manifest.json"),
|
("image_manifest", "manifest.json"),
|
||||||
("gcc", "usr/bin/gcc"),
|
|
||||||
("g++", "usr/bin/g++"),
|
|
||||||
("dynamic_loader", "lib64/ld-linux-x86-64.so.2"),
|
("dynamic_loader", "lib64/ld-linux-x86-64.so.2"),
|
||||||
] {
|
]) {
|
||||||
if !Path::new(&policy.image_root).join(relative).is_file() {
|
if !Path::new(&policy.image_root).join(relative).is_file() {
|
||||||
refuse("image_file_not_visible", subject);
|
refuse("image_file_not_visible", subject);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,42 @@ pub enum ExecutionProfile {
|
|||||||
Native,
|
Native,
|
||||||
CompileC,
|
CompileC,
|
||||||
CompileCpp,
|
CompileCpp,
|
||||||
|
PythonCheck,
|
||||||
|
PythonRun,
|
||||||
|
JavaCompile,
|
||||||
|
JavaRun,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecutionProfile {
|
impl ExecutionProfile {
|
||||||
/// Trusted image-local compiler tools only; never inherit the host PATH.
|
/// Trusted image-local compiler tools only; never inherit the host PATH.
|
||||||
pub fn compiler_search_path(self) -> Option<&'static str> {
|
pub fn compiler_search_path(self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Native => None,
|
Self::Native
|
||||||
|
| Self::PythonCheck
|
||||||
|
| Self::PythonRun
|
||||||
|
| Self::JavaCompile
|
||||||
|
| Self::JavaRun => None,
|
||||||
Self::CompileC | Self::CompileCpp => Some("/usr/bin"),
|
Self::CompileC | Self::CompileCpp => Some("/usr/bin"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn prepares_program(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::CompileC | Self::CompileCpp | Self::PythonCheck | Self::JavaCompile
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gnu_compiler(self) -> bool {
|
||||||
|
matches!(self, Self::CompileC | Self::CompileCpp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_runtime(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::PythonCheck | Self::PythonRun | Self::JavaCompile | Self::JavaRun
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every field is explicit: omission cannot silently remove a resource limit.
|
/// Every field is explicit: omission cannot silently remove a resource limit.
|
||||||
@@ -66,7 +92,9 @@ impl Default for ResourceLimits {
|
|||||||
impl ResourceLimits {
|
impl ResourceLimits {
|
||||||
pub fn for_profile(profile: ExecutionProfile) -> Self {
|
pub fn for_profile(profile: ExecutionProfile) -> Self {
|
||||||
match profile {
|
match profile {
|
||||||
ExecutionProfile::Native => Self::default(),
|
ExecutionProfile::Native
|
||||||
|
| ExecutionProfile::PythonCheck
|
||||||
|
| ExecutionProfile::PythonRun => Self::default(),
|
||||||
ExecutionProfile::CompileC | ExecutionProfile::CompileCpp => Self {
|
ExecutionProfile::CompileC | ExecutionProfile::CompileCpp => Self {
|
||||||
cpu_time_us: 10_000_000,
|
cpu_time_us: 10_000_000,
|
||||||
wall_time_us: 30_000_000,
|
wall_time_us: 30_000_000,
|
||||||
@@ -78,6 +106,20 @@ impl ResourceLimits {
|
|||||||
workdir_bytes: 256 * MIB,
|
workdir_bytes: 256 * MIB,
|
||||||
..Self::default()
|
..Self::default()
|
||||||
},
|
},
|
||||||
|
ExecutionProfile::JavaCompile => Self {
|
||||||
|
memory_bytes: 512 * MIB,
|
||||||
|
processes: 32,
|
||||||
|
open_files: 128,
|
||||||
|
cpu_time_us: 10_000_000,
|
||||||
|
wall_time_us: 30_000_000,
|
||||||
|
..Self::default()
|
||||||
|
},
|
||||||
|
ExecutionProfile::JavaRun => Self {
|
||||||
|
memory_bytes: 384 * MIB,
|
||||||
|
processes: 32,
|
||||||
|
open_files: 64,
|
||||||
|
..Self::default()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,12 +171,14 @@ pub struct ExecutionSpec {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct SpecPolicy {
|
pub struct SpecPolicy {
|
||||||
limits_ceiling: ResourceLimits,
|
limits_ceiling: ResourceLimits,
|
||||||
|
image_kind: Option<crate::runtime::ImageKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SpecPolicy {
|
impl Default for SpecPolicy {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
limits_ceiling: ResourceLimits::ceilings(),
|
limits_ceiling: ResourceLimits::ceilings(),
|
||||||
|
image_kind: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,8 +188,14 @@ impl SpecPolicy {
|
|||||||
validate_limits(&limits, &ResourceLimits::ceilings())?;
|
validate_limits(&limits, &ResourceLimits::ceilings())?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
limits_ceiling: limits,
|
limits_ceiling: limits,
|
||||||
|
image_kind: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn for_image(mut self, kind: crate::runtime::ImageKind) -> Self {
|
||||||
|
self.image_kind = Some(kind);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An immutable validated request, not proof that the environment is safe.
|
/// An immutable validated request, not proof that the environment is safe.
|
||||||
@@ -162,7 +212,15 @@ impl ValidatedSpec {
|
|||||||
/// image-local PATH/TMPDIR defaults through their environment whitelist.
|
/// image-local PATH/TMPDIR defaults through their environment whitelist.
|
||||||
pub fn execution_environment(&self) -> BTreeMap<String, String> {
|
pub fn execution_environment(&self) -> BTreeMap<String, String> {
|
||||||
let mut env = BTreeMap::from([
|
let mut env = BTreeMap::from([
|
||||||
("LANG".into(), "C".into()),
|
(
|
||||||
|
"LANG".into(),
|
||||||
|
if self.0.profile.managed_runtime() {
|
||||||
|
"C.UTF-8"
|
||||||
|
} else {
|
||||||
|
"C"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
("TZ".into(), "UTC".into()),
|
("TZ".into(), "UTC".into()),
|
||||||
("TMPDIR".into(), "/tmp".into()),
|
("TMPDIR".into(), "/tmp".into()),
|
||||||
]);
|
]);
|
||||||
@@ -238,6 +296,14 @@ pub(crate) fn path(value: &str, field: &'static str) -> Result<(), SpecError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn artifact_path(value: &str, field: &'static str) -> Result<(), SpecError> {
|
||||||
|
if kf_contracts::judging_v2::class_name(value) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
path(value, field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn overlaps(left: &str, right: &str) -> bool {
|
fn overlaps(left: &str, right: &str) -> bool {
|
||||||
left == right
|
left == right
|
||||||
|| left
|
|| left
|
||||||
@@ -308,16 +374,36 @@ impl ExecutionSpec {
|
|||||||
return Err(error("env", ValidationCode::InvalidEnvironment));
|
return Err(error("env", ValidationCode::InvalidEnvironment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if self.profile.managed_runtime() && (!self.env.is_empty() || self.cwd != ".") {
|
||||||
|
return Err(error("env_or_cwd", ValidationCode::InvalidEnvironment));
|
||||||
|
}
|
||||||
|
if policy
|
||||||
|
.image_kind
|
||||||
|
.is_some_and(|kind| !kind.supports(self.profile))
|
||||||
|
{
|
||||||
|
return Err(error("profile", ValidationCode::UnsupportedExecutable));
|
||||||
|
}
|
||||||
if self.cwd != "." {
|
if self.cwd != "." {
|
||||||
path(&self.cwd, "cwd")?;
|
path(&self.cwd, "cwd")?;
|
||||||
}
|
}
|
||||||
if self.inputs.len() > 64 || self.outputs.len() > 16 {
|
if self.inputs.len()
|
||||||
|
> if self.profile == ExecutionProfile::JavaRun {
|
||||||
|
kf_contracts::judging_v2::MAX_CLASS_FILES
|
||||||
|
} else {
|
||||||
|
64
|
||||||
|
}
|
||||||
|
|| self.outputs.len() > 16
|
||||||
|
{
|
||||||
return Err(error("artifacts", ValidationCode::TooManyArtifacts));
|
return Err(error("artifacts", ValidationCode::TooManyArtifacts));
|
||||||
}
|
}
|
||||||
let mut input_paths = BTreeSet::new();
|
let mut input_paths = BTreeSet::new();
|
||||||
for input in &self.inputs {
|
for input in &self.inputs {
|
||||||
identifier(&input.artifact_id, "inputs.artifact_id")?;
|
identifier(&input.artifact_id, "inputs.artifact_id")?;
|
||||||
path(&input.path, "inputs.path")?;
|
if self.profile == ExecutionProfile::JavaRun {
|
||||||
|
artifact_path(&input.path, "inputs.path")?;
|
||||||
|
} else {
|
||||||
|
path(&input.path, "inputs.path")?;
|
||||||
|
}
|
||||||
if input_paths
|
if input_paths
|
||||||
.iter()
|
.iter()
|
||||||
.any(|other: &&str| overlaps(other, &input.path))
|
.any(|other: &&str| overlaps(other, &input.path))
|
||||||
@@ -353,6 +439,14 @@ impl ExecutionSpec {
|
|||||||
.is_some_and(|relative| input_paths.contains(relative)),
|
.is_some_and(|relative| input_paths.contains(relative)),
|
||||||
ExecutionProfile::CompileC => executable == "/usr/bin/gcc",
|
ExecutionProfile::CompileC => executable == "/usr/bin/gcc",
|
||||||
ExecutionProfile::CompileCpp => executable == "/usr/bin/g++",
|
ExecutionProfile::CompileCpp => executable == "/usr/bin/g++",
|
||||||
|
ExecutionProfile::PythonCheck
|
||||||
|
| ExecutionProfile::PythonRun
|
||||||
|
| ExecutionProfile::JavaCompile
|
||||||
|
| ExecutionProfile::JavaRun => {
|
||||||
|
crate::profiles::validate(&self)
|
||||||
|
.map_err(|_| error("profile", ValidationCode::InvalidArgv))?;
|
||||||
|
true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if !approved {
|
if !approved {
|
||||||
return Err(error("argv[0]", ValidationCode::UnsupportedExecutable));
|
return Err(error("argv[0]", ValidationCode::UnsupportedExecutable));
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Ordinary lexical/authority policy tests. Actual runtime/kernel evidence is VM-only.
|
||||||
|
use kf_contracts::judging_v2::{ImageKind as ContractKind, RuntimeIdentity};
|
||||||
|
use kf_sandbox::{
|
||||||
|
profiles,
|
||||||
|
runtime::{ImageKind, RuntimeConfig},
|
||||||
|
spec::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn request(profile: ExecutionProfile) -> ExecutionSpec {
|
||||||
|
let limits = ResourceLimits::for_profile(profile);
|
||||||
|
ExecutionSpec {
|
||||||
|
schema_version: kf_contracts::SchemaVersion,
|
||||||
|
run_id: "managed-policy".into(),
|
||||||
|
profile,
|
||||||
|
argv: profiles::argv(profile, &limits).unwrap(),
|
||||||
|
env: Default::default(),
|
||||||
|
cwd: ".".into(),
|
||||||
|
inputs: if profile == ExecutionProfile::JavaRun {
|
||||||
|
[
|
||||||
|
"Helper.class",
|
||||||
|
"Main$1.class",
|
||||||
|
"Main$Inner.class",
|
||||||
|
"Main.class",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, path)| InputArtifact {
|
||||||
|
artifact_id: format!("class-{i}"),
|
||||||
|
path: (*path).into(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
vec![InputArtifact {
|
||||||
|
artifact_id: "source".into(),
|
||||||
|
path: if profile == ExecutionProfile::JavaCompile {
|
||||||
|
"Main.java"
|
||||||
|
} else {
|
||||||
|
"main.py"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
stdin_artifact: None,
|
||||||
|
outputs: vec![],
|
||||||
|
limits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_environment_and_source_or_class_sets_are_closed() {
|
||||||
|
for profile in [
|
||||||
|
ExecutionProfile::PythonCheck,
|
||||||
|
ExecutionProfile::PythonRun,
|
||||||
|
ExecutionProfile::JavaCompile,
|
||||||
|
ExecutionProfile::JavaRun,
|
||||||
|
] {
|
||||||
|
let original = request(profile);
|
||||||
|
let valid = original.clone().validate(&SpecPolicy::default()).unwrap();
|
||||||
|
assert_eq!(valid.execution_environment()["LANG"], "C.UTF-8");
|
||||||
|
assert!(!valid.execution_environment().contains_key("PATH"));
|
||||||
|
for arg in [
|
||||||
|
"-c",
|
||||||
|
"-m",
|
||||||
|
"-javaagent:/work/user.jar",
|
||||||
|
"-cp",
|
||||||
|
"@options",
|
||||||
|
"/bin/sh",
|
||||||
|
] {
|
||||||
|
let mut modified = original.clone();
|
||||||
|
modified.argv.push(arg.into());
|
||||||
|
assert!(modified.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
for variable in [
|
||||||
|
"PATH",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"PYTHONHOME",
|
||||||
|
"JAVA_TOOL_OPTIONS",
|
||||||
|
"JDK_JAVA_OPTIONS",
|
||||||
|
"CLASSPATH",
|
||||||
|
"LD_PRELOAD",
|
||||||
|
"LANG",
|
||||||
|
] {
|
||||||
|
let mut modified = original.clone();
|
||||||
|
modified.env.insert(variable.into(), "C".into());
|
||||||
|
assert!(modified.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
for input in [
|
||||||
|
"../Main.class",
|
||||||
|
"pkg/Main.class",
|
||||||
|
"user.jar",
|
||||||
|
"Main$1.class/child",
|
||||||
|
"main.pyc",
|
||||||
|
"options",
|
||||||
|
] {
|
||||||
|
let mut modified = original.clone();
|
||||||
|
modified.inputs[0].path = input.into();
|
||||||
|
assert!(modified.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed.cwd = "tmp".into();
|
||||||
|
assert!(changed.validate(&SpecPolicy::default()).is_err());
|
||||||
|
let mut changed = original;
|
||||||
|
changed.outputs.push("user.jar".into());
|
||||||
|
assert!(changed.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
let mut missing = request(ExecutionProfile::JavaRun);
|
||||||
|
missing.inputs.pop();
|
||||||
|
assert!(missing.validate(&SpecPolicy::default()).is_err());
|
||||||
|
let mut duplicate = request(ExecutionProfile::JavaRun);
|
||||||
|
duplicate.inputs.push(duplicate.inputs[0].clone());
|
||||||
|
assert!(duplicate.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_heap_and_budget_parameters_cannot_be_supplied_by_request() {
|
||||||
|
for profile in [ExecutionProfile::JavaCompile, ExecutionProfile::JavaRun] {
|
||||||
|
let original = request(profile);
|
||||||
|
assert!(
|
||||||
|
original
|
||||||
|
.argv
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.contains("ActiveProcessorCount=1"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
original
|
||||||
|
.argv
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.contains("DisableAttachMechanism"))
|
||||||
|
);
|
||||||
|
assert!(original.argv.iter().any(|a| a.contains("UseSerialGC")));
|
||||||
|
let mut changed = original.clone();
|
||||||
|
changed.limits.memory_bytes += 1024 * 1024;
|
||||||
|
assert!(changed.validate(&SpecPolicy::default()).is_err());
|
||||||
|
let mut tiny = original.limits;
|
||||||
|
tiny.memory_bytes = 128 * 1024 * 1024;
|
||||||
|
assert!(profiles::argv(profile, &tiny).is_err());
|
||||||
|
}
|
||||||
|
let mut small = request(ExecutionProfile::PythonRun);
|
||||||
|
small.limits.open_files = 8;
|
||||||
|
assert!(small.validate(&SpecPolicy::default()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_image_kind_never_promotes_legacy_authority() {
|
||||||
|
let legacy: RuntimeConfig = serde_json::from_str(include_str!(
|
||||||
|
"../../../../deploy/sandbox.runtime.example.json"
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let old = legacy.clone().validate().unwrap();
|
||||||
|
assert!(
|
||||||
|
request(ExecutionProfile::PythonRun)
|
||||||
|
.validate(&old.spec_policy())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for (kind, contract, profile, id, version) in [
|
||||||
|
(
|
||||||
|
ImageKind::LinuxCPython313X86_64V1,
|
||||||
|
ContractKind::CPython313,
|
||||||
|
ExecutionProfile::PythonRun,
|
||||||
|
"cpython313-v1",
|
||||||
|
"3.13.16",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ImageKind::LinuxOpenJdk21X86_64V1,
|
||||||
|
ContractKind::OpenJdk21,
|
||||||
|
ExecutionProfile::JavaRun,
|
||||||
|
"openjdk21-v1",
|
||||||
|
"21.0.12.1+1-LTS",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut config = legacy.clone();
|
||||||
|
config.image_kind = kind;
|
||||||
|
assert!(config.clone().validate().is_err());
|
||||||
|
config.runtime_identity = Some(RuntimeIdentity {
|
||||||
|
toolchain_id: id.to_owned().try_into().unwrap(),
|
||||||
|
image_kind: contract,
|
||||||
|
image_revision: 1,
|
||||||
|
image_digest: "a".repeat(64).try_into().unwrap(),
|
||||||
|
runtime_version: version.to_owned().try_into().unwrap(),
|
||||||
|
});
|
||||||
|
let validated = config.clone().validate().unwrap();
|
||||||
|
assert!(request(profile).validate(&validated.spec_policy()).is_ok());
|
||||||
|
assert!(
|
||||||
|
request(if profile == ExecutionProfile::PythonRun {
|
||||||
|
ExecutionProfile::JavaRun
|
||||||
|
} else {
|
||||||
|
ExecutionProfile::PythonRun
|
||||||
|
})
|
||||||
|
.validate(&validated.spec_policy())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
config.runtime_identity.as_mut().unwrap().image_revision = 0;
|
||||||
|
assert!(config.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#![cfg(all(target_os = "linux", feature = "vm-acceptance"))]
|
||||||
|
//! Actual new profile execution through the ordinary default Root entry.
|
||||||
|
//! This does not substitute for the later ordinary Worker/browser pipeline.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
mod support;
|
||||||
|
use kf_contracts::judging_v2::RuntimeIdentity;
|
||||||
|
use kf_sandbox::{
|
||||||
|
image::{ImageCatalog, catalog_digest},
|
||||||
|
profiles,
|
||||||
|
runtime::ImageKind,
|
||||||
|
spec::*,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
os::unix::fs::{MetadataExt, PermissionsExt},
|
||||||
|
path::PathBuf,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
root: support::Root,
|
||||||
|
dir: PathBuf,
|
||||||
|
config: PathBuf,
|
||||||
|
}
|
||||||
|
impl Fixture {
|
||||||
|
fn new(language: &str, kind: ImageKind) -> Self {
|
||||||
|
support::require_vm();
|
||||||
|
let mut root = support::Root::new();
|
||||||
|
root.config.max_concurrent_runs = 1;
|
||||||
|
root.config.identities.count = 1;
|
||||||
|
root.config.aggregate.task_cpu_quota_us = 100_000;
|
||||||
|
root.config.per_task_ceiling.cpu_time_us = 30_000_000;
|
||||||
|
root.config.per_task_ceiling.wall_time_us = 60_000_000;
|
||||||
|
root.config.image_root = format!("/var/lib/kf-judge/images/{language}");
|
||||||
|
root.config.image_kind = kind;
|
||||||
|
let bytes = fs::read(format!("{}/manifest.json", root.config.image_root)).unwrap();
|
||||||
|
let catalog: ImageCatalog = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
root.config.runtime_identity = Some(RuntimeIdentity {
|
||||||
|
toolchain_id: catalog.toolchain_id.clone(),
|
||||||
|
image_kind: catalog.image_kind,
|
||||||
|
image_revision: catalog.image_revision,
|
||||||
|
image_digest: catalog_digest(&bytes),
|
||||||
|
runtime_version: catalog.runtime_version.clone(),
|
||||||
|
});
|
||||||
|
root.config.clone().validate().unwrap();
|
||||||
|
catalog
|
||||||
|
.validate(root.config.runtime_identity.as_ref().unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let image = PathBuf::from(&root.config.image_root);
|
||||||
|
for (path, mode, bytes) in std::iter::once((String::new(), 0o555, None))
|
||||||
|
.chain(catalog.directories.iter().map(|p| (p.clone(), 0o555, None)))
|
||||||
|
.chain(catalog.files.iter().map(|p| {
|
||||||
|
(
|
||||||
|
p.path.clone(),
|
||||||
|
if p.executable { 0o555 } else { 0o444 },
|
||||||
|
Some(p.bytes),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
{
|
||||||
|
let metadata = fs::symlink_metadata(image.join(&path)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(metadata.uid(), metadata.gid(), metadata.mode() & 0o7777),
|
||||||
|
(0, 0, mode),
|
||||||
|
"image metadata {path}"
|
||||||
|
);
|
||||||
|
if let Some(bytes) = bytes {
|
||||||
|
assert!(metadata.is_file(), "{path}");
|
||||||
|
assert_eq!(metadata.nlink(), 1, "{path}");
|
||||||
|
assert_eq!(metadata.len(), bytes, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("KF_MANAGED_CATALOG_METADATA_STATUS=0 {language}");
|
||||||
|
let dir = PathBuf::from(format!("/kf/managed-{}-{language}", std::process::id()));
|
||||||
|
fs::create_dir(&dir).unwrap();
|
||||||
|
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
for name in ["inputs", "outputs"] {
|
||||||
|
fs::create_dir(dir.join(name)).unwrap();
|
||||||
|
}
|
||||||
|
let config = dir.join("runtime.json");
|
||||||
|
let fixture = Self { root, dir, config };
|
||||||
|
fixture.save_config();
|
||||||
|
println!(
|
||||||
|
"KF_MANAGED_IMAGE_PROOF {}",
|
||||||
|
serde_json::json!({"language":language,"identity":fixture.root.config.runtime_identity})
|
||||||
|
);
|
||||||
|
fixture
|
||||||
|
}
|
||||||
|
fn save_config(&self) {
|
||||||
|
fs::write(&self.config, serde_json::to_vec(&self.root.config).unwrap()).unwrap();
|
||||||
|
fs::set_permissions(&self.config, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
fn input(&self, name: &str, bytes: &[u8]) {
|
||||||
|
fs::write(self.dir.join("inputs").join(name), bytes).unwrap();
|
||||||
|
fs::set_permissions(
|
||||||
|
self.dir.join("inputs").join(name),
|
||||||
|
fs::Permissions::from_mode(0o600),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fn request(&self, id: &str, profile: ExecutionProfile) -> ExecutionSpec {
|
||||||
|
let mut limits = ResourceLimits::for_profile(profile);
|
||||||
|
// Same finite acceptance ceiling as the existing real GNU regression;
|
||||||
|
// this fixture does not change any submitted job's configured limits.
|
||||||
|
limits.cpu_time_us = 30_000_000;
|
||||||
|
limits.wall_time_us = 60_000_000;
|
||||||
|
ExecutionSpec {
|
||||||
|
schema_version: kf_contracts::SchemaVersion,
|
||||||
|
run_id: id.into(),
|
||||||
|
profile,
|
||||||
|
argv: profiles::argv(profile, &limits).unwrap(),
|
||||||
|
env: Default::default(),
|
||||||
|
cwd: ".".into(),
|
||||||
|
inputs: vec![InputArtifact {
|
||||||
|
artifact_id: "source".into(),
|
||||||
|
path: if profile == ExecutionProfile::JavaCompile {
|
||||||
|
"Main.java"
|
||||||
|
} else {
|
||||||
|
"main.py"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
}],
|
||||||
|
stdin_artifact: None,
|
||||||
|
outputs: vec![],
|
||||||
|
limits,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn command(&self, spec: &ExecutionSpec) -> Command {
|
||||||
|
fs::write(
|
||||||
|
self.dir.join("spec.json"),
|
||||||
|
serde_json::to_vec(spec).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut command = Command::new(std::env::var_os("KF_SANDBOX_PRODUCTION_BIN").unwrap());
|
||||||
|
command
|
||||||
|
.env_clear()
|
||||||
|
.args(["run", "--config"])
|
||||||
|
.arg(&self.config)
|
||||||
|
.arg("--spec")
|
||||||
|
.arg(self.dir.join("spec.json"))
|
||||||
|
.arg("--artifacts")
|
||||||
|
.arg(self.dir.join("inputs"))
|
||||||
|
.arg("--output")
|
||||||
|
.arg(self.dir.join("outputs"));
|
||||||
|
command
|
||||||
|
}
|
||||||
|
fn execute(&self, spec: &ExecutionSpec) -> serde_json::Value {
|
||||||
|
let output = if std::env::var("KF_MANAGED_TRACE").as_deref() == Ok("1") {
|
||||||
|
let mut child = self
|
||||||
|
.command(spec)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||||
|
let mut tracer = None;
|
||||||
|
while child.try_wait().unwrap().is_none() && std::time::Instant::now() < deadline {
|
||||||
|
let mut parents = vec![child.id()];
|
||||||
|
for _ in 0..4 {
|
||||||
|
let mut children = vec![];
|
||||||
|
for pid in parents {
|
||||||
|
if let Ok(data) =
|
||||||
|
fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||||
|
{
|
||||||
|
children.extend(
|
||||||
|
data.split_whitespace()
|
||||||
|
.filter_map(|v| v.parse::<u32>().ok()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(pid) = children.iter().find(|pid| {
|
||||||
|
fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
|
.is_ok_and(|s| s.trim() == "python3.13")
|
||||||
|
}) {
|
||||||
|
tracer = Some(
|
||||||
|
Command::new("/kf/bin/strace")
|
||||||
|
.env_clear()
|
||||||
|
.args([
|
||||||
|
"-ff",
|
||||||
|
"-qq",
|
||||||
|
"-e",
|
||||||
|
"trace=open,openat,openat2,fcntl,ioctl",
|
||||||
|
"-e",
|
||||||
|
"status=failed",
|
||||||
|
"-o",
|
||||||
|
])
|
||||||
|
.arg(self.dir.join("trace"))
|
||||||
|
.arg("-p")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.spawn()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parents = children;
|
||||||
|
}
|
||||||
|
if tracer.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
let output = child.wait_with_output().unwrap();
|
||||||
|
if let Some(mut tracer) = tracer {
|
||||||
|
let _ = tracer.wait();
|
||||||
|
}
|
||||||
|
output
|
||||||
|
} else {
|
||||||
|
self.command(spec).output().unwrap()
|
||||||
|
};
|
||||||
|
if std::env::var("KF_MANAGED_TRACE").as_deref() == Ok("1") {
|
||||||
|
for entry in fs::read_dir(&self.dir).unwrap() {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
if entry.file_name().to_string_lossy().starts_with("trace.") {
|
||||||
|
let data = fs::read_to_string(entry.path()).unwrap();
|
||||||
|
for line in data.lines().filter(|s| s.contains("EPERM")) {
|
||||||
|
println!(
|
||||||
|
"KF_MANAGED_DENIED_SYSCALL {}",
|
||||||
|
line.chars().take(1024).collect::<String>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{} {}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
println!(
|
||||||
|
"KF_MANAGED_RUN_PROOF {}",
|
||||||
|
serde_json::json!({"spec":spec,"result":value})
|
||||||
|
);
|
||||||
|
for name in ["stdout", "stderr"] {
|
||||||
|
if let Ok(bytes) = fs::read(self.dir.join("outputs").join(&spec.run_id).join(name)) {
|
||||||
|
println!(
|
||||||
|
"KF_MANAGED_CAPTURE {}",
|
||||||
|
serde_json::json!({"run_id":spec.run_id,"name":name,"prefix":String::from_utf8_lossy(&bytes[..bytes.len().min(4096)])})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(value["report"]["cleanup"], "complete");
|
||||||
|
assert_eq!(value["report"]["errors"], serde_json::json!([]));
|
||||||
|
self.empty();
|
||||||
|
value
|
||||||
|
}
|
||||||
|
fn reject(&self, spec: &ExecutionSpec, code: &str) {
|
||||||
|
let output = self.command(spec).output().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(2),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
|
||||||
|
assert_eq!(value["error"], code);
|
||||||
|
println!("KF_MANAGED_REFUSAL_PROOF {value}");
|
||||||
|
self.empty();
|
||||||
|
}
|
||||||
|
fn stdout(&self, id: &str) -> Vec<u8> {
|
||||||
|
fs::read(self.dir.join("outputs").join(id).join("stdout")).unwrap()
|
||||||
|
}
|
||||||
|
fn image_refusals(&mut self, spec: &ExecutionSpec, binary: &str) {
|
||||||
|
let image = PathBuf::from(&self.root.config.image_root);
|
||||||
|
let executable = image.join(binary);
|
||||||
|
let backup = self.dir.join("trusted-runtime-backup");
|
||||||
|
fs::rename(&executable, &backup).unwrap();
|
||||||
|
self.reject(spec, "runtime_not_ready");
|
||||||
|
fs::rename(&backup, &executable).unwrap();
|
||||||
|
fs::set_permissions(&executable, fs::Permissions::from_mode(0o777)).unwrap();
|
||||||
|
self.reject(spec, "runtime_not_ready");
|
||||||
|
fs::set_permissions(&executable, fs::Permissions::from_mode(0o555)).unwrap();
|
||||||
|
let original = self.root.config.runtime_identity.clone().unwrap();
|
||||||
|
self.root
|
||||||
|
.config
|
||||||
|
.runtime_identity
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.runtime_version = "1.0.0".to_owned().try_into().unwrap();
|
||||||
|
self.save_config();
|
||||||
|
self.reject(spec, "untrusted_runtime_config");
|
||||||
|
self.root.config.runtime_identity = Some(original.clone());
|
||||||
|
self.root
|
||||||
|
.config
|
||||||
|
.runtime_identity
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.image_digest = "e".repeat(64).try_into().unwrap();
|
||||||
|
self.save_config();
|
||||||
|
self.reject(spec, "runtime_not_ready");
|
||||||
|
self.root.config.runtime_identity = Some(original);
|
||||||
|
self.save_config();
|
||||||
|
let loader = image.join("lib64/ld-linux-x86-64.so.2");
|
||||||
|
fs::rename(&loader, &backup).unwrap();
|
||||||
|
self.reject(spec, "runtime_not_ready");
|
||||||
|
fs::rename(&backup, &loader).unwrap();
|
||||||
|
let original = fs::read(&loader).unwrap();
|
||||||
|
let mut corrupted = original.clone();
|
||||||
|
let last = corrupted.len() - 1;
|
||||||
|
corrupted[last] ^= 1;
|
||||||
|
fs::write(&loader, corrupted).unwrap();
|
||||||
|
self.reject(spec, "runtime_not_ready");
|
||||||
|
fs::write(&loader, original).unwrap();
|
||||||
|
println!(
|
||||||
|
"KF_MANAGED_IMAGE_REFUSALS_STATUS=0 {}",
|
||||||
|
self.root.config.image_root
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn empty(&self) {
|
||||||
|
for name in ["tasks", "captures", "identities"] {
|
||||||
|
let p = PathBuf::from("/var/lib/kf-judge").join(name);
|
||||||
|
if p.exists() {
|
||||||
|
assert_eq!(fs::read_dir(p).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!fs::read_dir(&self.root.path).unwrap().any(|e| e
|
||||||
|
.unwrap()
|
||||||
|
.file_type()
|
||||||
|
.unwrap()
|
||||||
|
.is_dir())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "independent no-NIC/no-share VM with read-back total and fixture caps"]
|
||||||
|
fn python_profile_check_run_and_identity_refusals() {
|
||||||
|
let mut f = Fixture::new("python313", ImageKind::LinuxCPython313X86_64V1);
|
||||||
|
f.input("source", b"import sys\nprint(sys.version.split()[0])\nprint('top-level')\nopen('marker','w').write('owned')\n");
|
||||||
|
let check = f.request("py-check", ExecutionProfile::PythonCheck);
|
||||||
|
let r = f.execute(&check);
|
||||||
|
assert_eq!(r["outcome"], serde_json::json!({"kind":"exited","code":0}));
|
||||||
|
assert!(f.stdout("py-check").is_empty());
|
||||||
|
assert!(r["report"]["artifacts"].as_array().unwrap().is_empty());
|
||||||
|
let run = f.request("py-run", ExecutionProfile::PythonRun);
|
||||||
|
let r = f.execute(&run);
|
||||||
|
assert_eq!(r["outcome"], serde_json::json!({"kind":"exited","code":0}));
|
||||||
|
assert_eq!(f.stdout("py-run"), b"3.13.16\ntop-level\n");
|
||||||
|
f.input("source", b"# coding: latin-1\nprint('caf\xe9')\n");
|
||||||
|
assert_eq!(
|
||||||
|
f.execute(&f.request("py-encoding", ExecutionProfile::PythonCheck))["outcome"]["code"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
f.input("source", b"if !invalid:\n pass\n");
|
||||||
|
assert_eq!(
|
||||||
|
f.execute(&f.request("py-syntax", ExecutionProfile::PythonCheck))["outcome"]["code"],
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let mut wrong = run.clone();
|
||||||
|
wrong.argv.push("-c".into());
|
||||||
|
wrong.run_id = "py-argv-refused".into();
|
||||||
|
f.reject(&wrong, "invalid_execution_spec");
|
||||||
|
f.image_refusals(&run, "usr/bin/python3.13");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "independent no-NIC/no-share VM with read-back total and fixture caps"]
|
||||||
|
fn java_profile_compiles_class_set_and_runs_same_set() {
|
||||||
|
let mut f = Fixture::new("java21", ImageKind::LinuxOpenJdk21X86_64V1);
|
||||||
|
f.input("source", br#"public class Main { static class Inner { } public static void main(String[] args) { System.out.println(System.getProperty("java.runtime.version")); Runnable r = new Runnable() { public void run() { System.out.println("java-ok"); } }; r.run(); new Inner(); } } class Helper {}"#);
|
||||||
|
let compile = f.request("java-compile", ExecutionProfile::JavaCompile);
|
||||||
|
let r = f.execute(&compile);
|
||||||
|
assert_eq!(r["outcome"], serde_json::json!({"kind":"exited","code":0}));
|
||||||
|
let artifacts = r["report"]["artifacts"].as_array().unwrap();
|
||||||
|
let names: Vec<_> = artifacts
|
||||||
|
.iter()
|
||||||
|
.map(|a| a["path"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
[
|
||||||
|
"Helper.class",
|
||||||
|
"Main$1.class",
|
||||||
|
"Main$Inner.class",
|
||||||
|
"Main.class"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let mut run = f.request("java-run", ExecutionProfile::JavaRun);
|
||||||
|
run.inputs.clear();
|
||||||
|
for (i, name) in names.iter().enumerate() {
|
||||||
|
let id = format!("class-{i}");
|
||||||
|
let bytes = fs::read(
|
||||||
|
f.dir
|
||||||
|
.join("outputs/java-compile")
|
||||||
|
.join(format!("artifact-{i}")),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
f.input(&id, &bytes);
|
||||||
|
run.inputs.push(InputArtifact {
|
||||||
|
artifact_id: id,
|
||||||
|
path: (*name).into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let r = f.execute(&run);
|
||||||
|
assert_eq!(r["outcome"], serde_json::json!({"kind":"exited","code":0}));
|
||||||
|
assert_eq!(f.stdout("java-run"), b"21.0.12.1+1-LTS\njava-ok\n");
|
||||||
|
f.input("source", b"public class Main { invalid }\n");
|
||||||
|
let r = f.execute(&f.request("java-syntax", ExecutionProfile::JavaCompile));
|
||||||
|
assert_eq!(r["outcome"]["code"], 1);
|
||||||
|
assert!(r["report"]["artifacts"].as_array().unwrap().is_empty());
|
||||||
|
let mut wrong = run;
|
||||||
|
wrong
|
||||||
|
.env
|
||||||
|
.insert("JAVA_TOOL_OPTIONS".into(), "-javaagent:/work/x.jar".into());
|
||||||
|
wrong.run_id = "java-env-refused".into();
|
||||||
|
f.reject(&wrong, "invalid_execution_spec");
|
||||||
|
f.image_refusals(&wrong, "usr/lib/jvm/bin/java");
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Offline curated directory images from pinned public archives and verified libraries.
|
||||||
|
|
||||||
|
Build as an ordinary user. Publish as root with 0555 directories / 0444 data /
|
||||||
|
0555 executables in a private VM; the resulting catalog fingerprint belongs in
|
||||||
|
the protected runtime config. This tool never executes the target runtimes,
|
||||||
|
submitted source, package hooks, or network installers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
PINS = {
|
||||||
|
"python313": {
|
||||||
|
"sha256": "4595c5589fff7bf0cb158d9a88a797e0d791fa33830770fcb7bf3f4b104feeae",
|
||||||
|
"version": "3.13.16",
|
||||||
|
"prefix": "python/",
|
||||||
|
"toolchain_id": "cpython313-v1",
|
||||||
|
"kind": "linux_cpython313_x86_64_v1",
|
||||||
|
"source": "https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.13.16%2B20261003-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz",
|
||||||
|
},
|
||||||
|
"java21": {
|
||||||
|
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
|
||||||
|
"version": "21.0.12.1+1-LTS",
|
||||||
|
"prefix": "jdk-21.0.12.1+1/",
|
||||||
|
"toolchain_id": "openjdk21-v1",
|
||||||
|
"kind": "linux_openjdk21_x86_64_v1",
|
||||||
|
"source": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sha(path):
|
||||||
|
with path.open("rb") as source:
|
||||||
|
return hashlib.file_digest(source, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(name):
|
||||||
|
return (
|
||||||
|
len(name) <= 4096
|
||||||
|
and not name.startswith("/")
|
||||||
|
and all(
|
||||||
|
re.fullmatch(r"[A-Za-z0-9_.-]{1,255}", p) and p not in (".", "..")
|
||||||
|
for p in name.split("/")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def member_bytes(archive, members, name, seen=()):
|
||||||
|
if name in seen or len(seen) > 8 or name not in members:
|
||||||
|
raise ValueError("public archive link outside bounded member set")
|
||||||
|
item = members[name]
|
||||||
|
if item.issym() or item.islnk():
|
||||||
|
parts = []
|
||||||
|
target = PurePosixPath(item.linkname)
|
||||||
|
if target.is_absolute():
|
||||||
|
raise ValueError("absolute public archive link")
|
||||||
|
target = target if item.islnk() else PurePosixPath(name).parent / target
|
||||||
|
for part in target.parts:
|
||||||
|
if part == "..":
|
||||||
|
if not parts:
|
||||||
|
raise ValueError("public archive link escapes root")
|
||||||
|
parts.pop()
|
||||||
|
elif part != ".":
|
||||||
|
parts.append(part)
|
||||||
|
return member_bytes(archive, members, "/".join(parts), (*seen, name))
|
||||||
|
if not item.isfile() or item.size > 256 * 1024 * 1024:
|
||||||
|
raise ValueError("unsupported public archive member")
|
||||||
|
with archive.extractfile(item) as source:
|
||||||
|
data = source.read(item.size + 1)
|
||||||
|
if len(data) != item.size:
|
||||||
|
raise ValueError("public archive length")
|
||||||
|
return data, bool(item.mode & 0o111)
|
||||||
|
|
||||||
|
|
||||||
|
def put(root, name, data, executable=False):
|
||||||
|
if not canonical(name):
|
||||||
|
raise ValueError("noncanonical image path")
|
||||||
|
target = root / name
|
||||||
|
if target.exists():
|
||||||
|
raise ValueError("duplicate image path")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(data)
|
||||||
|
target.chmod(0o555 if executable else 0o444)
|
||||||
|
|
||||||
|
|
||||||
|
def selected(language, relative):
|
||||||
|
if language == "python313":
|
||||||
|
if relative == "bin/python3.13":
|
||||||
|
return "usr/bin/python3.13"
|
||||||
|
if relative.startswith("lib/"):
|
||||||
|
if not relative.startswith("lib/python3.13/") and relative not in {
|
||||||
|
"lib/libpython3.so",
|
||||||
|
"lib/libpython3.13.so",
|
||||||
|
"lib/libpython3.13.so.1.0",
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
parts = PurePosixPath(relative).parts
|
||||||
|
if any(
|
||||||
|
p
|
||||||
|
in {
|
||||||
|
"site-packages",
|
||||||
|
"__pycache__",
|
||||||
|
"ensurepip",
|
||||||
|
"test",
|
||||||
|
"tests",
|
||||||
|
"idlelib",
|
||||||
|
"tkinter",
|
||||||
|
}
|
||||||
|
for p in parts
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if relative.endswith((".pyc", ".pth", ".a")) or "_tkinter" in relative:
|
||||||
|
return None
|
||||||
|
if relative.endswith(("/sitecustomize.py", "/usercustomize.py")):
|
||||||
|
return None
|
||||||
|
return "usr/" + relative
|
||||||
|
return None
|
||||||
|
if relative in {"bin/java", "bin/javac", "release", "NOTICE"}:
|
||||||
|
return "usr/lib/jvm/" + relative
|
||||||
|
if relative.startswith(("lib/", "conf/", "legal/")):
|
||||||
|
if relative.endswith("src.zip") or PurePosixPath(relative).name in {
|
||||||
|
"libawt_xawt.so",
|
||||||
|
"libjawt.so",
|
||||||
|
"libsplashscreen.so",
|
||||||
|
"libjsound.so",
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
return "usr/lib/jvm/" + relative
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def support_file(args, name, records):
|
||||||
|
root = args.support_dir.resolve(strict=True)
|
||||||
|
source = (root / "usr/lib" / name).resolve(strict=True)
|
||||||
|
relative = source.relative_to(root).as_posix()
|
||||||
|
expected = records.get(relative)
|
||||||
|
if expected is None or expected.get("kind") != "file" or sha(source) != expected["sha256"]:
|
||||||
|
raise ValueError("support library differs from verified public manifest")
|
||||||
|
return source.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def dependencies(path):
|
||||||
|
with path.open("rb") as source:
|
||||||
|
if source.read(4) != b"\x7fELF":
|
||||||
|
return []
|
||||||
|
result = subprocess.run(
|
||||||
|
["readelf", "-d", str(path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
return re.findall(r"\(NEEDED\).*\[([^\]]+)\]", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def build(args):
|
||||||
|
pin = PINS[args.language]
|
||||||
|
if sha(args.archive) != pin["sha256"]:
|
||||||
|
raise ValueError("runtime archive does not match fixed upstream digest")
|
||||||
|
if sha(args.support_manifest) != args.support_manifest_sha256:
|
||||||
|
raise ValueError("support manifest fingerprint")
|
||||||
|
support = json.loads(args.support_manifest.read_text())
|
||||||
|
records = {v["path"]: v for v in support["files"]}
|
||||||
|
root = args.output
|
||||||
|
root.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||||
|
# Inflate once into a bounded anonymous file for random member/link reads.
|
||||||
|
# No archive member path is extracted during this conversion.
|
||||||
|
with tempfile.TemporaryFile() as unpacked:
|
||||||
|
with gzip.open(args.archive, "rb") as compressed:
|
||||||
|
total = 0
|
||||||
|
while data := compressed.read(1024 * 1024):
|
||||||
|
total += len(data)
|
||||||
|
if total > 512 * 1024 * 1024:
|
||||||
|
raise ValueError("public archive inflated byte ceiling")
|
||||||
|
unpacked.write(data)
|
||||||
|
unpacked.seek(0)
|
||||||
|
with tarfile.open(fileobj=unpacked, mode="r:") as archive:
|
||||||
|
copy_archive(args, pin, root, archive)
|
||||||
|
if args.language == "python313":
|
||||||
|
checker = (
|
||||||
|
Path(__file__).resolve().parents[1] / "judge/crates/sandbox/runtime/python_check.py"
|
||||||
|
)
|
||||||
|
put(root, "usr/lib/kf/python_check.py", checker.read_bytes())
|
||||||
|
finish(args, pin, root, records)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_archive(args, pin, root, archive):
|
||||||
|
members = {}
|
||||||
|
for item in archive.getmembers():
|
||||||
|
name = item.name.rstrip("/")
|
||||||
|
if (
|
||||||
|
name in members
|
||||||
|
or PurePosixPath(name).is_absolute()
|
||||||
|
or ".." in PurePosixPath(name).parts
|
||||||
|
):
|
||||||
|
raise ValueError("duplicate/noncanonical public archive member")
|
||||||
|
members[name] = item
|
||||||
|
for name in sorted(members):
|
||||||
|
relative = name.removeprefix(pin["prefix"])
|
||||||
|
if relative == name or members[name].isdir():
|
||||||
|
continue
|
||||||
|
destination = selected(args.language, relative)
|
||||||
|
if destination is not None:
|
||||||
|
data, executable = member_bytes(archive, members, name)
|
||||||
|
put(root, destination, data, executable)
|
||||||
|
|
||||||
|
|
||||||
|
def finish(args, pin, root, records):
|
||||||
|
# Flatten immutable image-local loader/libs; no symlink or host source is
|
||||||
|
# retained. Resolve ELF requirements from the image plus verified support.
|
||||||
|
put(
|
||||||
|
root,
|
||||||
|
"lib64/ld-linux-x86-64.so.2",
|
||||||
|
support_file(args, "ld-linux-x86-64.so.2", records),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
known = {p.name for p in root.rglob("*") if p.is_file()}
|
||||||
|
pending = [p for p in root.rglob("*") if p.is_file()]
|
||||||
|
copied = []
|
||||||
|
while pending:
|
||||||
|
source = pending.pop()
|
||||||
|
for name in dependencies(source):
|
||||||
|
if name.startswith("$ORIGIN/"):
|
||||||
|
target = (source.parent / name.removeprefix("$ORIGIN/")).resolve(strict=True)
|
||||||
|
relative = target.relative_to(root.resolve()).as_posix()
|
||||||
|
if not canonical(relative) or not target.is_file() or target.is_symlink():
|
||||||
|
raise ValueError("image-relative ELF dependency is not a listed regular file")
|
||||||
|
continue
|
||||||
|
if not canonical(name) or "/" in name:
|
||||||
|
raise ValueError("noncanonical ELF dependency")
|
||||||
|
if name in known:
|
||||||
|
continue
|
||||||
|
data = support_file(args, name, records)
|
||||||
|
put(root, "usr/lib/" + name, data, True)
|
||||||
|
known.add(name)
|
||||||
|
copied.append({"path": "usr/lib/" + name, "sha256": hashlib.sha256(data).hexdigest()})
|
||||||
|
pending.append(root / "usr/lib" / name)
|
||||||
|
for anchor in ["work", "tmp", "proc", "dev"]:
|
||||||
|
(root / anchor).mkdir()
|
||||||
|
files = []
|
||||||
|
directories = []
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
if path.is_symlink() or not canonical(relative):
|
||||||
|
raise ValueError("unexpected image inode/path")
|
||||||
|
if path.is_file():
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"path": relative,
|
||||||
|
"bytes": path.stat().st_size,
|
||||||
|
"sha256": sha(path),
|
||||||
|
"executable": bool(path.stat().st_mode & 0o111),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif path.is_dir():
|
||||||
|
directories.append(relative)
|
||||||
|
else:
|
||||||
|
raise ValueError("special image inode")
|
||||||
|
if len(files) > 4096 or sum(f["bytes"] for f in files) > 1024**3:
|
||||||
|
raise ValueError("image exceeds catalog capacity")
|
||||||
|
catalog = {
|
||||||
|
"format_version": 2,
|
||||||
|
"architecture": "x86_64",
|
||||||
|
"image_kind": pin["kind"],
|
||||||
|
"image_revision": 1,
|
||||||
|
"toolchain_id": pin["toolchain_id"],
|
||||||
|
"runtime_version": pin["version"],
|
||||||
|
"directories": sorted(directories),
|
||||||
|
"files": sorted(files, key=lambda f: f["path"]),
|
||||||
|
}
|
||||||
|
data = (
|
||||||
|
json.dumps(catalog, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + "\n"
|
||||||
|
).encode()
|
||||||
|
if len(data) > 1024**2:
|
||||||
|
raise ValueError("catalog exceeds bounded transport")
|
||||||
|
put(root, "manifest.json", data)
|
||||||
|
for directory in sorted((p for p in root.rglob("*") if p.is_dir()), reverse=True):
|
||||||
|
directory.chmod(0o555)
|
||||||
|
root.chmod(0o555)
|
||||||
|
identity = {
|
||||||
|
"toolchain_id": pin["toolchain_id"],
|
||||||
|
"image_kind": pin["kind"],
|
||||||
|
"image_revision": 1,
|
||||||
|
"runtime_version": pin["version"],
|
||||||
|
"image_digest": hashlib.sha256(data).hexdigest(),
|
||||||
|
}
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"runtime_identity": identity,
|
||||||
|
"archive": pin,
|
||||||
|
"support_manifest_sha256": args.support_manifest_sha256,
|
||||||
|
"files": len(files),
|
||||||
|
"bytes": sum(f["bytes"] for f in files),
|
||||||
|
"support_libraries": copied,
|
||||||
|
"source_execution": False,
|
||||||
|
"root_publication_and_VM_acceptance": False,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("language", choices=PINS)
|
||||||
|
parser.add_argument("--archive", required=True, type=Path)
|
||||||
|
parser.add_argument("--support-dir", required=True, type=Path)
|
||||||
|
parser.add_argument("--support-manifest", required=True, type=Path)
|
||||||
|
parser.add_argument("--support-manifest-sha256", required=True)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
build(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user