use std::{iter::Peekable, str::Chars};
use color_eyre::eyre::{Result, eyre};
use crate::discovery::Entry;
use super::is_supported_field;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Fragment {
Literal(String),
Field(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Token {
fragments: Vec<Fragment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandTemplate {
tokens: Vec<Token>,
}
impl CommandTemplate {
pub fn compile(command: &str) -> Result<Self> {
Self::compile_with_option_policy(command, false)
}
pub(super) fn compile_allowing_option_like_values(command: &str) -> Result<Self> {
Self::compile_with_option_policy(command, true)
}
fn compile_with_option_policy(command: &str, allow_option_like_values: bool) -> Result<Self> {
let tokens = tokenize(command)?;
let Some(program) = tokens.first() else {
return Err(eyre!("action command is empty"));
};
if program.fragments.is_empty() {
return Err(eyre!("action command has an empty program name"));
}
if program
.fragments
.iter()
.any(|fragment| matches!(fragment, Fragment::Field(_)))
{
return Err(eyre!(
"action command program must be literal; a discovered field cannot select the executable"
));
}
if !allow_option_like_values {
validate_option_like_values(&tokens)?;
}
Ok(Self { tokens })
}
pub(super) fn fields(&self) -> impl Iterator<Item = &str> {
self.tokens
.iter()
.flat_map(|token| token.fragments.iter())
.filter_map(|fragment| match fragment {
Fragment::Field(name) => Some(name.as_str()),
Fragment::Literal(_) => None,
})
}
pub(super) fn references(&self, field: &str) -> bool {
self.fields().any(|name| name == field)
}
pub(super) fn render(&self, record: &Entry) -> Result<Vec<String>> {
self.tokens
.iter()
.map(|token| token.render(record))
.collect()
}
}
fn validate_option_like_values(tokens: &[Token]) -> Result<()> {
let mut options_terminated = false;
for token in tokens.iter().skip(1) {
if token.is_literal("--") {
options_terminated = true;
continue;
}
if options_terminated {
continue;
}
if let Some(field) = token.leading_option_sensitive_field() {
return Err(eyre!(
"service field `{{{field}}}` can begin an option-like argument; put a literal `--` before it or set `action.allow_option_like_values = true`"
));
}
}
Ok(())
}
impl Token {
fn is_literal(&self, expected: &str) -> bool {
matches!(self.fragments.as_slice(), [Fragment::Literal(value)] if value == expected)
}
fn leading_option_sensitive_field(&self) -> Option<&str> {
for fragment in &self.fragments {
match fragment {
Fragment::Literal(text) if text.is_empty() => {}
Fragment::Literal(_) => return None,
Fragment::Field(field) if matches!(field.as_str(), "address" | "port") => {
return None;
}
Fragment::Field(field) => return Some(field),
}
}
None
}
fn render(&self, record: &Entry) -> Result<String> {
let mut argument = String::new();
for fragment in &self.fragments {
match fragment {
Fragment::Literal(text) => argument.push_str(text),
Fragment::Field(field) => {
let Some(value) = record.field_value(field) else {
return Err(eyre!(
"service field `{field}` is unavailable for `{}`",
record.name
));
};
argument.push_str(&value);
}
}
}
Ok(argument)
}
}
fn tokenize(command: &str) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let mut fragments: Vec<Fragment> = Vec::new();
let mut literal = String::new();
let mut started = false;
let mut quote: Option<char> = None;
let mut chars = command.chars().peekable();
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(active), ch) if ch == active => quote = None,
(_, '\\') => {
let Some(escaped) = chars.next() else {
return Err(eyre!("dangling `\\` at the end of `{command}`"));
};
literal.push(escaped);
started = true;
}
(_, '{') => {
if chars.next_if_eq(&'{').is_some() {
literal.push('{');
} else {
let field = read_placeholder(&mut chars, command)?;
flush_literal(&mut literal, &mut fragments);
fragments.push(Fragment::Field(field));
}
started = true;
}
(None, '"' | '\'') => {
quote = Some(ch);
started = true;
}
(None, ch) if ch.is_whitespace() => {
if started {
flush_literal(&mut literal, &mut fragments);
tokens.push(Token {
fragments: std::mem::take(&mut fragments),
});
started = false;
}
}
(_, ch) => {
literal.push(ch);
started = true;
}
}
}
if let Some(active) = quote {
return Err(eyre!("unterminated `{active}` quote in `{command}`"));
}
if started {
flush_literal(&mut literal, &mut fragments);
tokens.push(Token { fragments });
}
Ok(tokens)
}
fn read_placeholder(chars: &mut Peekable<Chars<'_>>, command: &str) -> Result<String> {
let mut field = String::new();
loop {
let Some(ch) = chars.next() else {
return Err(eyre!("unterminated placeholder `{{` in `{command}`"));
};
match ch {
'}' => break,
'{' => return Err(eyre!("nested `{{` inside a placeholder in `{command}`")),
_ => field.push(ch),
}
}
if field.is_empty() {
return Err(eyre!("empty placeholder `{{}}` in `{command}`"));
}
if !is_supported_field(&field) {
return Err(eyre!(
"unknown service field `{field}` in `{command}`; \
supported fields are name, service_type (or type), domain, \
hostname, address, port, and txt.<key>"
));
}
Ok(field)
}
fn flush_literal(literal: &mut String, fragments: &mut Vec<Fragment>) {
if !literal.is_empty() {
fragments.push(Fragment::Literal(std::mem::take(literal)));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(template: &str) -> Vec<String> {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some("alpha.local".to_string());
record.addresses = vec!["192.0.2.5".parse().unwrap()];
record.port = Some(22);
record.txt.insert("path".to_string(), "/admin".to_string());
CommandTemplate::compile_allowing_option_like_values(template)
.expect("template compiles")
.render(&record)
.expect("all fields resolve")
}
fn error(template: &str) -> String {
CommandTemplate::compile(template)
.expect_err("template must be rejected")
.to_string()
}
#[test]
fn unquoted_whitespace_separates_arguments() {
assert_eq!(
render("ssh alpha\tbeta\ngamma"),
["ssh", "alpha", "beta", "gamma"]
);
}
#[test]
fn quotes_are_removed_and_contents_preserved() {
assert_eq!(
render(r#"printf "two words" 'single quoted'"#),
["printf", "two words", "single quoted"]
);
}
#[test]
fn the_other_quote_style_stays_literal_inside_a_quote() {
assert_eq!(
render(r#"echo "it's" 'say "hi"'"#),
["echo", "it's", r#"say "hi""#]
);
}
#[test]
fn adjacent_fragments_form_one_argument() {
assert_eq!(render(r#"echo a"b"'c'd"#), ["echo", "abcd"]);
assert_eq!(
render(r#"ssh user@"{hostname}":22"#),
["ssh", "user@alpha.local:22"]
);
}
#[test]
fn backslash_escapes_the_next_scalar_inside_and_outside_quotes() {
assert_eq!(render(r"echo one\ arg"), ["echo", "one arg"]);
assert_eq!(render(r#"echo "a\"b" 'c\'d'"#), ["echo", r#"a"b"#, "c'd"]);
assert_eq!(render(r"echo \{hostname\}"), ["echo", "{hostname}"]);
assert_eq!(render(r"echo \z"), ["echo", "z"]);
}
#[test]
fn quoted_empty_arguments_are_preserved_including_at_the_end() {
assert_eq!(render(r#"cmd "" next"#), ["cmd", "", "next"]);
assert_eq!(render("cmd ''"), ["cmd", ""]);
assert_eq!(render(r#"cmd '' """#), ["cmd", "", ""]);
}
#[test]
fn dangling_backslash_is_rejected() {
assert!(error(r"echo \").contains("dangling"));
assert!(error(r"echo 'a\").contains("dangling"));
}
#[test]
fn unterminated_quote_is_rejected() {
assert!(error("echo 'alpha").contains("unterminated `'` quote"));
assert!(error(r#"echo "alpha"#).contains("unterminated `\"` quote"));
}
#[test]
fn double_brace_emits_a_literal_brace_and_a_lone_close_brace_stays_literal() {
assert_eq!(render("echo {{hostname}"), ["echo", "{hostname}"]);
assert_eq!(render("echo }"), ["echo", "}"]);
assert_eq!(render("echo {hostname}}"), ["echo", "alpha.local}"]);
}
#[test]
fn malformed_placeholders_are_rejected() {
assert!(error("echo {name").contains("unterminated placeholder"));
assert!(error("echo {}").contains("empty placeholder"));
assert!(error("echo {na{me}}").contains("nested"));
assert!(error("echo {nonexistent_field}").contains("unknown service field"));
assert!(error("echo {service_typ}").contains("unknown service field"));
}
#[test]
fn every_supported_field_and_alias_compiles_and_renders() {
assert_eq!(
render(
"run {name} {type} {service_type} {domain} {hostname} {address} {port} {txt.path}"
),
[
"run",
"alpha",
"_ssh._tcp",
"_ssh._tcp",
"local",
"alpha.local",
"192.0.2.5",
"22",
"/admin",
]
);
}
#[test]
fn arbitrary_txt_keys_are_supported_but_a_bare_txt_is_not() {
assert!(
CommandTemplate::compile_allowing_option_like_values("echo {txt.anything-at-all}")
.is_ok()
);
assert!(
CommandTemplate::compile_allowing_option_like_values("echo {txt.txt.path}").is_ok()
);
assert!(error("echo {txt}").contains("unknown service field"));
assert!(error("echo {txt.}").contains("unknown service field"));
}
#[test]
fn empty_and_whitespace_only_commands_are_rejected() {
assert!(error("").contains("empty"));
assert!(error(" \t\n ").contains("empty"));
assert!(error(r#""""#).contains("empty program name"));
assert!(error("''").contains("empty program name"));
}
#[test]
fn a_discovered_field_cannot_select_the_program() {
let err = CommandTemplate::compile_allowing_option_like_values("{hostname} --flag")
.unwrap_err()
.to_string();
assert!(err.contains("program must be literal"));
}
#[test]
fn option_sensitive_fields_need_a_terminator_or_explicit_opt_out() {
let err = CommandTemplate::compile("ssh {hostname}")
.unwrap_err()
.to_string();
assert!(err.contains("can begin an option-like argument"));
assert!(err.contains("allow_option_like_values"));
assert!(CommandTemplate::compile("ssh -- {hostname}").is_ok());
assert!(CommandTemplate::compile("open http://{hostname}").is_ok());
assert!(CommandTemplate::compile("ssh -p {port} -- {hostname}").is_ok());
assert!(
CommandTemplate::compile_allowing_option_like_values("program {txt.value}").is_ok()
);
}
#[test]
fn fields_and_references_report_the_compiled_placeholders() {
let template =
CommandTemplate::compile("curl http://{hostname}:{port}/{txt.path}").unwrap();
assert_eq!(
template.fields().collect::<Vec<_>>(),
["hostname", "port", "txt.path"]
);
assert!(template.references("port"));
assert!(!template.references("address"));
assert!(
!CommandTemplate::compile("echo {{port}")
.unwrap()
.references("port")
);
}
#[test]
fn a_missing_field_fails_rendering_rather_than_dropping_an_argument() {
let record = Entry::new("alpha", "_ssh._tcp", "local");
let template = CommandTemplate::compile("ssh -- {hostname}").unwrap();
let err = template.render(&record).unwrap_err().to_string();
assert!(err.contains("service field `hostname`"));
assert!(err.contains("alpha"));
}
#[test]
fn field_values_cannot_reshape_argv() {
let mut record = Entry::new("alpha", "_ssh._tcp", "local");
record.hostname = Some(r#"h.local' -oProxyCommand=evil ' "x" \ {name} {{ }"#.to_string());
let template = CommandTemplate::compile("ssh -- {hostname} tail").unwrap();
let argv = template.render(&record).unwrap();
assert_eq!(
argv,
[
"ssh",
"--",
r#"h.local' -oProxyCommand=evil ' "x" \ {name} {{ }"#,
"tail",
]
);
}
}