1use std::error::Error as StdError;
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::process::{Command, ExitStatus};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use kcode_rust_source::Source;
14
15const CONTAINERFILE: &str = include_str!("assets/Containerfile");
16const CHROMIUM_OUTER_SANDBOX_CONFIG: &str = include_str!("assets/ChromiumOuterSandbox.conf");
17static UNIQUE: AtomicU64 = AtomicU64::new(0);
18
19pub struct Error(String);
21
22pub type Result<T> = std::result::Result<T, Error>;
24
25impl Error {
26 fn new(category: &str, message: impl fmt::Display) -> Self {
27 Self(format!("{category}: {message}"))
28 }
29
30 fn io(operation: &str, path: impl AsRef<Path>, source: io::Error) -> Self {
31 Self::new(
32 "io",
33 format!("{operation} at {}: {source}", path.as_ref().display()),
34 )
35 }
36
37 fn redact(mut self, secret: &str) -> Self {
38 if !secret.is_empty() {
39 self.0 = self.0.replace(secret, "[REDACTED]");
40 }
41 self
42 }
43}
44
45impl fmt::Display for Error {
46 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
47 formatter.write_str(&self.0)
48 }
49}
50
51impl fmt::Debug for Error {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 formatter.debug_tuple("Error").field(&self.0).finish()
54 }
55}
56
57impl StdError for Error {}
58
59pub fn check(source: &Source) -> Result<()> {
61 check_with(source, OsStr::new("podman"), &tool_image())
62}
63
64pub fn publish(source: &Source, crates_io_registry_token: &str) -> Result<()> {
66 publish_with(
67 source,
68 crates_io_registry_token,
69 OsStr::new("podman"),
70 &tool_image(),
71 )
72}
73
74fn check_with(source: &Source, podman: &OsStr, image: &str) -> Result<()> {
75 checked_run(source, podman, image).map(|_| ())
76}
77
78fn checked_run(source: &Source, podman: &OsStr, image: &str) -> Result<RunDirectory> {
79 ensure_image(podman, image)?;
80 let run = RunDirectory::new(source, "check")?;
81 for stage in stages() {
82 run_stage(podman, image, &run, stage)?;
83 }
84 Ok(run)
85}
86
87fn publish_with(source: &Source, token: &str, podman: &OsStr, image: &str) -> Result<()> {
88 let token = token.trim();
89 if token.is_empty() {
90 return Err(Error::new(
91 "invalid_token",
92 "the crates.io registry token is empty",
93 ));
94 }
95
96 let run = checked_run(source, podman, image)?;
97 let mut command = podman_command(podman, &run, "/tmp", true);
98 command
99 .env("CARGO_REGISTRY_TOKEN", token)
100 .arg("--env=CARGO_REGISTRY_TOKEN")
101 .arg(image)
102 .arg("cargo")
103 .arg("--quiet")
104 .arg("--config")
105 .arg("registry.global-credential-providers=[\"cargo:token\"]")
106 .arg("publish")
107 .arg("--manifest-path=/workspace/Cargo.toml")
108 .arg("--registry=crates-io")
109 .arg("--no-verify")
110 .arg("--color=never");
111 run_checked(command, "publish", "cargo publish").map_err(|error| error.redact(token))
112}
113
114#[derive(Clone, Copy)]
115struct Stage {
116 category: &'static str,
117 label: &'static str,
118 arguments: &'static [&'static str],
119 network: bool,
120}
121
122fn stages() -> [Stage; 6] {
123 [
124 Stage {
125 category: "check.fetch",
126 label: "cargo fetch",
127 arguments: &["fetch", "--color", "never"],
128 network: true,
129 },
130 Stage {
131 category: "check.format",
132 label: "cargo fmt",
133 arguments: &["fmt", "--all"],
134 network: false,
135 },
136 Stage {
137 category: "check.build",
138 label: "cargo build",
139 arguments: &[
140 "build",
141 "--workspace",
142 "--all-targets",
143 "--all-features",
144 "--locked",
145 "--offline",
146 "--color",
147 "never",
148 ],
149 network: false,
150 },
151 Stage {
152 category: "check.clippy",
153 label: "cargo clippy",
154 arguments: &[
155 "clippy",
156 "--workspace",
157 "--all-targets",
158 "--all-features",
159 "--locked",
160 "--offline",
161 "--color",
162 "never",
163 "--",
164 "-D",
165 "warnings",
166 ],
167 network: false,
168 },
169 Stage {
170 category: "check.test",
171 label: "cargo test",
172 arguments: &[
173 "test",
174 "--workspace",
175 "--all-targets",
176 "--all-features",
177 "--locked",
178 "--offline",
179 "--no-fail-fast",
180 "--color",
181 "never",
182 ],
183 network: false,
184 },
185 Stage {
186 category: "check.doc_test",
187 label: "cargo doc tests",
188 arguments: &[
189 "test",
190 "--workspace",
191 "--all-features",
192 "--doc",
193 "--locked",
194 "--offline",
195 "--no-fail-fast",
196 "--color",
197 "never",
198 ],
199 network: false,
200 },
201 ]
202}
203
204fn run_stage(podman: &OsStr, image: &str, run: &RunDirectory, stage: Stage) -> Result<()> {
205 let mut command = podman_command(podman, run, "/workspace", stage.network);
206 command
207 .arg(image)
208 .arg("cargo")
209 .arg("--quiet")
210 .args(stage.arguments);
211 run_checked(command, stage.category, stage.label)
212}
213
214fn ensure_image(podman: &OsStr, image: &str) -> Result<()> {
215 let mut inspect = Command::new(podman);
216 inspect.arg("image").arg("exists").arg(image);
217 let inspected = run_capture(inspect, "inspect Podman image")?;
218 if inspected.status.success() {
219 return Ok(());
220 }
221 if inspected.status.code() != Some(1) {
222 return Err(command_error(
223 "sandbox.image",
224 "Podman image inspection",
225 inspected,
226 ));
227 }
228
229 let build = TemporaryDirectory::new("image")?;
230 let containerfile = build.path().join("Containerfile");
231 fs::write(&containerfile, CONTAINERFILE)
232 .map_err(|error| Error::io("write embedded Containerfile", &containerfile, error))?;
233 let chromium_config = build.path().join("ChromiumOuterSandbox.conf");
234 fs::write(&chromium_config, CHROMIUM_OUTER_SANDBOX_CONFIG).map_err(|error| {
235 Error::io(
236 "write embedded Chromium outer-sandbox configuration",
237 &chromium_config,
238 error,
239 )
240 })?;
241
242 let mut command = Command::new(podman);
243 command
244 .arg("build")
245 .arg("--tag")
246 .arg(image)
247 .arg("--file")
248 .arg(&containerfile)
249 .arg(build.path());
250 run_checked(command, "sandbox.image", "Podman image build")
251}
252
253fn podman_command(podman: &OsStr, run: &RunDirectory, workdir: &str, network: bool) -> Command {
254 let mut command = Command::new(podman);
255 command
256 .arg("run")
257 .arg("--rm")
258 .arg("--read-only")
259 .arg("--cap-drop=all")
260 .arg("--security-opt=no-new-privileges")
261 .arg("--userns=keep-id")
262 .arg("--tmpfs=/tmp:rw,nosuid,nodev")
263 .arg("--workdir")
264 .arg(workdir)
265 .arg("--env=CARGO_HOME=/cargo-home")
266 .arg("--env=CARGO_TARGET_DIR=/target")
267 .arg("--env=CARGO_TERM_COLOR=never");
268 if !network {
269 command.arg("--network=none");
270 }
271 command
272 .arg("--pull=never")
273 .arg("--volume")
274 .arg(volume_spec(&run.workspace, "/workspace"))
275 .arg("--volume")
276 .arg(volume_spec(&run.cargo_home, "/cargo-home"))
277 .arg("--volume")
278 .arg(volume_spec(&run.target, "/target"));
279 command
280}
281
282fn volume_spec(source: &Path, destination: &str) -> OsString {
283 let mut specification = source.as_os_str().to_os_string();
284 specification.push(":");
285 specification.push(destination);
286 specification.push(":rw,Z");
287 specification
288}
289
290fn run_checked(command: Command, category: &'static str, label: &'static str) -> Result<()> {
291 let output = run_capture(command, label)?;
292 if output.status.success() {
293 Ok(())
294 } else {
295 Err(command_error(category, label, output))
296 }
297}
298
299fn run_capture(mut command: Command, label: &str) -> Result<CapturedOutput> {
300 let output = command
301 .output()
302 .map_err(|error| Error::new("sandbox.start", format!("could not run {label}: {error}")))?;
303 Ok(CapturedOutput {
304 status: output.status,
305 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
306 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
307 })
308}
309
310fn command_error(category: &str, label: &str, output: CapturedOutput) -> Error {
311 let status = output
312 .status
313 .code()
314 .map_or_else(|| "signal".to_owned(), |code| code.to_string());
315 Error::new(
316 category,
317 format!(
318 "{label} exited with {status}\n--- stdout ---\n{}\n--- stderr ---\n{}",
319 output.stdout, output.stderr
320 ),
321 )
322}
323
324struct CapturedOutput {
325 status: ExitStatus,
326 stdout: String,
327 stderr: String,
328}
329
330struct RunDirectory {
331 _root: TemporaryDirectory,
332 workspace: PathBuf,
333 cargo_home: PathBuf,
334 target: PathBuf,
335}
336
337impl RunDirectory {
338 fn new(source: &Source, label: &str) -> Result<Self> {
339 let root = TemporaryDirectory::new(label)?;
340 let workspace = root.path().join("workspace");
341 let cargo_home = root.path().join("cargo-home");
342 let target = root.path().join("target");
343 fs::create_dir(&workspace)
344 .map_err(|error| Error::io("create disposable workspace", &workspace, error))?;
345 materialize(&workspace, source)?;
346 fs::create_dir(&cargo_home)
347 .map_err(|error| Error::io("create disposable Cargo home", &cargo_home, error))?;
348 fs::create_dir(&target)
349 .map_err(|error| Error::io("create disposable target", &target, error))?;
350 Ok(Self {
351 _root: root,
352 workspace,
353 cargo_home,
354 target,
355 })
356 }
357}
358
359fn materialize(root: &Path, source: &Source) -> Result<()> {
360 for file in source.files() {
361 let destination = root.join(&file.path);
362 let parent = destination.parent().ok_or_else(|| {
363 Error::new(
364 "unsafe_path",
365 format!("source path has no parent: {:?}", file.path),
366 )
367 })?;
368 fs::create_dir_all(parent)
369 .map_err(|error| Error::io("create source parent", parent, error))?;
370 fs::write(&destination, file.contents.as_bytes())
371 .map_err(|error| Error::io("write source file", &destination, error))?;
372 }
373 Ok(())
374}
375
376struct TemporaryDirectory(PathBuf);
377
378impl TemporaryDirectory {
379 fn new(label: &str) -> Result<Self> {
380 let parent = std::env::temp_dir().join("kcode-rust-library-toolchain");
381 fs::create_dir_all(&parent)
382 .map_err(|error| Error::io("create temporary work root", &parent, error))?;
383 loop {
384 let nanos = SystemTime::now()
385 .duration_since(UNIX_EPOCH)
386 .unwrap_or_default()
387 .as_nanos();
388 let counter = UNIQUE.fetch_add(1, Ordering::Relaxed);
389 let path = parent.join(format!(
390 "{label}-{}-{nanos:x}-{counter:x}",
391 std::process::id()
392 ));
393 match fs::create_dir(&path) {
394 Ok(()) => return Ok(Self(path)),
395 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
396 Err(error) => {
397 return Err(Error::io("create temporary directory", path, error));
398 }
399 }
400 }
401 }
402
403 fn path(&self) -> &Path {
404 &self.0
405 }
406}
407
408impl Drop for TemporaryDirectory {
409 fn drop(&mut self) {
410 let _ = fs::remove_dir_all(&self.0);
411 }
412}
413
414fn tool_image() -> String {
415 let mut hash = 0xcbf29ce484222325_u64;
416 for byte in CONTAINERFILE
417 .bytes()
418 .chain(CHROMIUM_OUTER_SANDBOX_CONFIG.bytes())
419 {
420 hash ^= u64::from(byte);
421 hash = hash.wrapping_mul(0x100000001b3);
422 }
423 format!(
424 "localhost/kcode-rust-library-toolchain:{}-{hash:016x}",
425 env!("CARGO_PKG_VERSION")
426 )
427}
428
429#[cfg(all(test, unix))]
430mod tests {
431 use std::ffi::OsStr;
432 use std::fs;
433 use std::os::unix::fs::PermissionsExt;
434 use std::path::{Path, PathBuf};
435
436 use kcode_rust_source::{File, Source};
437
438 use super::{RunDirectory, check_with, podman_command, publish_with};
439
440 struct Fixture(PathBuf);
441
442 impl Fixture {
443 fn new(label: &str) -> Self {
444 let path = std::env::temp_dir().join(format!(
445 "kcode-rust-library-toolchain-test-{label}-{}-{}",
446 std::process::id(),
447 super::UNIQUE.fetch_add(1, super::Ordering::Relaxed)
448 ));
449 fs::create_dir(&path).unwrap();
450 Self(path)
451 }
452
453 fn path(&self) -> &Path {
454 &self.0
455 }
456
457 fn script(&self, contents: &str) -> PathBuf {
458 let path = self.path().join("fake-podman");
459 fs::write(&path, contents).unwrap();
460 let mut permissions = fs::metadata(&path).unwrap().permissions();
461 permissions.set_mode(0o755);
462 fs::set_permissions(&path, permissions).unwrap();
463 path
464 }
465 }
466
467 impl Drop for Fixture {
468 fn drop(&mut self) {
469 let _ = fs::remove_dir_all(&self.0);
470 }
471 }
472
473 fn source() -> Source {
474 Source::validate(
475 &[
476 File {
477 path: "Cargo.toml".to_owned(),
478 contents:
479 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"
480 .to_owned(),
481 },
482 File {
483 path: "Documentation.md".to_owned(),
484 contents: "docs\n".to_owned(),
485 },
486 File {
487 path: "src/lib.rs".to_owned(),
488 contents: String::new(),
489 },
490 ],
491 "demo",
492 )
493 .unwrap()
494 }
495
496 #[test]
497 fn every_cargo_invocation_is_globally_quiet() {
498 let fixture = Fixture::new("quiet");
499 let log = fixture.path().join("arguments.log");
500 let podman = fixture.script(&format!(
501 "#!/bin/sh\nif [ \"$1\" = image ]; then exit 0; fi\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n",
502 log.display()
503 ));
504 check_with(&source(), podman.as_os_str(), "test-image").unwrap();
505 publish_with(&source(), "private-token", podman.as_os_str(), "test-image").unwrap();
506
507 let arguments = fs::read_to_string(log).unwrap();
508 let cargo_lines = arguments
509 .lines()
510 .filter(|line| line.contains(" cargo "))
511 .collect::<Vec<_>>();
512 assert_eq!(cargo_lines.len(), 13);
513 assert!(
514 cargo_lines
515 .iter()
516 .all(|line| line.contains("test-image cargo --quiet "))
517 );
518 assert!(
519 cargo_lines
520 .iter()
521 .any(|line| line.contains(" cargo --quiet fmt --all"))
522 );
523 assert!(!arguments.contains("private-token"));
524 assert!(!arguments.contains("fmt --all --check"));
525 }
526
527 #[test]
528 fn failed_commands_retain_large_complete_stdout_and_stderr() {
529 let fixture = Fixture::new("complete-output");
530 let podman = fixture.script(
531 "#!/bin/sh\nif [ \"$1\" = image ]; then exit 0; fi\n\
532 printf 'stdout-start\\n'\n\
533 i=0; while [ $i -lt 4000 ]; do printf '0123456789'; i=$((i+1)); done\n\
534 printf '\\nstdout-end\\n'\n\
535 printf 'stderr-start\\n' >&2\n\
536 i=0; while [ $i -lt 4000 ]; do printf 'abcdefghij' >&2; i=$((i+1)); done\n\
537 printf '\\nstderr-end\\n' >&2\n\
538 exit 1\n",
539 );
540 let rendered = check_with(&source(), podman.as_os_str(), "test-image")
541 .unwrap_err()
542 .to_string();
543 assert!(rendered.len() > 75 * 1024);
544 for marker in ["stdout-start", "stdout-end", "stderr-start", "stderr-end"] {
545 assert!(rendered.contains(marker));
546 }
547 assert!(!rendered.contains("truncated"));
548 }
549
550 #[test]
551 fn publication_redacts_the_token_across_complete_output() {
552 let fixture = Fixture::new("redaction");
553 let podman = fixture.script(
554 "#!/bin/sh\nif [ \"$1\" = image ]; then exit 0; fi\n\
555 case \"$*\" in\n\
556 *' publish '*)\n\
557 printf 'stdout-before %s stdout-after\\n' \"$CARGO_REGISTRY_TOKEN\"\n\
558 printf 'stderr-before %s stderr-after\\n' \"$CARGO_REGISTRY_TOKEN\" >&2\n\
559 exit 1\n\
560 ;;\n\
561 esac\n\
562 exit 0\n",
563 );
564 let rendered = publish_with(&source(), "private-token", podman.as_os_str(), "test-image")
565 .unwrap_err()
566 .to_string();
567 assert!(!rendered.contains("private-token"));
568 assert_eq!(rendered.matches("[REDACTED]").count(), 2);
569 for marker in [
570 "stdout-before",
571 "stdout-after",
572 "stderr-before",
573 "stderr-after",
574 ] {
575 assert!(rendered.contains(marker));
576 }
577 }
578
579 #[test]
580 fn image_and_outer_container_preserve_browser_hardening() {
581 assert!(super::CONTAINERFILE.contains("chromium"));
582 assert!(
583 super::CONTAINERFILE
584 .contains("COPY ChromiumOuterSandbox.conf /etc/chromium.d/kcode-outer-sandbox")
585 );
586 assert!(
587 super::CHROMIUM_OUTER_SANDBOX_CONFIG
588 .lines()
589 .any(|line| line == r#"CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox""#)
590 );
591
592 let run = RunDirectory::new(&source(), "outer-sandbox-test").unwrap();
593 let command = podman_command(OsStr::new("podman"), &run, "/workspace", false);
594 let arguments = command
595 .get_args()
596 .map(|argument| argument.to_string_lossy().into_owned())
597 .collect::<Vec<_>>();
598 for expected in [
599 "--read-only",
600 "--cap-drop=all",
601 "--security-opt=no-new-privileges",
602 "--userns=keep-id",
603 "--tmpfs=/tmp:rw,nosuid,nodev",
604 "--network=none",
605 ] {
606 assert!(arguments.iter().any(|argument| argument == expected));
607 }
608 }
609}