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 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";
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, 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 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..=36).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-36",
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    let parent = canonical_dir(parent, "output parent")?;
289    Ok(parent.join(name))
290}
291
292fn validate_output(path: &Path) -> Result<(), FileFailure> {
293    match fs::symlink_metadata(path) {
294        Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_file() => Ok(()),
295        Ok(_) => Err(failure(
296            "validate output",
297            path,
298            io::Error::new(
299                io::ErrorKind::InvalidInput,
300                "not absent or an ordinary nonsymlink file",
301            ),
302        )),
303        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
304        Err(source) => Err(failure("metadata", path, source)),
305    }
306}
307
308fn volume(command: &mut Command, host: &Path, destination: &str) {
309    let mut value = host.as_os_str().to_os_string();
310    value.push(":");
311    value.push(destination);
312    command.arg("--volume").arg(value);
313}
314
315fn invalid(field: &'static str, reason: impl Into<String>) -> RustPodmanError {
316    RustPodmanError::InvalidInput {
317        field,
318        reason: reason.into(),
319    }
320}
321
322fn failure(operation: &'static str, path: &Path, source: io::Error) -> FileFailure {
323    FileFailure {
324        operation,
325        path: path.to_path_buf(),
326        source,
327    }
328}
329
330fn file(operation: &'static str, path: &Path, source: io::Error) -> RustPodmanError {
331    RustPodmanError::File(failure(operation, path, source))
332}
333
334fn after(diagnostics: CommandDiagnostics, failure: FileFailure) -> RustPodmanError {
335    RustPodmanError::AfterCommand {
336        diagnostics,
337        failure,
338    }
339}
340
341fn after_file(
342    diagnostics: CommandDiagnostics,
343    operation: &'static str,
344    path: &Path,
345    source: io::Error,
346) -> RustPodmanError {
347    after(diagnostics, failure(operation, path, source))
348}
349
350struct StageDir {
351    path: PathBuf,
352    armed: bool,
353}
354
355impl StageDir {
356    fn create(parent: &Path, output_name: &OsStr) -> Result<Self, RustPodmanError> {
357        let mut name = OsString::from(".");
358        name.push(output_name);
359        name.push(format!(".k1-stage-{}", std::process::id()));
360        let path = parent.join(name);
361        match fs::symlink_metadata(&path) {
362            Ok(metadata) if !metadata.file_type().is_symlink() && metadata.is_dir() => {
363                fs::remove_dir_all(&path)
364                    .map_err(|source| file("remove stale stage directory", &path, source))?;
365            }
366            Ok(_) => {
367                return Err(file(
368                    "validate stage directory",
369                    &path,
370                    io::Error::new(
371                        io::ErrorKind::InvalidInput,
372                        "not an ordinary nonsymlink directory",
373                    ),
374                ));
375            }
376            Err(source) if source.kind() == io::ErrorKind::NotFound => {}
377            Err(source) => return Err(file("metadata", &path, source)),
378        }
379        DirBuilder::new()
380            .mode(0o700)
381            .create(&path)
382            .map_err(|source| file("create stage directory", &path, source))?;
383        if let Err(source) = fs::set_permissions(&path, fs::Permissions::from_mode(0o700)) {
384            let _ = fs::remove_dir(&path);
385            return Err(file("set stage permissions", &path, source));
386        }
387        Ok(Self { path, armed: true })
388    }
389
390    fn cleanup(&mut self) -> io::Result<()> {
391        match fs::remove_dir_all(&self.path) {
392            Ok(()) => {
393                self.armed = false;
394                Ok(())
395            }
396            Err(source) if source.kind() == io::ErrorKind::NotFound => {
397                self.armed = false;
398                Ok(())
399            }
400            Err(source) => Err(source),
401        }
402    }
403}
404
405impl Drop for StageDir {
406    fn drop(&mut self) {
407        if self.armed {
408            let _ = self.cleanup();
409            if self.armed {
410                let _ = self.cleanup();
411            }
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn binary_names_are_strict() {
422        for valid in [
423            "a",
424            "1",
425            "a1",
426            "one-binary",
427            "a23456789012345678901234567890123456",
428        ] {
429            assert!(valid_binary(valid).is_ok(), "{valid}");
430        }
431        for invalid_name in [
432            "",
433            "A",
434            "-a",
435            "a-",
436            "a--b",
437            "a_b",
438            "a234567890123456789012345678901234567",
439        ] {
440            assert!(valid_binary(invalid_name).is_err(), "{invalid_name}");
441        }
442    }
443}