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;