Skip to main content

kcode_k1_web_podman/
lib.rs

1use kcode_k1_web_checker_protocol::{
2    Outcome, Report, Request, SCHEMA_VERSION, SelectionInput, WebIdInput, decode_report,
3    encode_report, encode_request,
4};
5use nix::sys::signal::{Signal, killpg};
6use nix::unistd::Pid;
7use sha2::{Digest, Sha256};
8use std::error::Error;
9use std::ffi::OsString;
10use std::fmt;
11use std::fs;
12use std::io::{self, Write};
13use std::os::unix::ffi::OsStrExt;
14use std::os::unix::fs::PermissionsExt;
15use std::os::unix::process::CommandExt;
16use std::path::{Component, Path, PathBuf};
17use std::process::{Command, ExitStatus, Stdio};
18use std::sync::mpsc::{self, RecvTimeoutError};
19use std::thread;
20use std::time::{Duration, Instant};
21
22const CANDIDATE: &str = "/k1/input/candidate";
23const PROJECTION: &str = "/k1/input/public";
24const CHECKER: &str = "/k1/bin/web-checker";
25const DATA_OPTIONS: &str = "ro,nosuid,nodev,noexec";
26const CHECKER_OPTIONS: &str = "ro,nosuid,nodev";
27
28pub struct WebPodmanConfig {
29    pub podman: PathBuf,
30    pub image: String,
31    pub checker: PathBuf,
32    pub chromium: PathBuf,
33    pub chromium_version: String,
34    pub cpu_millis: u32,
35    pub memory_bytes: u64,
36    pub pids_limit: u32,
37    pub tmpfs_bytes: u64,
38    pub shm_bytes: u64,
39    pub checker_timeout: Duration,
40    pub wall_timeout: Duration,
41}
42
43pub struct CheckInput {
44    pub candidate: WebIdInput,
45    pub candidate_root: PathBuf,
46    pub projection_root: PathBuf,
47    pub entry: String,
48    pub tests: String,
49    pub selections: Vec<SelectionInput>,
50}
51
52#[derive(Debug)]
53pub struct CommandDiagnostics {
54    pub status: ExitStatus,
55    pub stdout: Vec<u8>,
56    pub stderr: Vec<u8>,
57}
58
59pub struct CheckOutput {
60    pub diagnostics: CommandDiagnostics,
61    pub report: Report,
62}
63
64#[derive(Debug)]
65pub enum WebPodmanError {
66    InvalidInput {
67        field: &'static str,
68        reason: String,
69    },
70    Spawn(io::Error),
71    Process(String),
72    Timeout {
73        diagnostics: CommandDiagnostics,
74        kill_failure: Option<String>,
75    },
76    Infrastructure {
77        diagnostics: CommandDiagnostics,
78        report: Option<Box<Report>>,
79        reason: String,
80    },
81}
82
83impl fmt::Display for WebPodmanError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(f, "{self:?}")
86    }
87}
88
89impl Error for WebPodmanError {}
90
91pub struct WebPodman {
92    config: WebPodmanConfig,
93    checker_digest: String,
94    policy_identity: String,
95    args: Vec<OsString>,
96}
97
98impl WebPodman {
99    pub fn new(mut config: WebPodmanConfig) -> Result<Self, WebPodmanError> {
100        config.podman = host_path(&config.podman, "podman", false, true)?;
101        config.checker = host_path(&config.checker, "checker", false, true)?;
102        container_path(&config.chromium, "chromium")?;
103        if config.chromium_version.is_empty()
104            || config.chromium_version.trim() != config.chromium_version
105            || config.chromium_version.chars().any(char::is_control)
106        {
107            return Err(invalid(
108                "chromium_version",
109                "must be nonempty trimmed text without controls",
110            ));
111        }
112        pinned_image(&config.image)?;
113        for (field, value) in [
114            ("cpu_millis", config.cpu_millis as u64),
115            ("memory_bytes", config.memory_bytes),
116            ("pids_limit", config.pids_limit as u64),
117            ("tmpfs_bytes", config.tmpfs_bytes),
118            ("shm_bytes", config.shm_bytes),
119        ] {
120            if value == 0 {
121                return Err(invalid(field, "must be nonzero"));
122            }
123        }
124        let timeout_ms = milliseconds(config.checker_timeout, "checker_timeout")?;
125        let wall_ms = milliseconds(config.wall_timeout, "wall_timeout")?;
126        if wall_ms <= timeout_ms {
127            return Err(invalid("wall_timeout", "must exceed checker_timeout"));
128        }
129        let checker_digest = file_digest(&config.checker, "checker")?;
130        let args = run_args(&config);
131        let policy_identity = policy_identity(&args, &config.chromium, timeout_ms, wall_ms);
132        Ok(Self {
133            config,
134            checker_digest,
135            policy_identity,
136            args,
137        })
138    }
139
140    pub fn checker_digest(&self) -> &str {
141        &self.checker_digest
142    }
143    pub fn image_identity(&self) -> &str {
144        &self.config.image
145    }
146    pub fn image_digest(&self) -> &str {
147        &self.config.image[self.config.image.len() - 71..]
148    }
149    pub fn chromium_version(&self) -> &str {
150        &self.config.chromium_version
151    }
152    pub fn command_policy_identity(&self) -> &str {
153        &self.policy_identity
154    }
155
156    pub fn check(&self, input: CheckInput) -> Result<CheckOutput, WebPodmanError> {
157        let candidate = host_path(&input.candidate_root, "candidate_root", true, false)?;
158        let projection = host_path(&input.projection_root, "projection_root", true, false)?;
159        if overlaps(&candidate, &projection)
160            || self.config.checker.starts_with(&candidate)
161            || self.config.checker.starts_with(&projection)
162        {
163            return Err(invalid("paths", "mounted host paths overlap"));
164        }
165        let checker = host_path(&self.config.checker, "checker", false, true)?;
166        if file_digest(&checker, "checker")? != self.checker_digest {
167            return Err(invalid("checker", "bytes changed after construction"));
168        }
169        let request = Request {
170            schema: SCHEMA_VERSION,
171            candidate: input.candidate,
172            candidate_root: PathBuf::from(CANDIDATE),
173            projection_root: PathBuf::from(PROJECTION),
174            entry: input.entry,
175            tests: input.tests,
176            selections: input.selections,
177            chromium: self.config.chromium.clone(),
178            timeout_ms: self.config.checker_timeout.as_millis() as u64,
179        };
180        let bytes = encode_request(&request)
181            .map_err(|source| invalid("request", format!("cannot encode: {source}")))?;
182        let mut command = Command::new(&self.config.podman);
183        command.args(&self.args);
184        volume(&mut command, &candidate, CANDIDATE, DATA_OPTIONS);
185        volume(&mut command, &projection, PROJECTION, DATA_OPTIONS);
186        volume(&mut command, &checker, CHECKER, CHECKER_OPTIONS);
187        command.arg("--").arg(&self.config.image).arg(CHECKER);
188        let diagnostics = self.execute(command, bytes)?;
189        let report = match decode_report(&diagnostics.stdout) {
190            Ok(report) => report,
191            Err(source) => {
192                return Err(infrastructure(
193                    diagnostics,
194                    None,
195                    format!("invalid report: {source}"),
196                ));
197            }
198        };
199        if report.schema != SCHEMA_VERSION {
200            return Err(infrastructure(
201                diagnostics,
202                Some(report),
203                "report schema mismatch",
204            ));
205        }
206        let canonical = match encode_report(&report) {
207            Ok(canonical) => canonical,
208            Err(source) => {
209                return Err(infrastructure(
210                    diagnostics,
211                    None,
212                    format!("cannot re-encode report: {source}"),
213                ));
214            }
215        };
216        if canonical != diagnostics.stdout {
217            return Err(infrastructure(
218                diagnostics,
219                Some(report),
220                "report is not canonical",
221            ));
222        }
223        let matching = matches!(
224            (diagnostics.status.code(), &report.outcome),
225            (Some(0), Outcome::Success) | (Some(1), Outcome::Failure { .. })
226        );
227        if !matching {
228            return Err(infrastructure(
229                diagnostics,
230                Some(report),
231                "status and outcome mismatch",
232            ));
233        }
234        Ok(CheckOutput {
235            diagnostics,
236            report,
237        })
238    }
239
240    fn execute(
241        &self,
242        mut command: Command,
243        bytes: Vec<u8>,
244    ) -> Result<CommandDiagnostics, WebPodmanError> {
245        command
246            .process_group(0)
247            .stdin(Stdio::piped())
248            .stdout(Stdio::piped())
249            .stderr(Stdio::piped());
250        let started = Instant::now();
251        let mut child = command.spawn().map_err(WebPodmanError::Spawn)?;
252        let pid = Pid::from_raw(child.id() as i32);
253        let mut stdin = child.stdin.take().expect("piped stdin");
254        let writer = thread::spawn(move || stdin.write_all(&bytes));
255        let (sender, receiver) = mpsc::sync_channel(1);
256        let waiter = thread::spawn(move || sender.send(child.wait_with_output()));
257        let remaining = self.config.wall_timeout.saturating_sub(started.elapsed());
258        let (result, timed_out, kill_failure) = match receiver.recv_timeout(remaining) {
259            Ok(result) => (result, false, None),
260            Err(RecvTimeoutError::Timeout) => {
261                let failure = killpg(pid, Signal::SIGKILL)
262                    .err()
263                    .map(|source| source.to_string());
264                let result = receiver
265                    .recv()
266                    .map_err(|source| WebPodmanError::Process(source.to_string()))?;
267                (result, true, failure)
268            }
269            Err(source) => return Err(WebPodmanError::Process(source.to_string())),
270        };
271        waiter
272            .join()
273            .map_err(|_| WebPodmanError::Process("wait thread panicked".to_owned()))?
274            .map_err(|source| WebPodmanError::Process(source.to_string()))?;
275        let write_failure = match writer.join() {
276            Ok(Ok(())) => None,
277            Ok(Err(source)) => Some(format!("stdin: {source}")),
278            Err(_) => Some("stdin thread panicked".to_owned()),
279        };
280        let output = result.map_err(|source| WebPodmanError::Process(source.to_string()))?;
281        let diagnostics = CommandDiagnostics {
282            status: output.status,
283            stdout: output.stdout,
284            stderr: output.stderr,
285        };
286        if timed_out {
287            return Err(WebPodmanError::Timeout {
288                diagnostics,
289                kill_failure,
290            });
291        }
292        if let Some(reason) = write_failure {
293            return Err(infrastructure(diagnostics, None, reason));
294        }
295        Ok(diagnostics)
296    }
297}
298
299fn run_args(config: &WebPodmanConfig) -> Vec<OsString> {
300    let cpu = format!(
301        "{}.{:03}",
302        config.cpu_millis / 1000,
303        config.cpu_millis % 1000
304    );
305    [
306        "--remote=false".to_owned(),
307        "run".to_owned(),
308        "--rm".to_owned(),
309        "--pull=never".to_owned(),
310        "--network=none".to_owned(),
311        "--read-only".to_owned(),
312        "--userns=keep-id".to_owned(),
313        "--cap-drop=ALL".to_owned(),
314        "--security-opt=no-new-privileges".to_owned(),
315        "--http-proxy=false".to_owned(),
316        "--ipc=private".to_owned(),
317        "--workdir=/tmp".to_owned(),
318        "--env=HOME=/tmp/home".to_owned(),
319        "--env=TMPDIR=/tmp".to_owned(),
320        format!("--cpus={cpu}"),
321        format!("--memory={}", config.memory_bytes),
322        format!("--memory-swap={}", config.memory_bytes),
323        format!("--pids-limit={}", config.pids_limit),
324        format!(
325            "--tmpfs=/tmp:rw,nosuid,nodev,noexec,size={}",
326            config.tmpfs_bytes
327        ),
328        format!("--shm-size={}", config.shm_bytes),
329    ]
330    .into_iter()
331    .map(OsString::from)
332    .collect()
333}
334
335fn host_path(
336    path: &Path,
337    field: &'static str,
338    directory: bool,
339    executable: bool,
340) -> Result<PathBuf, WebPodmanError> {
341    if !path.is_absolute() || path.as_os_str().as_bytes().contains(&b':') {
342        return Err(invalid(field, "must be an absolute colon-free path"));
343    }
344    let metadata = fs::symlink_metadata(path)
345        .map_err(|source| invalid(field, format!("metadata failed: {source}")))?;
346    let ordinary = if directory {
347        metadata.is_dir()
348    } else {
349        metadata.is_file()
350    };
351    if metadata.file_type().is_symlink() || !ordinary {
352        return Err(invalid(
353            field,
354            "must be an ordinary nonsymlink path of the required kind",
355        ));
356    }
357    if executable && metadata.permissions().mode() & 0o111 == 0 {
358        return Err(invalid(field, "must be executable"));
359    }
360    let canonical = fs::canonicalize(path)
361        .map_err(|source| invalid(field, format!("canonicalization failed: {source}")))?;
362    if canonical != path {
363        return Err(invalid(
364            field,
365            "must be canonical and contain no symlink component",
366        ));
367    }
368    Ok(canonical)
369}
370
371fn container_path(path: &Path, field: &'static str) -> Result<(), WebPodmanError> {
372    let clean = path.is_absolute()
373        && path.to_str().is_some()
374        && path
375            .components()
376            .filter(|part| matches!(part, Component::Normal(_)))
377            .count()
378            > 0
379        && path
380            .components()
381            .all(|part| matches!(part, Component::RootDir | Component::Normal(_)));
382    if clean {
383        Ok(())
384    } else {
385        Err(invalid(field, "must be a normalized absolute UTF-8 path"))
386    }
387}
388
389fn pinned_image(value: &str) -> Result<(), WebPodmanError> {
390    let Some((name, digest)) = value.rsplit_once("@sha256:") else {
391        return Err(invalid("image", "must contain an exact sha256 digest"));
392    };
393    let valid = !name.is_empty()
394        && !name.contains('@')
395        && value.bytes().all(|byte| byte.is_ascii_graphic())
396        && digest.len() == 64
397        && digest
398            .bytes()
399            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
400    if valid {
401        Ok(())
402    } else {
403        Err(invalid(
404            "image",
405            "must be an exact lowercase sha256 image reference",
406        ))
407    }
408}
409
410fn milliseconds(value: Duration, field: &'static str) -> Result<u64, WebPodmanError> {
411    let millis = value.as_millis();
412    if value.is_zero()
413        || !value.subsec_nanos().is_multiple_of(1_000_000)
414        || millis > u64::MAX as u128
415    {
416        Err(invalid(
417            field,
418            "must be a positive whole number of milliseconds",
419        ))
420    } else {
421        Ok(millis as u64)
422    }
423}
424
425fn file_digest(path: &Path, field: &'static str) -> Result<String, WebPodmanError> {
426    let bytes =
427        fs::read(path).map_err(|source| invalid(field, format!("read failed: {source}")))?;
428    let mut hash = Sha256::new();
429    hash.update(bytes);
430    Ok(finish(hash))
431}
432
433fn overlaps(left: &Path, right: &Path) -> bool {
434    left.starts_with(right) || right.starts_with(left)
435}
436
437fn volume(command: &mut Command, host: &Path, target: &str, options: &str) {
438    let mut value = host.as_os_str().to_os_string();
439    value.push(format!(":{target}:{options}"));
440    command.arg("--volume").arg(value);
441}
442
443fn policy_identity(args: &[OsString], chromium: &Path, checker_ms: u64, wall_ms: u64) -> String {
444    let mut hash = Sha256::new();
445    add(&mut hash, b"k1-web-podman-policy-v1");
446    for arg in args {
447        add(&mut hash, arg.as_bytes());
448    }
449    for value in [
450        CANDIDATE,
451        DATA_OPTIONS,
452        PROJECTION,
453        DATA_OPTIONS,
454        CHECKER,
455        CHECKER_OPTIONS,
456    ] {
457        add(&mut hash, value.as_bytes());
458    }
459    add(&mut hash, chromium.as_os_str().as_bytes());
460    add(&mut hash, &checker_ms.to_be_bytes());
461    add(&mut hash, &wall_ms.to_be_bytes());
462    finish(hash)
463}
464
465fn add(hash: &mut Sha256, value: &[u8]) {
466    hash.update((value.len() as u64).to_be_bytes());
467    hash.update(value);
468}
469
470fn finish(hash: Sha256) -> String {
471    let mut value = String::from("sha256:");
472    for byte in hash.finalize() {
473        value.push_str(&format!("{byte:02x}"));
474    }
475    value
476}
477
478fn invalid(field: &'static str, reason: impl Into<String>) -> WebPodmanError {
479    WebPodmanError::InvalidInput {
480        field,
481        reason: reason.into(),
482    }
483}
484
485fn infrastructure(
486    diagnostics: CommandDiagnostics,
487    report: Option<Report>,
488    reason: impl Into<String>,
489) -> WebPodmanError {
490    WebPodmanError::Infrastructure {
491        diagnostics,
492        report: report.map(Box::new),
493        reason: reason.into(),
494    }
495}
496
497#[cfg(test)]
498mod tests;