// REGION: API // api: util::CmdOutput { status: i32, stdout: String, stderr: String } // api: util::which_tool(name: &str) -> crate::Result<Option<String>> // api: util::run_cmd(args: &[&str]) -> crate::Result<()> // api: util::run_cmd_capture(args: &[&str]) -> crate::Result<CmdOutput> // api: util::udev_settle(timeout_ms: u64) -> crate::Result<()> // REGION: API-END // // REGION: RESPONSIBILITIES // - Centralize external tool discovery and invocation (sgdisk, blkid, mkfs.*, udevadm). // - Provide capture and error mapping to crate::Error consistently. // Non-goals: business logic (planning/validation), direct parsing of complex outputs beyond what callers need. // REGION: RESPONSIBILITIES-END // // REGION: EXTENSION_POINTS // ext: pluggable command runner for tests/dry-run; inject via cfg(test) or trait in future. // ext: backoff/retry policies for transient tool failures (feature-gated). // REGION: EXTENSION_POINTS-END // // REGION: SAFETY // safety: never mutate state here except invoking intended external tools; callers enforce preconditions. // safety: capture stderr/stdout to aid diagnostics without leaking sensitive data. // REGION: SAFETY-END // // REGION: ERROR_MAPPING // errmap: process::ExitStatus non-zero -> crate::Error::Tool { tool, status, stderr }. // errmap: IO/spawn errors -> crate::Error::Other(anyhow) with context. // REGION: ERROR_MAPPING-END // // REGION: TODO // todo: implement which_tool via 'which' crate; consider PATH overrides in initramfs. // todo: implement run_cmd and run_cmd_capture with tracing spans. // todo: implement udev_settle to no-op if udevadm missing, with warn-level log. // REGION: TODO-END //! Utility helpers for external tool invocation and system integration. //! //! All shell-outs are centralized here to enable testing, structured logging, //! and consistent error handling. use crate::{Error, Result}; use std::process::Command; use tracing::{debug, warn}; /// Captured output from an external tool invocation. #[derive(Debug, Clone)] pub struct CmdOutput { /// Process exit status code. pub status: i32, /// Captured stdout as UTF-8 (lossy if needed). pub stdout: String, /// Captured stderr as UTF-8 (lossy if needed). pub stderr: String, } /// Locate the absolute path to a required tool if available in PATH. /// /// Returns Ok(Some(path)) when found, Ok(None) when missing. pub fn which_tool(name: &str) -> Result> { match which::which(name) { Ok(path) => Ok(Some(path.to_string_lossy().into_owned())), Err(which::Error::CannotFindBinaryPath) => Ok(None), Err(e) => Err(Error::Other(anyhow::anyhow!("which({name}) failed: {e}"))), } } /// Run a command and return Ok if the exit status is zero. /// /// args[0] must be the program binary path; the rest are arguments. /// On non-zero exit, returns Error::Tool with captured stderr. pub fn run_cmd(args: &[&str]) -> Result<()> { if args.is_empty() { return Err(Error::Other(anyhow::anyhow!( "run_cmd requires at least one arg (the program)" ))); } debug!(target: "util.run_cmd", "exec: {:?}", args); let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) })?; let status_code = output.status.code().unwrap_or(-1); if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); return Err(Error::Tool { tool: args[0].to_string(), status: status_code, stderr, }); } Ok(()) } /// Run a command and capture stdout/stderr for parsing (e.g., blkid). /// /// On non-zero exit, returns Error::Tool with captured stderr and status. pub fn run_cmd_capture(args: &[&str]) -> Result { if args.is_empty() { return Err(Error::Other(anyhow::anyhow!( "run_cmd_capture requires at least one arg (the program)" ))); } debug!(target: "util.run_cmd_capture", "exec: {:?}", args); let output = Command::new(args[0]).args(&args[1..]).output().map_err(|e| { Error::Other(anyhow::anyhow!("failed to spawn {:?}: {}", args, e)) })?; let status_code = output.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if !output.status.success() { return Err(Error::Tool { tool: args[0].to_string(), status: status_code, stderr, }); } Ok(CmdOutput { status: status_code, stdout, stderr, }) } /// Call udevadm settle with a timeout; warn if unavailable, then no-op. /// /// Ensures the system has settled after partition table changes. pub fn udev_settle(timeout_ms: u64) -> Result<()> { // Locate udevadm let Some(udevadm) = which_tool("udevadm")? else { warn!("udevadm not found; skipping udev settle"); return Ok(()); }; let timeout_arg = format!("--timeout={}", timeout_ms / 1000); // udevadm takes seconds; floor // Some implementations accept milliseconds if provided without units; prefer seconds for portability. let args = [udevadm.as_str(), "settle", timeout_arg.as_str()]; // We intentionally ignore non-zero exit as some initramfs may not have udev running. match Command::new(args[0]).args(&args[1..]).status() { Ok(_status) => { debug!("udevadm settle invoked"); Ok(()) } Err(e) => { warn!("failed to invoke udevadm settle: {}", e); Ok(()) } } } #[cfg(test)] mod tests { use super::*; #[test] fn which_tool_finds_sh() { // 'sh' should exist on virtually all Linux systems let p = which_tool("sh").expect("which_tool failed"); assert!(p.is_some(), "expected to find 'sh' in PATH"); } #[test] fn which_tool_missing() { let p = which_tool("definitely-not-a-cmd-xyz").expect("which_tool failed"); assert!(p.is_none()); } #[test] fn run_cmd_true_ok() { // Use sh -c true to ensure availability run_cmd(&["sh", "-c", "true"]).expect("true should succeed"); } #[test] fn run_cmd_false_err() { let err = run_cmd(&["sh", "-c", "false"]).unwrap_err(); match err { Error::Tool { tool, status, .. } => { assert_eq!(tool, "sh"); assert_ne!(status, 0); } other => panic!("expected Error::Tool, got: {:?}", other), } } #[test] fn run_cmd_capture_echo_stdout() { let out = run_cmd_capture(&["sh", "-c", "printf hello"]).expect("capture ok"); assert_eq!(out.stdout, "hello"); assert_eq!(out.status, 0); } #[test] fn udev_settle_ok_even_if_missing() { // Should never fail even if udevadm is missing. udev_settle(1000).expect("udev_settle should be non-fatal"); } }