use kcode_k1_web_checker_protocol::{
Outcome, Report, Request, SCHEMA_VERSION, SelectionInput, WebIdInput, decode_report,
encode_report, encode_request,
};
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use sha2::{Digest, Sha256};
use std::error::Error;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::process::CommandExt;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
const CANDIDATE: &str = "/k1/input/candidate";
const ADMITTED: &str = "/k1/input/admitted";
const PROJECTION: &str = "/k1/input/public";
const CHECKER: &str = "/k1/bin/web-checker";
const DATA_OPTIONS: &str = "ro,nosuid,nodev,noexec";
const CHECKER_OPTIONS: &str = "ro,nosuid,nodev";
#[derive(Clone)]
pub struct WebPodmanConfig {
pub podman: PathBuf,
pub image: String,
pub checker: PathBuf,
pub chromium: PathBuf,
pub chromium_version: String,
pub cpu_millis: u32,
pub memory_bytes: u64,
pub pids_limit: u32,
pub tmpfs_bytes: u64,
pub shm_bytes: u64,
pub checker_timeout: Duration,
pub wall_timeout: Duration,
}
pub struct CheckInput {
pub candidate: WebIdInput,
pub candidate_root: PathBuf,
pub projection_root: PathBuf,
pub entry: String,
pub tests: String,
pub selections: Vec<SelectionInput>,
}
#[derive(Debug)]
pub struct CommandDiagnostics {
pub status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub struct CheckOutput {
pub diagnostics: CommandDiagnostics,
pub report: Report,
}
#[derive(Debug)]
pub enum WebPodmanError {
InvalidInput {
field: &'static str,
reason: String,
},
Spawn(io::Error),
Process(String),
Timeout {
diagnostics: CommandDiagnostics,
kill_failure: Option<String>,
},
Infrastructure {
diagnostics: CommandDiagnostics,
report: Option<Box<Report>>,
reason: String,
},
}
impl fmt::Display for WebPodmanError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{self:?}")
}
}
impl Error for WebPodmanError {}
pub struct WebPodman {
config: WebPodmanConfig,
checker_digest: String,
image_digest: String,
policy_identity: String,
frozen_policy_identity: String,
args: Vec<OsString>,
}
impl WebPodman {
pub fn new(mut config: WebPodmanConfig) -> Result<Self, WebPodmanError> {
config.podman = host_path(&config.podman, "podman", false, true)?;
config.checker = host_path(&config.checker, "checker", false, true)?;
container_path(&config.chromium, "chromium")?;
if config.chromium_version.is_empty()
|| config.chromium_version.trim() != config.chromium_version
|| config.chromium_version.chars().any(char::is_control)
{
return Err(invalid(
"chromium_version",
"must be nonempty trimmed text without controls",
));
}
let image_digest = image_identity(&config.image)?;
let timeout_ms = milliseconds(config.checker_timeout, "checker_timeout")?;
let wall_ms = milliseconds(config.wall_timeout, "wall_timeout")?;
if wall_ms < timeout_ms {
return Err(invalid("wall_timeout", "must not precede checker_timeout"));
}
let checker_digest = file_digest(&config.checker, "checker")?;
let args = run_args(&config);
let policy_identity = make_policy_identity(
b"k1-web-podman-policy-v1",
&args,
&[
CANDIDATE,
DATA_OPTIONS,
PROJECTION,
DATA_OPTIONS,
CHECKER,
CHECKER_OPTIONS,
],
&config.chromium,
timeout_ms,
wall_ms,
);
let frozen_policy_identity = make_policy_identity(
b"k1-web-podman-frozen-policy-v1",
&args,
&[
"candidate",
CANDIDATE,
DATA_OPTIONS,
"admitted",
ADMITTED,
DATA_OPTIONS,
"public",
PROJECTION,
DATA_OPTIONS,
"checker",
CHECKER,
CHECKER_OPTIONS,
"request_projection",
ADMITTED,
],
&config.chromium,
timeout_ms,
wall_ms,
);
Ok(Self {
config,
checker_digest,
image_digest,
policy_identity,
frozen_policy_identity,
args,
})
}
pub fn checker_digest(&self) -> &str {
&self.checker_digest
}
pub fn image_identity(&self) -> &str {
&self.config.image
}
pub fn image_digest(&self) -> &str {
&self.image_digest
}
pub fn chromium_version(&self) -> &str {
&self.config.chromium_version
}
pub fn command_policy_identity(&self) -> &str {
&self.policy_identity
}
pub fn frozen_command_policy_identity(&self) -> &str {
&self.frozen_policy_identity
}
pub fn check(&self, input: CheckInput) -> Result<CheckOutput, WebPodmanError> {
self.check_with_public(input, None)
}
pub fn check_frozen(
&self,
input: CheckInput,
public_projection_root: impl AsRef<Path>,
) -> Result<CheckOutput, WebPodmanError> {
self.check_with_public(input, Some(public_projection_root.as_ref()))
}
fn check_with_public(
&self,
input: CheckInput,
public_projection_root: Option<&Path>,
) -> Result<CheckOutput, WebPodmanError> {
let candidate = host_path(&input.candidate_root, "candidate_root", true, false)?;
let projection = host_path(&input.projection_root, "projection_root", true, false)?;
let public = public_projection_root
.map(|path| host_path(path, "public_projection_root", true, false))
.transpose()?;
if public.is_none()
&& (overlaps(&candidate, &projection)
|| self.config.checker.starts_with(&candidate)
|| self.config.checker.starts_with(&projection))
{
return Err(invalid("paths", "mounted host paths overlap"));
}
let checker = host_path(&self.config.checker, "checker", false, true)?;
if file_digest(&checker, "checker")? != self.checker_digest {
return Err(invalid("checker", "bytes changed after construction"));
}
let request_projection = if let Some(public) = &public {
let paths = [
candidate.as_path(),
projection.as_path(),
public.as_path(),
checker.as_path(),
];
let overlapping = paths
.iter()
.enumerate()
.any(|(index, left)| paths[index + 1..].iter().any(|right| overlaps(left, right)));
if overlapping {
return Err(invalid("paths", "mounted host paths overlap"));
}
ADMITTED
} else {
PROJECTION
};
let request = Request {
schema: SCHEMA_VERSION,
candidate: input.candidate,
candidate_root: PathBuf::from(CANDIDATE),
projection_root: PathBuf::from(request_projection),
entry: input.entry,
tests: input.tests,
selections: input.selections,
chromium: self.config.chromium.clone(),
timeout_ms: self.config.checker_timeout.as_millis() as u64,
};
let bytes = encode_request(&request)
.map_err(|source| invalid("request", format!("cannot encode: {source}")))?;
let mut command = Command::new(&self.config.podman);
command.args(&self.args);
volume(&mut command, &candidate, CANDIDATE, DATA_OPTIONS);
if let Some(public) = &public {
volume(&mut command, &projection, ADMITTED, DATA_OPTIONS);
volume(&mut command, public, PROJECTION, DATA_OPTIONS);
} else {
volume(&mut command, &projection, PROJECTION, DATA_OPTIONS);
}
volume(&mut command, &checker, CHECKER, CHECKER_OPTIONS);
command.arg("--").arg(&self.config.image).arg(CHECKER);
let diagnostics = self.execute(command, bytes)?;
let report = match decode_report(&diagnostics.stdout) {
Ok(report) => report,
Err(source) => {
return Err(infrastructure(
diagnostics,
None,
format!("invalid report: {source}"),
));
}
};
if report.schema != SCHEMA_VERSION {
return Err(infrastructure(
diagnostics,
Some(report),
"report schema mismatch",
));
}
let canonical = match encode_report(&report) {
Ok(canonical) => canonical,
Err(source) => {
return Err(infrastructure(
diagnostics,
None,
format!("cannot re-encode report: {source}"),
));
}
};
if canonical != diagnostics.stdout {
return Err(infrastructure(
diagnostics,
Some(report),
"report is not canonical",
));
}
let matching = matches!(
(diagnostics.status.code(), &report.outcome),
(Some(0), Outcome::Success) | (Some(1), Outcome::Failure { .. })
);
if !matching {
return Err(infrastructure(
diagnostics,
Some(report),
"status and outcome mismatch",
));
}
Ok(CheckOutput {
diagnostics,
report,
})
}
fn execute(
&self,
mut command: Command,
bytes: Vec<u8>,
) -> Result<CommandDiagnostics, WebPodmanError> {
command
.process_group(0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let started = Instant::now();
let mut child = command.spawn().map_err(WebPodmanError::Spawn)?;
let pid = Pid::from_raw(child.id() as i32);
let mut stdin = child.stdin.take().expect("piped stdin");
let writer = thread::spawn(move || stdin.write_all(&bytes));
let (sender, receiver) = mpsc::sync_channel(1);
let waiter = thread::spawn(move || sender.send(child.wait_with_output()));
let remaining = self.config.wall_timeout.saturating_sub(started.elapsed());
let (result, timed_out, kill_failure) = match receiver.recv_timeout(remaining) {
Ok(result) => (result, false, None),
Err(RecvTimeoutError::Timeout) => {
let failure = killpg(pid, Signal::SIGKILL)
.err()
.map(|source| source.to_string());
let result = receiver
.recv()
.map_err(|source| WebPodmanError::Process(source.to_string()))?;
(result, true, failure)
}
Err(source) => return Err(WebPodmanError::Process(source.to_string())),
};
waiter
.join()
.map_err(|_| WebPodmanError::Process("wait thread panicked".to_owned()))?
.map_err(|source| WebPodmanError::Process(source.to_string()))?;
let write_failure = match writer.join() {
Ok(Ok(())) => None,
Ok(Err(source)) => Some(format!("stdin: {source}")),
Err(_) => Some("stdin thread panicked".to_owned()),
};
let output = result.map_err(|source| WebPodmanError::Process(source.to_string()))?;
let diagnostics = CommandDiagnostics {
status: output.status,
stdout: output.stdout,
stderr: output.stderr,
};
if timed_out {
return Err(WebPodmanError::Timeout {
diagnostics,
kill_failure,
});
}
if let Some(reason) = write_failure {
return Err(infrastructure(diagnostics, None, reason));
}
Ok(diagnostics)
}
}
fn run_args(config: &WebPodmanConfig) -> Vec<OsString> {
let mut args = [
"--remote=false",
"run",
"--rm",
"--pull=never",
"--network=none",
"--read-only",
"--userns=keep-id",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--http-proxy=false",
"--ipc=private",
"--workdir=/tmp",
"--env=HOME=/tmp/home",
"--env=TMPDIR=/tmp",
]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
if config.cpu_millis != 0 {
args.push(
format!(
"--cpus={}.{:03}",
config.cpu_millis / 1000,
config.cpu_millis % 1000
)
.into(),
);
}
if config.memory_bytes != 0 {
args.push(format!("--memory={}", config.memory_bytes).into());
args.push(format!("--memory-swap={}", config.memory_bytes).into());
}
if config.pids_limit != 0 {
args.push(format!("--pids-limit={}", config.pids_limit).into());
}
let tmpfs = if config.tmpfs_bytes == 0 {
"--tmpfs=/tmp:rw,nosuid,nodev,noexec".to_owned()
} else {
format!(
"--tmpfs=/tmp:rw,nosuid,nodev,noexec,size={}",
config.tmpfs_bytes
)
};
args.push(tmpfs.into());
if config.shm_bytes != 0 {
args.push(format!("--shm-size={}", config.shm_bytes).into());
}
args
}
fn host_path(
path: &Path,
field: &'static str,
directory: bool,
executable: bool,
) -> Result<PathBuf, WebPodmanError> {
if !path.is_absolute() || path.as_os_str().as_bytes().contains(&b':') {
return Err(invalid(field, "must be an absolute colon-free path"));
}
let metadata = fs::symlink_metadata(path)
.map_err(|source| invalid(field, format!("metadata failed: {source}")))?;
let ordinary = if directory {
metadata.is_dir()
} else {
metadata.is_file()
};
if metadata.file_type().is_symlink() || !ordinary {
return Err(invalid(
field,
"must be an ordinary nonsymlink path of the required kind",
));
}
if executable && metadata.permissions().mode() & 0o111 == 0 {
return Err(invalid(field, "must be executable"));
}
let canonical = fs::canonicalize(path)
.map_err(|source| invalid(field, format!("canonicalization failed: {source}")))?;
if canonical != path {
return Err(invalid(
field,
"must be canonical and contain no symlink component",
));
}
Ok(canonical)
}
fn container_path(path: &Path, field: &'static str) -> Result<(), WebPodmanError> {
let clean = path.is_absolute()
&& path.to_str().is_some()
&& path
.components()
.filter(|part| matches!(part, Component::Normal(_)))
.count()
> 0
&& path
.components()
.all(|part| matches!(part, Component::RootDir | Component::Normal(_)));
if clean {
Ok(())
} else {
Err(invalid(field, "must be a normalized absolute UTF-8 path"))
}
}
fn image_identity(value: &str) -> Result<String, WebPodmanError> {
if value.is_empty()
|| value.trim() != value
|| value.bytes().any(|byte| !byte.is_ascii_graphic())
{
return Err(invalid(
"image",
"must be a nonempty trimmed ordinary image reference",
));
}
if let Some((name, digest)) = value.rsplit_once("@sha256:") {
let canonical = !name.is_empty()
&& !name.contains('@')
&& digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
if canonical {
return Ok(format!("sha256:{digest}"));
}
}
let mut hash = Sha256::new();
add(&mut hash, b"k1-web-podman-image-reference-v1");
add(&mut hash, value.as_bytes());
Ok(finish(hash))
}
fn milliseconds(value: Duration, field: &'static str) -> Result<u64, WebPodmanError> {
let millis = value.as_millis();
if value.is_zero()
|| !value.subsec_nanos().is_multiple_of(1_000_000)
|| millis > u64::MAX as u128
{
Err(invalid(
field,
"must be a positive whole number of milliseconds",
))
} else {
Ok(millis as u64)
}
}
fn file_digest(path: &Path, field: &'static str) -> Result<String, WebPodmanError> {
let bytes =
fs::read(path).map_err(|source| invalid(field, format!("read failed: {source}")))?;
let mut hash = Sha256::new();
hash.update(bytes);
Ok(finish(hash))
}
fn overlaps(left: &Path, right: &Path) -> bool {
left.starts_with(right) || right.starts_with(left)
}
fn volume(command: &mut Command, host: &Path, target: &str, options: &str) {
let mut value = host.as_os_str().to_os_string();
value.push(format!(":{target}:{options}"));
command.arg("--volume").arg(value);
}
fn make_policy_identity(
tag: &[u8],
args: &[OsString],
bindings: &[&str],
chromium: &Path,
checker_ms: u64,
wall_ms: u64,
) -> String {
let mut hash = Sha256::new();
add(&mut hash, tag);
for arg in args {
add(&mut hash, arg.as_bytes());
}
for value in bindings {
add(&mut hash, value.as_bytes());
}
add(&mut hash, chromium.as_os_str().as_bytes());
add(&mut hash, &checker_ms.to_be_bytes());
add(&mut hash, &wall_ms.to_be_bytes());
finish(hash)
}
fn add(hash: &mut Sha256, value: &[u8]) {
hash.update((value.len() as u64).to_be_bytes());
hash.update(value);
}
fn finish(hash: Sha256) -> String {
let mut value = String::from("sha256:");
for byte in hash.finalize() {
value.push_str(&format!("{byte:02x}"));
}
value
}
fn invalid(field: &'static str, reason: impl Into<String>) -> WebPodmanError {
WebPodmanError::InvalidInput {
field,
reason: reason.into(),
}
}
fn infrastructure(
diagnostics: CommandDiagnostics,
report: Option<Report>,
reason: impl Into<String>,
) -> WebPodmanError {
WebPodmanError::Infrastructure {
diagnostics,
report: report.map(Box::new),
reason: reason.into(),
}
}
#[cfg(test)]
mod tests;