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