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