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;