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 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}