Skip to main content

bootc_internal_utils/
command.rs

1//! Helpers intended for [`std::process::Command`] and related structures.
2
3use std::{
4    fmt::Write,
5    io::{Read, Seek},
6    os::unix::process::CommandExt,
7    process::Command,
8};
9
10use anyhow::{Context, Result};
11
12/// Create a seekable, filesystem-independent file for command output.
13fn command_output_file() -> Result<std::fs::File> {
14    // bootc's command helpers run from a systemd generator. Generators on
15    // systemd 252 and older may see a read-only /tmp (253+ provides a private
16    // writable /tmp), so output capture must not rely on filesystem temp files.
17    rustix::fs::memfd_create("bootc-command-output", rustix::fs::MemfdFlags::CLOEXEC)
18        .map(std::fs::File::from)
19        .context("create memfd for command output")
20}
21
22/// Helpers intended for [`std::process::Command`].
23pub trait CommandRunExt {
24    /// Log (at debug level) the full child commandline.
25    fn log_debug(&mut self) -> &mut Self;
26
27    /// Execute the child process and wait for it to exit.
28    ///
29    /// # Streams
30    ///
31    /// - stdin, stdout, stderr: All inherited
32    ///
33    /// # Errors
34    ///
35    /// An non-successful exit status will result in an error.
36    fn run_inherited(&mut self) -> Result<()>;
37
38    /// Execute the child process and wait for it to exit.
39    ///
40    /// # Streams
41    ///
42    /// - stdin, stdout: Inherited
43    /// - stderr: captured and included in error
44    ///
45    /// # Errors
46    ///
47    /// An non-successful exit status will result in an error.
48    fn run_capture_stderr(&mut self) -> Result<()>;
49
50    /// Execute the child process and wait for it to exit; the
51    /// complete argument list will be included in the error.
52    ///
53    /// # Streams
54    ///
55    /// - stdin, stdout, stderr: All nherited
56    ///
57    /// # Errors
58    ///
59    /// An non-successful exit status will result in an error.
60    fn run_inherited_with_cmd_context(&mut self) -> Result<()>;
61
62    /// Ensure the child does not outlive the parent.
63    fn lifecycle_bind(&mut self) -> &mut Self;
64
65    /// Execute the child process and capture its output. This uses `run_capture_stderr` internally
66    /// and will return an error if the child process exits abnormally.
67    fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
68
69    /// Execute the child process and capture its output as a string.
70    /// This uses `run_capture_stderr` internally.
71    fn run_get_string(&mut self) -> Result<String>;
72
73    /// Execute the child process, parsing its stdout as JSON. This uses `run_capture_stderr` internally
74    /// and will return an error if the child process exits abnormally.
75    fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
76
77    /// Print the command as it would be typed into a terminal
78    fn to_string_pretty(&self) -> String;
79}
80
81/// Helpers intended for [`std::process::ExitStatus`].
82pub trait ExitStatusExt {
83    /// If the exit status signals it was not successful, return an error.
84    /// Note that we intentionally *don't* include the command string
85    /// in the output; we leave it to the caller to add that if they want,
86    /// as it may be verbose.
87    fn check_status(&mut self) -> Result<()>;
88
89    /// If the exit status signals it was not successful, return an error;
90    /// this also includes the contents of `stderr`.
91    ///
92    /// Otherwise this is the same as [`Self::check_status`].
93    fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()>;
94}
95
96/// Parse the last chunk (e.g. 1024 bytes) from the provided file,
97/// ensure it's UTF-8, and return that value. This function is infallible;
98/// if the file cannot be read for some reason, a copy of a static string
99/// is returned.
100fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
101    // u16 since we truncate to just the trailing bytes here
102    // to avoid pathological error messages
103    const MAX_STDERR_BYTES: u16 = 1024;
104    let size = f
105        .metadata()
106        .map_err(|e| {
107            tracing::warn!("failed to fstat: {e}");
108        })
109        .map(|m| m.len().try_into().unwrap_or(u16::MAX))
110        .unwrap_or(0);
111    let size = size.min(MAX_STDERR_BYTES);
112    let seek_offset = -(size as i32);
113    let mut stderr_buf = Vec::with_capacity(size.into());
114    // We should never fail to seek()+read() really, but let's be conservative
115    let r = match f
116        .seek(std::io::SeekFrom::End(seek_offset.into()))
117        .and_then(|_| f.read_to_end(&mut stderr_buf))
118    {
119        Ok(_) => String::from_utf8_lossy(&stderr_buf),
120        Err(e) => {
121            tracing::warn!("failed seek+read: {e}");
122            "<failed to read stderr>".into()
123        }
124    };
125    (&*r).to_owned()
126}
127
128impl ExitStatusExt for std::process::ExitStatus {
129    fn check_status(&mut self) -> Result<()> {
130        if self.success() {
131            return Ok(());
132        }
133        anyhow::bail!(format!("Subprocess failed: {self:?}"))
134    }
135    fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()> {
136        let stderr_buf = last_utf8_content_from_file(stderr);
137        if self.success() {
138            return Ok(());
139        }
140        anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
141    }
142}
143
144impl CommandRunExt for Command {
145    fn run_inherited(&mut self) -> Result<()> {
146        tracing::trace!("exec: {self:?}");
147        self.status()?.check_status()
148    }
149
150    /// Synchronously execute the child, and return an error if the child exited unsuccessfully.
151    fn run_capture_stderr(&mut self) -> Result<()> {
152        let stderr = command_output_file()?;
153        self.stderr(stderr.try_clone()?);
154        tracing::trace!("exec: {self:?}");
155        self.status()?.check_status_with_stderr(stderr)
156    }
157
158    #[allow(unsafe_code)]
159    fn lifecycle_bind(&mut self) -> &mut Self {
160        // SAFETY: This API is safe to call in a forked child.
161        unsafe {
162            self.pre_exec(|| {
163                rustix::process::set_parent_process_death_signal(Some(
164                    rustix::process::Signal::TERM,
165                ))
166                .map_err(Into::into)
167            })
168        }
169    }
170
171    /// Output a debug-level log message with this command.
172    fn log_debug(&mut self) -> &mut Self {
173        // We unconditionally log at trace level, so avoid double logging
174        if !tracing::enabled!(tracing::Level::TRACE) {
175            tracing::debug!("exec: {self:?}");
176        }
177        self
178    }
179
180    fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
181        let mut stdout = command_output_file()?;
182        self.stdout(stdout.try_clone()?);
183        self.run_capture_stderr()?;
184        stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
185        Ok(Box::new(std::io::BufReader::new(stdout)))
186    }
187
188    fn run_get_string(&mut self) -> Result<String> {
189        let mut s = String::new();
190        let mut o = self.run_get_output()?;
191        o.read_to_string(&mut s)?;
192        Ok(s)
193    }
194
195    /// Synchronously execute the child, and parse its stdout as JSON.
196    fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
197        let output = self.run_get_output()?;
198        serde_json::from_reader(output).map_err(Into::into)
199    }
200
201    fn run_inherited_with_cmd_context(&mut self) -> Result<()> {
202        self.status()?
203            .success()
204            .then_some(())
205            // The [`Debug`] output of command contains a properly shell-escaped commandline
206            // representation that the user can copy paste into their shell
207            .context(format!("Failed to run command: {self:#?}"))
208    }
209
210    fn to_string_pretty(&self) -> String {
211        std::iter::once(self.get_program())
212            .chain(self.get_args())
213            .fold(String::new(), |mut acc, element| {
214                if !acc.is_empty() {
215                    acc.push(' ');
216                }
217                // SAFETY: Writes to string can't fail
218                write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
219                acc
220            })
221    }
222}
223
224/// Helpers intended for [`tokio::process::Command`].
225#[allow(async_fn_in_trait)]
226pub trait AsyncCommandRunExt {
227    /// Asynchronously execute the child, and return an error if the child exited unsuccessfully.
228    async fn run(&mut self) -> Result<()>;
229}
230
231impl AsyncCommandRunExt for tokio::process::Command {
232    async fn run(&mut self) -> Result<()> {
233        let stderr = command_output_file()?;
234        self.stderr(stderr.try_clone()?);
235        self.status().await?.check_status_with_stderr(stderr)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn command_run_inherited() {
245        // Test successful command
246        Command::new("true").run_inherited().unwrap();
247
248        // Test failed command
249        assert!(Command::new("false").run_inherited().is_err());
250
251        // Test that stderr is not captured (just check error format)
252        let e = Command::new("/bin/sh")
253            .args(["-c", "echo should-not-be-captured 1>&2; exit 1"])
254            .run_inherited()
255            .err()
256            .unwrap();
257        // Should not contain the stderr message since it's inherited
258        assert_eq!(
259            e.to_string(),
260            "Subprocess failed: ExitStatus(unix_wait_status(256))"
261        );
262    }
263
264    #[test]
265    fn command_run_capture_stderr() {
266        // The basics
267        Command::new("true").run_capture_stderr().unwrap();
268        assert!(Command::new("false").run_capture_stderr().is_err());
269
270        // Verify we capture stderr
271        let e = Command::new("/bin/sh")
272            .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
273            .run_capture_stderr()
274            .err()
275            .unwrap();
276        similar_asserts::assert_eq!(
277            e.to_string(),
278            "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
279        );
280
281        // Ignoring invalid UTF-8
282        let e = Command::new("/bin/sh")
283            .args([
284                "-c",
285                r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
286            ])
287            .run_capture_stderr()
288            .err()
289            .unwrap();
290        similar_asserts::assert_eq!(
291            e.to_string(),
292            "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
293        );
294    }
295
296    #[test]
297    fn command_output_file_is_a_memfd() {
298        use std::os::fd::AsRawFd;
299
300        let file = command_output_file().unwrap();
301        // An unprivileged test cannot reliably make /tmp read-only, so verify
302        // directly that the capture backing file is a memfd instead.
303        let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
304        let target = std::fs::read_link(fd_path).unwrap();
305        assert!(
306            target
307                .to_string_lossy()
308                .contains("memfd:bootc-command-output")
309        );
310    }
311
312    #[test]
313    fn exit_status_check_status() {
314        use std::process::Command;
315
316        // Test successful exit status
317        let mut success_status = Command::new("true").status().unwrap();
318        success_status.check_status().unwrap();
319
320        // Test failed exit status
321        let mut fail_status = Command::new("false").status().unwrap();
322        let e = fail_status.check_status().err().unwrap();
323        assert_eq!(
324            e.to_string(),
325            "Subprocess failed: ExitStatus(unix_wait_status(256))"
326        );
327    }
328
329    #[test]
330    fn exit_status_check_status_with_stderr() {
331        use std::io::Write;
332        use std::process::Command;
333
334        // Test successful exit status
335        let mut success_status = Command::new("true").status().unwrap();
336        let temp_stderr = command_output_file().unwrap();
337        success_status
338            .check_status_with_stderr(temp_stderr)
339            .unwrap();
340
341        // Test failed exit status with stderr content
342        let mut fail_status = Command::new("false").status().unwrap();
343        let mut temp_stderr = command_output_file().unwrap();
344        write!(temp_stderr, "test error message").unwrap();
345        let e = fail_status
346            .check_status_with_stderr(temp_stderr)
347            .err()
348            .unwrap();
349        assert!(
350            e.to_string()
351                .contains("Subprocess failed: ExitStatus(unix_wait_status(256))")
352        );
353        assert!(e.to_string().contains("test error message"));
354    }
355
356    #[test]
357    fn command_run_ext_json() {
358        #[derive(serde::Deserialize)]
359        struct Foo {
360            a: String,
361            b: u32,
362        }
363        let v: Foo = Command::new("echo")
364            .arg(r##"{"a": "somevalue", "b": 42}"##)
365            .run_and_parse_json()
366            .unwrap();
367        assert_eq!(v.a, "somevalue");
368        assert_eq!(v.b, 42);
369    }
370
371    #[tokio::test]
372    async fn async_command_run_ext() {
373        use tokio::process::Command as AsyncCommand;
374        let mut success = AsyncCommand::new("true");
375        let mut fail = AsyncCommand::new("false");
376        // Run these in parallel just because we can
377        let (success, fail) = tokio::join!(success.run(), fail.run(),);
378        success.unwrap();
379        assert!(fail.is_err());
380
381        let error = AsyncCommand::new("/bin/sh")
382            .args(["-c", "echo expected-async-error 1>&2; exit 1"])
383            .run()
384            .await
385            .unwrap_err();
386        assert!(error.to_string().contains("expected-async-error"));
387    }
388
389    #[test]
390    fn to_string_pretty() {
391        let mut cmd = Command::new("podman");
392        cmd.args([
393            "run",
394            "--privileged",
395            "--pid=host",
396            "--user=root:root",
397            "-v",
398            "/var/lib/containers:/var/lib/containers",
399            "-v",
400            "this has spaces",
401            "label=type:unconfined_t",
402            "--env=RUST_LOG=trace",
403            "quay.io/ckyrouac/bootc-dev",
404            "bootc",
405            "install",
406            "to-existing-root",
407        ]);
408
409        similar_asserts::assert_eq!(
410            cmd.to_string_pretty(),
411            "podman run --privileged --pid=host --user=root:root -v /var/lib/containers:/var/lib/containers -v 'this has spaces' label=type:unconfined_t --env=RUST_LOG=trace quay.io/ckyrouac/bootc-dev bootc install to-existing-root"
412        );
413    }
414}