use std::time::Duration; use tokio::{process::Command, time::timeout}; use tracing::error; pub fn to_file_name( config_name: &str, timestamp: u64, ) -> Result> { // reject empty or unsafe names to avoid path traversal or invalid filesystem chars if config_name.is_empty() { return Err("config_name is empty".into()); } if config_name.len() > 255 { return Err("config_name too long".into()); } if config_name.contains('/') || config_name.contains('\\') || config_name.contains("..") { return Err("config_name contains invalid path characters".into()); } if !config_name .chars() .all(|c| c.is_ascii_alphanumeric() || "-._".contains(c)) { return Err("config_name contains invalid characters".into()); } Ok(format!("{}_{}.conf", timestamp, config_name)) } pub async fn run_cmd( cmd: &str, args: &[&str], dur_s: u64, ) -> Result<(i32, String), Box> { let mut c = Command::new(cmd); c.args(args); let res = timeout(Duration::from_secs(dur_s), c.output()).await; let out = match res { Ok(Ok(out)) => out, Ok(Err(e)) => return Err(Box::new(e)), Err(_) => { return Err(Box::new(std::io::Error::new( std::io::ErrorKind::TimedOut, "command timeout", ))); } }; let code = out.status.code().unwrap_or(-1); let output = String::from_utf8_lossy(&[out.stdout, out.stderr].concat()).to_string(); if code != 0 { error!("command failed ({}): {}", code, output); return Err(format!("command failed ({}): {}", code, output).into()); } Ok((code, output)) } #[cfg(test)] mod tests { use super::to_file_name; #[test] fn to_file_name_valid() { let res = to_file_name("myconf", 1234).expect("should succeed"); assert_eq!(res, "1234_myconf.conf"); } #[test] fn to_file_name_empty() { assert!(to_file_name("", 1).is_err()); } #[test] fn to_file_name_invalid_chars() { assert!(to_file_name("bad/name", 1).is_err()); assert!(to_file_name("bad\\name", 1).is_err()); assert!(to_file_name("bad..name", 1).is_err()); assert!(to_file_name("bad$name", 1).is_err()); } #[test] fn to_file_name_too_long() { let long = "a".repeat(300); assert!(to_file_name(&long, 1).is_err()); } }