Skip to main content

kcode_k1_rust_podman/
lib.rs

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}