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.
18    fn run(&mut self) -> Result<()>;
19
20    /// Execute the child process. In case of failure, include the command and its arguments in the
21    /// error context
22    fn run_with_cmd_context(&mut self) -> Result<()>;
23
24    /// Ensure the child does not outlive the parent.
25    fn lifecycle_bind(&mut self) -> &mut Self;
26
27    /// Execute the child process and capture its output. This uses `run` internally
28    /// and will return an error if the child process exits abnormally.
29    fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
30
31    /// Execute the child process and capture its output as a string.
32    fn run_get_string(&mut self) -> Result<String>;
33
34    /// Execute the child process, parsing its stdout as JSON. This uses `run` internally
35    /// and will return an error if the child process exits abnormally.
36    fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
37
38    /// Print the command as it would be typed into a terminal
39    fn to_string_pretty(&self) -> String;
40}
41
42/// Helpers intended for [`std::process::ExitStatus`].
43pub trait ExitStatusExt {
44    /// If the exit status signals it was not successful, return an error.
45    /// Note that we intentionally *don't* include the command string
46    /// in the output; we leave it to the caller to add that if they want,
47    /// as it may be verbose.
48    fn check_status(&mut self, stderr: std::fs::File) -> Result<()>;
49}
50
51/// Parse the last chunk (e.g. 1024 bytes) from the provided file,
52/// ensure it's UTF-8, and return that value. This function is infallible;
53/// if the file cannot be read for some reason, a copy of a static string
54/// is returned.
55fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
56    // u16 since we truncate to just the trailing bytes here
57    // to avoid pathological error messages
58    const MAX_STDERR_BYTES: u16 = 1024;
59    let size = f
60        .metadata()
61        .map_err(|e| {
62            tracing::warn!("failed to fstat: {e}");
63        })
64        .map(|m| m.len().try_into().unwrap_or(u16::MAX))
65        .unwrap_or(0);
66    let size = size.min(MAX_STDERR_BYTES);
67    let seek_offset = -(size as i32);
68    let mut stderr_buf = Vec::with_capacity(size.into());
69    // We should never fail to seek()+read() really, but let's be conservative
70    let r = match f
71        .seek(std::io::SeekFrom::End(seek_offset.into()))
72        .and_then(|_| f.read_to_end(&mut stderr_buf))
73    {
74        Ok(_) => String::from_utf8_lossy(&stderr_buf),
75        Err(e) => {
76            tracing::warn!("failed seek+read: {e}");
77            "<failed to read stderr>".into()
78        }
79    };
80    (&*r).to_owned()
81}
82
83impl ExitStatusExt for std::process::ExitStatus {
84    fn check_status(&mut self, stderr: std::fs::File) -> Result<()> {
85        let stderr_buf = last_utf8_content_from_file(stderr);
86        if self.success() {
87            return Ok(());
88        }
89        anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
90    }
91}
92
93impl CommandRunExt for Command {
94    /// Synchronously execute the child, and return an error if the child exited unsuccessfully.
95    fn run(&mut self) -> Result<()> {
96        let stderr = tempfile::tempfile()?;
97        self.stderr(stderr.try_clone()?);
98        tracing::trace!("exec: {self:?}");
99        self.status()?.check_status(stderr)
100    }
101
102    #[allow(unsafe_code)]
103    fn lifecycle_bind(&mut self) -> &mut Self {
104        // SAFETY: This API is safe to call in a forked child.
105        unsafe {
106            self.pre_exec(|| {
107                rustix::process::set_parent_process_death_signal(Some(
108                    rustix::process::Signal::TERM,
109                ))
110                .map_err(Into::into)
111            })
112        }
113    }
114
115    /// Output a debug-level log message with this command.
116    fn log_debug(&mut self) -> &mut Self {
117        // We unconditionally log at trace level, so avoid double logging
118        if !tracing::enabled!(tracing::Level::TRACE) {
119            tracing::debug!("exec: {self:?}");
120        }
121        self
122    }
123
124    fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
125        let mut stdout = tempfile::tempfile()?;
126        self.stdout(stdout.try_clone()?);
127        self.run()?;
128        stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
129        Ok(Box::new(std::io::BufReader::new(stdout)))
130    }
131
132    fn run_get_string(&mut self) -> Result<String> {
133        let mut s = String::new();
134        let mut o = self.run_get_output()?;
135        o.read_to_string(&mut s)?;
136        Ok(s)
137    }
138
139    /// Synchronously execute the child, and parse its stdout as JSON.
140    fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
141        let output = self.run_get_output()?;
142        serde_json::from_reader(output).map_err(Into::into)
143    }
144
145    fn run_with_cmd_context(&mut self) -> Result<()> {
146        self.status()?
147            .success()
148            .then_some(())
149            // The [`Debug`] output of command contains a properly shell-escaped commandline
150            // representation that the user can copy paste into their shell
151            .context(format!("Failed to run command: {self:#?}"))
152    }
153
154    fn to_string_pretty(&self) -> String {
155        std::iter::once(self.get_program())
156            .chain(self.get_args())
157            .fold(String::new(), |mut acc, element| {
158                if !acc.is_empty() {
159                    acc.push(' ');
160                }
161                // SAFETY: Writes to string can't fail
162                write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
163                acc
164            })
165    }
166}
167
168/// Helpers intended for [`tokio::process::Command`].
169#[allow(async_fn_in_trait)]
170pub trait AsyncCommandRunExt {
171    /// Asynchronously execute the child, and return an error if the child exited unsuccessfully.
172    async fn run(&mut self) -> Result<()>;
173}
174
175impl AsyncCommandRunExt for tokio::process::Command {
176    async fn run(&mut self) -> Result<()> {
177        let stderr = tempfile::tempfile()?;
178        self.stderr(stderr.try_clone()?);
179        self.status().await?.check_status(stderr)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn command_run_ext() {
189        // The basics
190        Command::new("true").run().unwrap();
191        assert!(Command::new("false").run().is_err());
192
193        // Verify we capture stderr
194        let e = Command::new("/bin/sh")
195            .args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
196            .run()
197            .err()
198            .unwrap();
199        similar_asserts::assert_eq!(
200            e.to_string(),
201            "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
202        );
203
204        // Ignoring invalid UTF-8
205        let e = Command::new("/bin/sh")
206            .args([
207                "-c",
208                r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
209            ])
210            .run()
211            .err()
212            .unwrap();
213        similar_asserts::assert_eq!(
214            e.to_string(),
215            "Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
216        );
217    }
218
219    #[test]
220    fn command_run_ext_json() {
221        #[derive(serde::Deserialize)]
222        struct Foo {
223            a: String,
224            b: u32,
225        }
226        let v: Foo = Command::new("echo")
227            .arg(r##"{"a": "somevalue", "b": 42}"##)
228            .run_and_parse_json()
229            .unwrap();
230        assert_eq!(v.a, "somevalue");
231        assert_eq!(v.b, 42);
232    }
233
234    #[tokio::test]
235    async fn async_command_run_ext() {
236        use tokio::process::Command as AsyncCommand;
237        let mut success = AsyncCommand::new("true");
238        let mut fail = AsyncCommand::new("false");
239        // Run these in parallel just because we can
240        let (success, fail) = tokio::join!(success.run(), fail.run(),);
241        success.unwrap();
242        assert!(fail.is_err());
243    }
244
245    #[test]
246    fn to_string_pretty() {
247        let mut cmd = Command::new("podman");
248        cmd.args([
249            "run",
250            "--privileged",
251            "--pid=host",
252            "--user=root:root",
253            "-v",
254            "/var/lib/containers:/var/lib/containers",
255            "-v",
256            "this has spaces",
257            "label=type:unconfined_t",
258            "--env=RUST_LOG=trace",
259            "quay.io/ckyrouac/bootc-dev",
260            "bootc",
261            "install",
262            "to-existing-root",
263        ]);
264
265        similar_asserts::assert_eq!(cmd.to_string_pretty(), "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");
266    }
267}