1use std::error::Error;
2use std::ffi::{OsStr, OsString};
3use std::fmt;
4use std::fs::{self, DirBuilder};
5use std::io;
6use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus};
9
10const FORMAT: &str = "mkdir -p /tmp/home && cargo fmt --all";
11const CHECK: &str = "mkdir -p /tmp/home && cargo generate-lockfile && cargo fetch --locked && cargo build --locked && cargo test --locked";
12const BUILD: &str = "mkdir -p /tmp/home && cargo build --release --locked --bin \"$K1_BINARY\" && cp -- \"/target/release/$K1_BINARY\" /output/binary";
13
14#[derive(Debug)]
15pub struct CommandDiagnostics {
16 pub status: ExitStatus,
17 pub stdout: Vec<u8>,
18 pub stderr: Vec<u8>,
19}
20
21#[derive(Debug)]
22pub struct FileFailure {
23 pub operation: &'static str,
24 pub path: PathBuf,
25 pub source: io::Error,
26}
27
28#[derive(Debug)]
29pub enum RustPodmanError {
30 InvalidInput {
31 field: &'static str,
32 reason: String,
33 },
34 File(FileFailure),
35 Spawn(io::Error),
36 CommandFailed(CommandDiagnostics),
37 AfterCommand {
38 diagnostics: CommandDiagnostics,
39 failure: FileFailure,
40 },
41}
42
43impl fmt::Display for RustPodmanError {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(formatter, "{self:?}")
46 }
47}
48
49impl Error for RustPodmanError {}
50
51#[derive(Clone, Debug)]
52pub struct RustPodmanPaths {
53 pub workspace: PathBuf,
54 pub cargo_home: PathBuf,
55 pub target: PathBuf,
56 pub local_registry: PathBuf,
57 pub cargo_config: PathBuf,
58}
59
60impl RustPodmanPaths {
61 pub fn new(
62 workspace: impl Into<PathBuf>,
63 cargo_home: impl Into<PathBuf>,
64 target: impl Into<PathBuf>,
65 local_registry: impl Into<PathBuf>,
66 cargo_config: impl Into<PathBuf>,
67 ) -> Self {
68 Self {
69 workspace: workspace.into(),
70 cargo_home: cargo_home.into(),
71 target: target.into(),
72 local_registry: local_registry.into(),
73 cargo_config: cargo_config.into(),
74 }
75 }
76}
77
78#[derive(Clone, Debug)]
79pub struct RustPodman {
80 program: OsString,
81 image: OsString,
82}
83
84impl RustPodman {
85 pub fn new(
86 program: impl Into<OsString>,
87 image: impl Into<OsString>,
88 ) -> Result<Self, RustPodmanError> {
89 let program = program.into();
90 let image = image.into();
91 require_nonempty(&program, "program")?;
92 require_nonempty(&image, "image")?;
93 Ok(Self { program, image })
94 }
95
96 pub fn format(&self, paths: &RustPodmanPaths) -> Result<CommandDiagnostics, RustPodmanError> {
97 let paths = ResolvedPaths::new(paths)?;
98 let mut command = self.command(&paths);
99 command.arg("--network=none");
100 self.run(command, FORMAT)
101 }
102
103 pub fn check(&self, paths: &RustPodmanPaths) -> Result<CommandDiagnostics, RustPodmanError> {
104 let paths = ResolvedPaths::new(paths)?;
105 let diagnostics = self.run(self.command(&paths), CHECK)?;
106 if let Err(failure) = ordinary_file(&paths.workspace.join("Cargo.lock")) {
107 return Err(after(diagnostics, failure));
108 }
109 Ok(diagnostics)
110 }
111
112 pub fn build_binary(
113 &self,
114 paths: &RustPodmanPaths,
115 binary: &str,
116 output: impl AsRef<Path>,
117 ) -> Result<CommandDiagnostics, RustPodmanError> {
118 valid_binary(binary)?;
119 let paths = ResolvedPaths::new(paths)?;
120 ordinary_file(&paths.workspace.join("Cargo.lock")).map_err(RustPodmanError::File)?;
121 let output = output_location(output.as_ref())?;
122 validate_output(&output).map_err(RustPodmanError::File)?;
123 let mut stage = StageDir::create(output.parent().unwrap(), output.file_name().unwrap())?;
124 let mut command = self.command(&paths);
125 command.arg(format!("--env=K1_BINARY={binary}"));
126 volume(&mut command, &stage.path, "/output:rw");
127 let diagnostics = self.run(command, BUILD)?;
128 let staged = stage.path.join("binary");
129 if let Err(failure) = ordinary_file(&staged) {
130 return Err(after(diagnostics, failure));
131 }
132 if let Err(failure) = validate_output(&output) {
133 return Err(after(diagnostics, failure));
134 }
135 if let Err(source) = fs::rename(&staged, &output) {
136 return Err(after_file(diagnostics, "rename output", &output, source));
137 }
138 let _ = stage.cleanup();
139 Ok(diagnostics)
140 }
141
142 fn command(&self, paths: &ResolvedPaths) -> Command {
143 let mut command = Command::new(&self.program);
144 command.args([
145 "run",
146 "--rm",
147 "--read-only",
148 "--userns=keep-id",
149 "--cap-drop=ALL",
150 "--security-opt=no-new-privileges",
151 "--workdir=/workspace",
152 "--env=HOME=/tmp/home",
153 "--env=CARGO_HOME=/cargo-home",
154 "--env=CARGO_TARGET_DIR=/target",
155 "--tmpfs=/tmp:rw,nosuid,nodev",
156 ]);
157 volume(&mut command, &paths.workspace, "/workspace:rw");
158 volume(&mut command, &paths.local_registry, "/k1/local-registry:ro");
159 volume(&mut command, &paths.cargo_home, "/cargo-home:rw");
160 volume(&mut command, &paths.target, "/target:rw");
161 volume(
162 &mut command,
163 &paths.cargo_config,
164 "/cargo-home/config.toml:ro",
165 );
166 command
167 }
168
169 fn run(
170 &self,
171 mut command: Command,
172 script: &'static str,
173 ) -> Result<CommandDiagnostics, RustPodmanError> {
174 let output = command
175 .arg("--")
176 .arg(&self.image)
177 .args(["sh", "-c", script])
178 .output()
179 .map_err(RustPodmanError::Spawn)?;
180 let diagnostics = CommandDiagnostics {
181 status: output.status,
182 stdout: output.stdout,
183 stderr: output.stderr,
184 };
185 if diagnostics.status.success() {
186 Ok(diagnostics)
187 } else {
188 Err(RustPodmanError::CommandFailed(diagnostics))
189 }
190 }
191}
192
193struct ResolvedPaths {
194 workspace: PathBuf,
195 cargo_home: PathBuf,
196 target: PathBuf,
197 local_registry: PathBuf,
198 cargo_config: PathBuf,
199}
200
201impl ResolvedPaths {
202 fn new(paths: &RustPodmanPaths) -> Result<Self, RustPodmanError> {
203 let roots = [
204 canonical_dir(&paths.workspace, "workspace")?,
205 canonical_dir(&paths.cargo_home, "cargo_home")?,
206 canonical_dir(&paths.target, "target")?,
207 canonical_dir(&paths.local_registry, "local_registry")?,
208 ];
209 for left in 0..roots.len() {
210 for right in left + 1..roots.len() {
211 if roots[left].starts_with(&roots[right]) || roots[right].starts_with(&roots[left])
212 {
213 return Err(invalid("paths", "directory roots overlap"));
214 }
215 }
216 }
217 ordinary_file(&paths.cargo_config).map_err(RustPodmanError::File)?;
218 let cargo_config = fs::canonicalize(&paths.cargo_config)
219 .map_err(|source| file("canonicalize", &paths.cargo_config, source))?;
220 Ok(Self {
221 workspace: roots[0].clone(),
222 cargo_home: roots[1].clone(),
223 target: roots[2].clone(),
224 local_registry: roots[3].clone(),
225 cargo_config,
226 })
227 }
228}
229
230fn require_nonempty(value: &OsStr, field: &'static str) -> Result<(), RustPodmanError> {
231 if value.is_empty() {
232 Err(invalid(field, "must not be empty"))
233 } else {
234 Ok(())
235 }
236}
237
238fn valid_binary(value: &str) -> Result<(), RustPodmanError> {
239 let bytes = value.as_bytes();
240 let alphanumeric = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
241 let valid = (1..=250).contains(&bytes.len())
242 && alphanumeric(&bytes[0])
243 && alphanumeric(&bytes[bytes.len() - 1])
244 && bytes.iter().all(|byte| alphanumeric(byte) || *byte == b'-')
245 && !bytes.windows(2).any(|pair| pair == b"--");
246 if valid {
247 Ok(())
248 } else {
249 Err(invalid(
250 "binary",
251 "must be lowercase kebab-case of length 1-250",
252 ))
253 }
254}
255
256fn canonical_dir(path: &Path, field: &'static str) -> Result<PathBuf, RustPodmanError> {
257 let metadata = fs::symlink_metadata(path).map_err(|source| file("metadata", path, source))?;
258 if metadata.file_type().is_symlink() || !metadata.is_dir() {
259 return Err(invalid(field, "must be an ordinary nonsymlink directory"));
260 }
261 fs::canonicalize(path).map_err(|source| file("canonicalize", path, source))
262}
263
264fn ordinary_file(path: &Path) -> Result<(), FileFailure> {
265 let metadata =
266 fs::symlink_metadata(path).map_err(|source| failure("metadata", path, source))?;
267 if metadata.file_type().is_symlink() || !metadata.is_file() {
268 return Err(failure(
269 "validate ordinary file",
270 path,
271 io::Error::new(
272 io::ErrorKind::InvalidInput,
273 "not an ordinary nonsymlink file",
274 ),
275 ));
276 }
277 Ok(())
278}
279
280fn output_location(path: &Path) -> Result<PathBuf, RustPodmanError> {
281 let name = path
282 .file_name()
283 .ok_or_else(|| invalid("output", "must name a file"))?;
284 let parent = path
285 .parent()
286 .filter(|value| !value.as_os_str().is_empty())
287 .unwrap_or(Path::new("."));
288 Ok(canonical_dir(parent, "output parent")?.join(name))
289}
290
291fn validate_output(path: &Path) -> Result<(), FileFailure> {
292 match fs::symlink_metadata(path) {
293 Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_file() => Ok(()),
294 Ok(_) => Err(failure(
295 "validate output",
296 path,
297 io::Error::new(
298 io::ErrorKind::InvalidInput,
299 "not absent or an ordinary nonsymlink file",
300 ),
301 )),
302 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
303 Err(source) => Err(failure("metadata", path, source)),
304 }
305}
306
307fn volume(command: &mut Command, host: &Path, destination: &str) {
308 let mut value = host.as_os_str().to_os_string();
309 value.push(":");
310 value.push(destination);
311 command.arg("--volume").arg(value);
312}
313
314fn invalid(field: &'static str, reason: impl Into<String>) -> RustPodmanError {
315 RustPodmanError::InvalidInput {
316 field,
317 reason: reason.into(),
318 }
319}
320
321fn failure(operation: &'static str, path: &Path, source: io::Error) -> FileFailure {
322 FileFailure {
323 operation,
324 path: path.to_path_buf(),
325 source,
326 }
327}
328
329fn file(operation: &'static str, path: &Path, source: io::Error) -> RustPodmanError {
330 RustPodmanError::File(failure(operation, path, source))
331}
332
333fn after(diagnostics: CommandDiagnostics, failure: FileFailure) -> RustPodmanError {
334 RustPodmanError::AfterCommand {
335 diagnostics,
336 failure,
337 }
338}
339
340fn after_file(
341 diagnostics: CommandDiagnostics,
342 operation: &'static str,
343 path: &Path,
344 source: io::Error,
345) -> RustPodmanError {
346 after(diagnostics, failure(operation, path, source))
347}
348
349struct StageDir {
350 path: PathBuf,
351 armed: bool,
352}
353
354impl StageDir {
355 fn create(parent: &Path, output_name: &OsStr) -> Result<Self, RustPodmanError> {
356 let mut name = OsString::from(".");
357 name.push(output_name);
358 name.push(format!(".k1-stage-{}", std::process::id()));
359 let path = parent.join(name);
360 match fs::symlink_metadata(&path) {
361 Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_dir() => {
362 fs::remove_dir_all(&path)
363 .map_err(|source| file("remove stale stage directory", &path, source))?;
364 }
365 Ok(_) => {
366 return Err(file(
367 "validate stage directory",
368 &path,
369 io::Error::new(
370 io::ErrorKind::InvalidInput,
371 "not an ordinary nonsymlink directory",
372 ),
373 ));
374 }
375 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
376 Err(source) => return Err(file("metadata", &path, source)),
377 }
378 DirBuilder::new()
379 .mode(0o700)
380 .create(&path)
381 .map_err(|source| file("create stage directory", &path, source))?;
382 if let Err(source) = fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) {
383 let _ = fs::remove_dir(&path);
384 return Err(file("set stage permissions", &path, source));
385 }
386 Ok(Self { path, armed: true })
387 }
388
389 fn cleanup(&mut self) -> io::Result<()> {
390 match fs::remove_dir_all(&self.path) {
391 Ok(()) => {
392 self.armed = false;
393 Ok(())
394 }
395 Err(source) if source.kind() == io::ErrorKind::NotFound => {
396 self.armed = false;
397 Ok(())
398 }
399 Err(source) => Err(source),
400 }
401 }
402}
403
404impl Drop for StageDir {
405 fn drop(&mut self) {
406 if self.armed {
407 let _ = self.cleanup();
408 if self.armed {
409 let _ = self.cleanup();
410 }
411 }
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn binary_names_use_the_shared_250_character_boundary() {
421 for valid in [
422 "a".to_owned(),
423 "1".to_owned(),
424 "a1".to_owned(),
425 "one-binary".to_owned(),
426 "a".repeat(250),
427 ] {
428 assert!(valid_binary(&valid).is_ok(), "{valid}");
429 }
430 for invalid_name in [
431 String::new(),
432 "A".to_owned(),
433 "-a".to_owned(),
434 "a-".to_owned(),
435 "a--b".to_owned(),
436 "a_b".to_owned(),
437 "a".repeat(251),
438 ] {
439 assert!(valid_binary(&invalid_name).is_err(), "{invalid_name}");
440 }
441 }
442
443 #[test]
444 fn ordinary_check_uses_default_cargo_scope() {
445 assert_eq!(
446 CHECK,
447 "mkdir -p /tmp/home && cargo generate-lockfile && cargo fetch --locked && cargo build --locked && cargo test --locked"
448 );
449 for restriction in [
450 "fmt",
451 "clippy",
452 "--all-targets",
453 "--all-features",
454 "-D warnings",
455 "test --doc",
456 ] {
457 assert!(
458 !CHECK.contains(restriction),
459 "unexpected check restriction: {restriction}"
460 );
461 }
462 }
463}