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