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;