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