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