use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use color_eyre::eyre::{Result, eyre};
use crate::discovery::Entry;
use super::{ActionMode, CommandAction};
#[derive(Debug, Clone)]
pub struct PreparedCommand {
pub argv: Vec<String>,
pub mode: ActionMode,
}
pub fn prepare(action: &CommandAction, record: &Entry) -> Result<PreparedCommand> {
let argv = split_command_line(&action.command)?
.iter()
.map(|token| interpolate(token, record))
.collect::<Result<Vec<_>>>()?;
if argv.is_empty() {
return Err(eyre!("action command expanded to an empty argv"));
}
Ok(PreparedCommand {
argv,
mode: action.mode,
})
}
pub fn fork(command: &PreparedCommand) -> Result<()> {
let mut child = Command::new(&command.argv[0])
.args(&command.argv[1..])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|err| spawn_error(&command.argv[0], err))?;
std::thread::spawn(move || {
let _ = child.wait();
});
Ok(())
}
pub fn exec(command: PreparedCommand) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = Command::new(&command.argv[0])
.args(&command.argv[1..])
.exec();
Err(spawn_error(&command.argv[0], err))
}
#[cfg(not(unix))]
{
let status = Command::new(&command.argv[0])
.args(&command.argv[1..])
.status()
.map_err(|err| spawn_error(&command.argv[0], err))?;
if status.success() {
Ok(())
} else {
Err(eyre!("process exited with status {status}"))
}
}
}
fn spawn_error(program: &str, err: std::io::Error) -> color_eyre::eyre::Report {
if err.kind() == std::io::ErrorKind::NotFound {
eyre!("command `{program}` not found")
} else {
eyre!("could not start `{program}`: {err}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
pub command: String,
pub optional: bool,
}
pub fn parse_requirement(raw: &str) -> Requirement {
let mut parts = raw.splitn(2, ',');
let command = parts.next().unwrap_or("").trim().to_string();
let optional = parts
.next()
.is_some_and(|rest| rest.trim().eq_ignore_ascii_case("optional"));
Requirement { command, optional }
}
pub fn missing_requirement(requirements: &[String]) -> Option<String> {
requirements
.iter()
.map(|raw| parse_requirement(raw))
.filter(|req| !req.optional && !req.command.is_empty())
.find(|req| locate(&req.command).is_none())
.map(|req| req.command)
}
pub fn locate(program: &str) -> Option<PathBuf> {
if program.is_empty() {
return None;
}
if program.chars().any(std::path::is_separator) {
let path = PathBuf::from(program);
return is_executable(&path).then_some(path);
}
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.flat_map(|dir| candidates_in(&dir, program))
.find(|candidate| is_executable(candidate))
}
#[cfg(windows)]
fn candidates_in(dir: &Path, program: &str) -> Vec<PathBuf> {
let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let mut candidates = vec![dir.join(program)];
candidates.extend(
pathext
.split(';')
.filter(|ext| !ext.is_empty())
.map(|ext| dir.join(format!("{program}{ext}"))),
);
candidates
}
#[cfg(not(windows))]
fn candidates_in(dir: &Path, program: &str) -> Vec<PathBuf> {
vec![dir.join(program)]
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
fn interpolate(template: &str, record: &Entry) -> Result<String> {
let mut output = String::new();
let mut chars = template.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '{' {
output.push(ch);
continue;
}
let mut field = String::new();
loop {
let Some(next) = chars.next() else {
return Err(eyre!("unterminated interpolation in `{template}`"));
};
if next == '}' {
break;
}
field.push(next);
}
let Some(value) = record.field(&field) else {
return Err(eyre!(
"service field `{field}` is unavailable for `{}`",
record.name
));
};
output.push_str(&value);
}
Ok(output)
}
fn split_command_line(command: &str) -> Result<Vec<String>> {
let mut argv = Vec::new();
let mut current = String::new();
let mut chars = command.chars().peekable();
let mut quote: Option<char> = None;
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(q), c) if c == q => quote = None,
(Some(_), '\\') => {
if let Some(next) = chars.next() {
current.push(next);
}
}
(Some(_), c) => current.push(c),
(None, '"' | '\'') => quote = Some(ch),
(None, '\\') => {
if let Some(next) = chars.next() {
current.push(next);
}
}
(None, c) if c.is_whitespace() => {
if !current.is_empty() {
argv.push(std::mem::take(&mut current));
}
}
(None, c) => current.push(c),
}
}
if let Some(q) = quote {
return Err(eyre!("unterminated `{q}` quote in command"));
}
if !current.is_empty() {
argv.push(current);
}
Ok(argv)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plumber::ActionMode;
use std::net::{IpAddr, Ipv4Addr};
#[test]
fn interpolates_and_splits() {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some("alpha.local".to_string());
let action = CommandAction {
description: None,
command: "ssh '{hostname}'".to_string(),
mode: ActionMode::Execute,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(prepared.argv, vec!["ssh", "alpha.local"]);
}
#[test]
fn prepares_all_supported_service_fields() {
let mut record = Entry::new("Kitchen Printer", "_ipp._tcp", "local");
record.hostname = Some("printer.local".to_string());
record.addresses = vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 20))];
record.port = Some(631);
record
.txt
.insert("path".to_string(), "/ipp/print".to_string());
let action = CommandAction {
description: None,
command: "open '{name}' {service_type} {domain} {hostname} {address} {port} {txt.path}"
.to_string(),
mode: ActionMode::Fork,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(prepared.mode, ActionMode::Fork);
assert_eq!(
prepared.argv,
vec![
"open",
"Kitchen Printer",
"_ipp._tcp",
"local",
"printer.local",
"192.0.2.20",
"631",
"/ipp/print",
]
);
}
#[test]
fn splits_quoted_and_escaped_arguments() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let action = CommandAction {
description: None,
command: r#"printf "two words" one\ arg 'single quoted' "\\""#.to_string(),
mode: ActionMode::Execute,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(
prepared.argv,
vec!["printf", "two words", "one arg", "single quoted", "\\"]
);
}
#[test]
fn interpolated_values_cannot_inject_arguments() {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some("h.local' -oProxyCommand=evil '".to_string());
let action = CommandAction {
description: None,
command: "ssh {hostname}".to_string(),
mode: ActionMode::Execute,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(prepared.argv, vec!["ssh", "h.local' -oProxyCommand=evil '"]);
}
#[test]
fn values_with_quotes_and_spaces_stay_single_arguments() {
let mut record = Entry::new("O'Brien Printer", "_ipp._tcp", "local");
record.hostname = Some("printer.local".to_string());
let action = CommandAction {
description: None,
command: "notify-send {name} {hostname}".to_string(),
mode: ActionMode::Fork,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(
prepared.argv,
vec!["notify-send", "O'Brien Printer", "printer.local"]
);
}
#[test]
fn missing_interpolation_field_is_an_error() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let action = CommandAction {
description: None,
command: "ssh {hostname}".to_string(),
mode: ActionMode::Execute,
};
let err = prepare(&action, &record).unwrap_err();
assert!(err.to_string().contains("service field `hostname`"));
assert!(err.to_string().contains("alpha"));
}
#[test]
fn malformed_templates_and_quotes_are_errors() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let unterminated_interpolation = CommandAction {
description: None,
command: "echo {name".to_string(),
mode: ActionMode::Execute,
};
let unterminated_quote = CommandAction {
description: None,
command: "echo 'alpha".to_string(),
mode: ActionMode::Execute,
};
assert!(
prepare(&unterminated_interpolation, &record)
.unwrap_err()
.to_string()
.contains("unterminated interpolation")
);
assert!(
prepare(&unterminated_quote, &record)
.unwrap_err()
.to_string()
.contains("unterminated `'` quote")
);
}
#[test]
fn fork_spawns_a_real_process() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let action = CommandAction {
description: None,
command: "true".to_string(),
mode: ActionMode::Fork,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(prepared.mode, ActionMode::Fork);
fork(&prepared).unwrap();
}
#[test]
fn fork_reports_a_missing_binary() {
let command = PreparedCommand {
argv: vec!["kinjo-no-such-binary-xyz".to_string()],
mode: ActionMode::Fork,
};
let err = fork(&command).unwrap_err();
assert!(
err.to_string()
.contains("command `kinjo-no-such-binary-xyz` not found")
);
}
#[test]
fn interpolates_txt_record_fields() {
let mut record = Entry::new("nas", "_http._tcp", "local");
record.hostname = Some("nas.local".to_string());
record.txt.insert("path".to_string(), "/admin".to_string());
let action = CommandAction {
description: None,
command: "xdg-open http://{hostname}{txt.path}".to_string(),
mode: ActionMode::Fork,
};
let prepared = prepare(&action, &record).unwrap();
assert_eq!(prepared.argv, vec!["xdg-open", "http://nas.local/admin"]);
}
#[test]
fn missing_txt_field_is_an_error() {
let record = Entry::new("nas", "_http._tcp", "local");
let action = CommandAction {
description: None,
command: "echo {txt.path}".to_string(),
mode: ActionMode::Fork,
};
let err = prepare(&action, &record).unwrap_err();
assert!(err.to_string().contains("service field `txt.path`"));
}
#[test]
fn empty_command_after_splitting_is_an_error() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let action = CommandAction {
description: None,
command: " ".to_string(),
mode: ActionMode::Execute,
};
let err = prepare(&action, &record).unwrap_err();
assert!(err.to_string().contains("empty argv"));
}
#[test]
fn parse_requirement_detects_the_optional_marker() {
assert_eq!(
parse_requirement("xdg-open"),
Requirement {
command: "xdg-open".to_string(),
optional: false,
}
);
assert_eq!(
parse_requirement(" browser , Optional "),
Requirement {
command: "browser".to_string(),
optional: true,
}
);
assert!(!parse_requirement("foo, please").optional);
}
#[cfg(windows)]
const PRESENT_COMMAND: &str = "cmd.exe";
#[cfg(not(windows))]
const PRESENT_COMMAND: &str = "sh";
#[test]
fn locate_resolves_absolute_paths_and_path_lookups() {
let exe = std::env::current_exe().unwrap();
assert!(locate(exe.to_str().unwrap()).is_some());
assert!(locate(PRESENT_COMMAND).is_some());
assert!(locate("kinjo-no-such-binary-xyz").is_none());
assert!(locate("/no/such/absolute/path/xyz").is_none());
assert!(locate("").is_none());
}
#[cfg(windows)]
#[test]
fn locate_resolves_bare_names_via_pathext() {
assert!(locate("cmd").is_some());
}
#[test]
fn missing_requirement_skips_optional_and_present_commands() {
assert_eq!(missing_requirement(&[]), None);
assert_eq!(missing_requirement(&[PRESENT_COMMAND.to_string()]), None);
assert_eq!(
missing_requirement(&["definitely-absent-xyz, optional".to_string()]),
None
);
assert_eq!(
missing_requirement(&[
PRESENT_COMMAND.to_string(),
"definitely-absent-xyz".to_string()
]),
Some("definitely-absent-xyz".to_string())
);
}
}