use std::{
fmt::Write,
io::{Read, Seek},
os::unix::process::CommandExt,
process::Command,
};
use anyhow::{Context, Result};
fn command_output_file() -> Result<std::fs::File> {
rustix::fs::memfd_create("bootc-command-output", rustix::fs::MemfdFlags::CLOEXEC)
.map(std::fs::File::from)
.context("create memfd for command output")
}
pub trait CommandRunExt {
fn log_debug(&mut self) -> &mut Self;
fn run_inherited(&mut self) -> Result<()>;
fn run_capture_stderr(&mut self) -> Result<()>;
fn run_inherited_with_cmd_context(&mut self) -> Result<()>;
fn lifecycle_bind(&mut self) -> &mut Self;
fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>>;
fn run_get_string(&mut self) -> Result<String>;
fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T>;
fn to_string_pretty(&self) -> String;
}
pub trait ExitStatusExt {
fn check_status(&mut self) -> Result<()>;
fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()>;
}
fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
const MAX_STDERR_BYTES: u16 = 1024;
let size = f
.metadata()
.map_err(|e| {
tracing::warn!("failed to fstat: {e}");
})
.map(|m| m.len().try_into().unwrap_or(u16::MAX))
.unwrap_or(0);
let size = size.min(MAX_STDERR_BYTES);
let seek_offset = -(size as i32);
let mut stderr_buf = Vec::with_capacity(size.into());
let r = match f
.seek(std::io::SeekFrom::End(seek_offset.into()))
.and_then(|_| f.read_to_end(&mut stderr_buf))
{
Ok(_) => String::from_utf8_lossy(&stderr_buf),
Err(e) => {
tracing::warn!("failed seek+read: {e}");
"<failed to read stderr>".into()
}
};
(&*r).to_owned()
}
impl ExitStatusExt for std::process::ExitStatus {
fn check_status(&mut self) -> Result<()> {
if self.success() {
return Ok(());
}
anyhow::bail!(format!("Subprocess failed: {self:?}"))
}
fn check_status_with_stderr(&mut self, stderr: std::fs::File) -> Result<()> {
let stderr_buf = last_utf8_content_from_file(stderr);
if self.success() {
return Ok(());
}
anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
}
}
impl CommandRunExt for Command {
fn run_inherited(&mut self) -> Result<()> {
tracing::trace!("exec: {self:?}");
self.status()?.check_status()
}
fn run_capture_stderr(&mut self) -> Result<()> {
let stderr = command_output_file()?;
self.stderr(stderr.try_clone()?);
tracing::trace!("exec: {self:?}");
self.status()?.check_status_with_stderr(stderr)
}
#[allow(unsafe_code)]
fn lifecycle_bind(&mut self) -> &mut Self {
unsafe {
self.pre_exec(|| {
rustix::process::set_parent_process_death_signal(Some(
rustix::process::Signal::TERM,
))
.map_err(Into::into)
})
}
}
fn log_debug(&mut self) -> &mut Self {
if !tracing::enabled!(tracing::Level::TRACE) {
tracing::debug!("exec: {self:?}");
}
self
}
fn run_get_output(&mut self) -> Result<Box<dyn std::io::BufRead>> {
let mut stdout = command_output_file()?;
self.stdout(stdout.try_clone()?);
self.run_capture_stderr()?;
stdout.seek(std::io::SeekFrom::Start(0)).context("seek")?;
Ok(Box::new(std::io::BufReader::new(stdout)))
}
fn run_get_string(&mut self) -> Result<String> {
let mut s = String::new();
let mut o = self.run_get_output()?;
o.read_to_string(&mut s)?;
Ok(s)
}
fn run_and_parse_json<T: serde::de::DeserializeOwned>(&mut self) -> Result<T> {
let output = self.run_get_output()?;
serde_json::from_reader(output).map_err(Into::into)
}
fn run_inherited_with_cmd_context(&mut self) -> Result<()> {
self.status()?
.success()
.then_some(())
.context(format!("Failed to run command: {self:#?}"))
}
fn to_string_pretty(&self) -> String {
std::iter::once(self.get_program())
.chain(self.get_args())
.fold(String::new(), |mut acc, element| {
if !acc.is_empty() {
acc.push(' ');
}
write!(&mut acc, "{}", crate::PathQuotedDisplay::new(&element)).unwrap();
acc
})
}
}
#[allow(async_fn_in_trait)]
pub trait AsyncCommandRunExt {
async fn run(&mut self) -> Result<()>;
}
impl AsyncCommandRunExt for tokio::process::Command {
async fn run(&mut self) -> Result<()> {
let stderr = command_output_file()?;
self.stderr(stderr.try_clone()?);
self.status().await?.check_status_with_stderr(stderr)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_run_inherited() {
Command::new("true").run_inherited().unwrap();
assert!(Command::new("false").run_inherited().is_err());
let e = Command::new("/bin/sh")
.args(["-c", "echo should-not-be-captured 1>&2; exit 1"])
.run_inherited()
.err()
.unwrap();
assert_eq!(
e.to_string(),
"Subprocess failed: ExitStatus(unix_wait_status(256))"
);
}
#[test]
fn command_run_capture_stderr() {
Command::new("true").run_capture_stderr().unwrap();
assert!(Command::new("false").run_capture_stderr().is_err());
let e = Command::new("/bin/sh")
.args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
.run_capture_stderr()
.err()
.unwrap();
similar_asserts::assert_eq!(
e.to_string(),
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
);
let e = Command::new("/bin/sh")
.args([
"-c",
r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
])
.run_capture_stderr()
.err()
.unwrap();
similar_asserts::assert_eq!(
e.to_string(),
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
);
}
#[test]
fn command_output_file_is_a_memfd() {
use std::os::fd::AsRawFd;
let file = command_output_file().unwrap();
let fd_path = format!("/proc/self/fd/{}", file.as_raw_fd());
let target = std::fs::read_link(fd_path).unwrap();
assert!(
target
.to_string_lossy()
.contains("memfd:bootc-command-output")
);
}
#[test]
fn exit_status_check_status() {
use std::process::Command;
let mut success_status = Command::new("true").status().unwrap();
success_status.check_status().unwrap();
let mut fail_status = Command::new("false").status().unwrap();
let e = fail_status.check_status().err().unwrap();
assert_eq!(
e.to_string(),
"Subprocess failed: ExitStatus(unix_wait_status(256))"
);
}
#[test]
fn exit_status_check_status_with_stderr() {
use std::io::Write;
use std::process::Command;
let mut success_status = Command::new("true").status().unwrap();
let temp_stderr = command_output_file().unwrap();
success_status
.check_status_with_stderr(temp_stderr)
.unwrap();
let mut fail_status = Command::new("false").status().unwrap();
let mut temp_stderr = command_output_file().unwrap();
write!(temp_stderr, "test error message").unwrap();
let e = fail_status
.check_status_with_stderr(temp_stderr)
.err()
.unwrap();
assert!(
e.to_string()
.contains("Subprocess failed: ExitStatus(unix_wait_status(256))")
);
assert!(e.to_string().contains("test error message"));
}
#[test]
fn command_run_ext_json() {
#[derive(serde::Deserialize)]
struct Foo {
a: String,
b: u32,
}
let v: Foo = Command::new("echo")
.arg(r##"{"a": "somevalue", "b": 42}"##)
.run_and_parse_json()
.unwrap();
assert_eq!(v.a, "somevalue");
assert_eq!(v.b, 42);
}
#[tokio::test]
async fn async_command_run_ext() {
use tokio::process::Command as AsyncCommand;
let mut success = AsyncCommand::new("true");
let mut fail = AsyncCommand::new("false");
let (success, fail) = tokio::join!(success.run(), fail.run(),);
success.unwrap();
assert!(fail.is_err());
let error = AsyncCommand::new("/bin/sh")
.args(["-c", "echo expected-async-error 1>&2; exit 1"])
.run()
.await
.unwrap_err();
assert!(error.to_string().contains("expected-async-error"));
}
#[test]
fn to_string_pretty_preserves_empty_arguments() {
let mut cmd = Command::new("printf");
cmd.args(["", "%s", ""]);
let displayed = cmd.to_string_pretty();
assert_eq!(displayed, "printf '' '%s' ''");
assert_eq!(shlex::split(&displayed).unwrap(), ["printf", "", "%s", ""]);
}
#[test]
fn to_string_pretty() {
let mut cmd = Command::new("podman");
cmd.args([
"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",
]);
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"
);
}
}