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