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