From 401ce784a05c06b5da0c29616b71f1acf7c6ec32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:15:42 +0000 Subject: [PATCH 01/13] feat(templ): parse template references with static/command fallbacks Replace the plain-key-only regex scanner in src/templ.rs with a manual scanner/parser that yields one Reference per occurrence, preserving the byte span of each match. Supports plain {{NAME}}, static {{NAME:value}}, and command {{NAME|command}} forms while keeping the existing NAME rule ([A-Za-z0-9_-], 1-32 chars). Delimiter content after the first ':' or '|' is treated literally up to the next "}}", so additional ':'/'|' characters remain part of the fallback. Invalid, unterminated, or non-name constructs are left as ordinary template text, matching prior rendering behavior. find_keys is now implemented on top of scan() and keeps its previous signature and legacy discovery behavior, so src/template.rs is unaffected by this change. Adds parser tests for all three reference forms, literal ':'/'|' retention in fallback content, repeated names with distinct fallbacks, name-length boundaries, invalid/unterminated constructs, and legacy key discovery. --- src/templ.rs | 221 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 207 insertions(+), 14 deletions(-) diff --git a/src/templ.rs b/src/templ.rs index 1d4827d..4c6d4f2 100644 --- a/src/templ.rs +++ b/src/templ.rs @@ -1,33 +1,226 @@ -use lazy_static::lazy_static; -use regex::Regex; use std::collections::HashSet; +use std::ops::Range; -lazy_static! { - static ref VAR_REGEX: Regex = Regex::new(r"\{\{[A-Za-z0-9_-]{1,32}\}\}").unwrap(); +const NAME_MAX_LEN: usize = 32; + +/// A template reference discovered in a Handlebars-like template string, e.g. +/// `{{NAME}}`, `{{NAME:value}}`, or `{{NAME|command}}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reference { + pub name: String, + pub fallback: Fallback, + pub span: Range, +} + +/// The fallback carried by a template reference, if any. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fallback { + None, + Static(String), + Command(String), +} + +/// Scan `template` and return every valid reference in occurrence order, +/// preserving the byte span (relative to `template`) each reference occupies. +/// +/// Only well-formed references are yielded. Invalid, unterminated, or +/// non-name constructs (e.g. `{{}}`, `{{ }}`, or a construct missing its +/// closing `}}`) are left untouched as ordinary template text and are not +/// reported. +pub fn scan(template: &str) -> Vec { + let mut refs: Vec = Vec::new(); + let mut i: usize = 0; + let bytes: &[u8] = template.as_bytes(); + + while i + 1 < bytes.len() { + if bytes[i] == b'{' && bytes[i + 1] == b'{' { + if let Some((reference, next)) = parse_reference(template, i) { + refs.push(reference); + i = next; + continue; + } + } + i += 1; + } + + refs } +/// Discover the distinct reference names present in `template`, regardless +/// of fallback form. This is the legacy key-discovery behavior, now backed +/// by [`scan`]. pub fn find_keys(template: &str) -> HashSet { - VAR_REGEX - .find_iter(template) - .map(|m: regex::Match| trim_braces(m.as_str()).to_string()) - .collect() + scan(template).into_iter().map(|reference: Reference| reference.name).collect() } -fn trim_braces(input: &str) -> &str { - let end: usize = input.len() - 2; - input.get(2..end).unwrap() +fn parse_reference(template: &str, start: usize) -> Option<(Reference, usize)> { + let name_start: usize = start + 2; + let name_end: usize = parse_name_end(template, name_start); + + if name_end == name_start { + return None; + } + + let name: String = template.get(name_start..name_end)?.to_string(); + let bytes: &[u8] = template.as_bytes(); + + match bytes.get(name_end) { + Some(b'}') if bytes.get(name_end + 1) == Some(&b'}') => { + let end: usize = name_end + 2; + Some(( + Reference { + name, + fallback: Fallback::None, + span: start..end, + }, + end, + )) + } + Some(b':') => parse_delimited(template, start, name, name_end + 1, Fallback::Static), + Some(b'|') => parse_delimited(template, start, name, name_end + 1, Fallback::Command), + _ => None, + } +} + +fn parse_delimited( + template: &str, + start: usize, + name: String, + content_start: usize, + ctor: fn(String) -> Fallback, +) -> Option<(Reference, usize)> { + let rest: &str = template.get(content_start..)?; + let close: usize = rest.find("}}")?; + let content: String = rest.get(..close)?.to_string(); + let end: usize = content_start + close + 2; + Some(( + Reference { + name, + fallback: ctor(content), + span: start..end, + }, + end, + )) +} + +fn parse_name_end(template: &str, start: usize) -> usize { + let bytes: &[u8] = template.as_bytes(); + let mut i: usize = start; + + while i < bytes.len() && i - start < NAME_MAX_LEN && is_name_byte(bytes[i]) { + i += 1; + } + + i +} + +fn is_name_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' } #[cfg(test)] mod tests { use std::collections::HashSet; - use crate::templ; + use crate::templ::{self, Fallback, Reference}; #[test] - fn find_template_keys() { + fn find_template_keys_legacy_discovery() { let template = "{{FOO}} {{}}- {{{}}} {{ }} {{BAR}}"; - let keys: HashSet = templ::find_keys(&template); + let keys: HashSet = templ::find_keys(template); + let expected: HashSet = + [String::from("FOO"), String::from("BAR")].into_iter().collect(); + assert_eq!(expected, keys); + } + + #[test] + fn scan_plain_reference() { + let template = "{{FOO}}"; + let refs: Vec = templ::scan(template); + assert_eq!(1, refs.len()); + assert_eq!("FOO", refs[0].name); + assert_eq!(Fallback::None, refs[0].fallback); + assert_eq!(0..7, refs[0].span); + } + + #[test] + fn scan_static_reference() { + let template = "{{FOO:bar}}"; + let refs: Vec = templ::scan(template); + assert_eq!(1, refs.len()); + assert_eq!("FOO", refs[0].name); + assert_eq!(Fallback::Static(String::from("bar")), refs[0].fallback); + assert_eq!(0..11, refs[0].span); + } + + #[test] + fn scan_command_reference() { + let template = "{{FOO|echo hi}}"; + let refs: Vec = templ::scan(template); + assert_eq!(1, refs.len()); + assert_eq!("FOO", refs[0].name); + assert_eq!(Fallback::Command(String::from("echo hi")), refs[0].fallback); + assert_eq!(0..15, refs[0].span); + } + + #[test] + fn scan_static_fallback_retains_colon_and_pipe_literally() { + let template = "{{FOO:a:b|c}}"; + let refs: Vec = templ::scan(template); + assert_eq!(1, refs.len()); + assert_eq!(Fallback::Static(String::from("a:b|c")), refs[0].fallback); + } + + #[test] + fn scan_command_fallback_retains_colon_and_pipe_literally() { + let template = "{{FOO|a:b|c}}"; + let refs: Vec = templ::scan(template); + assert_eq!(1, refs.len()); + assert_eq!(Fallback::Command(String::from("a:b|c")), refs[0].fallback); + } + + #[test] + fn scan_repeated_name_with_distinct_fallbacks() { + let template = "{{FOO}} {{FOO:a}} {{FOO|b}}"; + let refs: Vec = templ::scan(template); + assert_eq!(3, refs.len()); + assert!(refs.iter().all(|r| r.name == "FOO")); + assert_eq!(Fallback::None, refs[0].fallback); + assert_eq!(Fallback::Static(String::from("a")), refs[1].fallback); + assert_eq!(Fallback::Command(String::from("b")), refs[2].fallback); + assert_eq!(0..7, refs[0].span); + assert_eq!(8..17, refs[1].span); + assert_eq!(18..27, refs[2].span); + } + + #[test] + fn scan_ignores_invalid_and_unterminated_constructs() { + let template = + "{{FOO}} {{}}- {{{}}} {{ }} {{BAR}} {{BAZ:unterminated {{QUX|also unterminated"; + let refs: Vec = templ::scan(template); + let names: HashSet<&str> = refs.iter().map(|r| r.name.as_str()).collect(); + let expected: HashSet<&str> = ["FOO", "BAR"].into_iter().collect(); + assert_eq!(expected, names); + } + + #[test] + fn scan_name_length_boundaries() { + let name_32 = "a".repeat(32); + let template_32 = format!("{{{{{}}}}}", name_32); + let refs: Vec = templ::scan(&template_32); + assert_eq!(1, refs.len()); + assert_eq!(name_32, refs[0].name); + + let name_33 = "a".repeat(33); + let template_33 = format!("{{{{{}}}}}", name_33); + let refs: Vec = templ::scan(&template_33); + assert!(refs.is_empty()); + } + + #[test] + fn find_keys_reflects_scanned_reference_names() { + let template = "{{FOO}} {{FOO:a}} {{BAR|cmd}}"; + let keys: HashSet = templ::find_keys(template); let expected: HashSet = [String::from("FOO"), String::from("BAR")].into_iter().collect(); assert_eq!(expected, keys); From a7b156c8e47bfe0311b35692abac947d91e8e69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:20:59 +0000 Subject: [PATCH 02/13] feat(template): resolve template references per occurrence with fallbacks Refactor substitution() in src/template.rs to resolve each parsed Reference from templ::scan() individually rather than by distinct key name only. For every occurrence, resolution now follows: a merged property by NAME, then that occurrence's static fallback, then its dynamic (command) result, then an interactive prompt only for a plain missing occurrence, and finally SubstitutionError::MissingValue. Extended (static/command) occurrences are rewritten to unique internal Handlebars keys before strict-mode rendering, so distinct fallback occurrences of the same NAME stay independent while a supplied value still overrides every occurrence, including an empty sourced value. Plain occurrences keep their original {{NAME}} text and shared vars entry, preserving no_escape, strict-mode failure mapping, and existing normal-template/legacy-missing-value behavior unchanged. Command execution itself is out of scope for this task (task 3); a resolve_dynamic() seam always reports unresolved for now, which correctly falls through to MissingValue without prompting. Adds tests for static fallback resolution, sourced-value override from every Property::Source (including an empty override), per-occurrence mixed/default values, suppression of interactive prompting by any fallback occurrence, and legacy missing-value behavior. --- src/templ.rs | 5 +- src/template.rs | 256 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 215 insertions(+), 46 deletions(-) diff --git a/src/templ.rs b/src/templ.rs index 4c6d4f2..0a23356 100644 --- a/src/templ.rs +++ b/src/templ.rs @@ -48,7 +48,10 @@ pub fn scan(template: &str) -> Vec { /// Discover the distinct reference names present in `template`, regardless /// of fallback form. This is the legacy key-discovery behavior, now backed -/// by [`scan`]. +/// by [`scan`]. `src/template.rs` resolves references directly via [`scan`] +/// for occurrence-level fallback handling, so this entry point is kept for +/// its own test coverage and as a stable legacy-discovery API. +#[allow(dead_code)] pub fn find_keys(template: &str) -> HashSet { scan(template).into_iter().map(|reference: Reference| reference.name).collect() } diff --git a/src/template.rs b/src/template.rs index 8565b18..759a1e2 100644 --- a/src/template.rs +++ b/src/template.rs @@ -2,7 +2,8 @@ use handlebars::{no_escape, Handlebars}; use std::collections::HashMap; use std::collections::HashSet; -use crate::{prop::Property, templ}; +use crate::prop::Property; +use crate::templ::{self, Fallback, Reference}; pub fn substitution( input: String, @@ -11,56 +12,127 @@ pub fn substitution( use_colors: bool, trim: bool, ) -> Result { - let keys: HashSet = templ::find_keys(&input); - let vars: HashMap = - resolve_values(interactive, use_colors, trim, keys, merge(vars))?; + let refs: Vec = templ::scan(&input); + let props: HashMap = merge(vars); + + let mut render_vars: HashMap = HashMap::new(); + let mut rewrites: Vec> = Vec::with_capacity(refs.len()); + let mut prompt_names: HashSet = HashSet::new(); + let mut missing: Option = None; + + for (index, reference) in refs.iter().enumerate() { + let resolved: Option = + props.get(&reference.name).cloned().or_else(|| match &reference.fallback { + Fallback::None => None, + Fallback::Static(value) => Some(value.clone()), + Fallback::Command(command) => resolve_dynamic(command), + }); + + match (&reference.fallback, resolved) { + (Fallback::None, Some(value)) => { + render_vars.insert(reference.name.clone(), value); + rewrites.push(None); + } + (Fallback::None, None) => { + prompt_names.insert(reference.name.clone()); + rewrites.push(None); + } + (_, Some(value)) => { + let key: String = internal_key(index); + render_vars.insert(key.clone(), value); + rewrites.push(Some(key)); + } + (_, None) => { + missing.get_or_insert_with(|| reference.name.clone()); + rewrites.push(None); + } + } + } + + if let Some(name) = missing { + return Err(SubstitutionError::MissingValue(name)); + } + + if !prompt_names.is_empty() { + if interactive { + let prompted: HashMap = prompt_for(prompt_names, use_colors, trim); + render_vars.extend(prompted); + } else { + let name: String = prompt_names.into_iter().next().unwrap(); + return Err(SubstitutionError::MissingValue(name)); + } + } + + let template: String = rewrite_template(&input, &refs, &rewrites); + let mut reg = Handlebars::new(); reg.register_escape_fn(no_escape); reg.set_strict_mode(true); - reg.register_template_string("template", input).unwrap(); - reg.render("template", &vars).map_err(|_| SubstitutionError::Rendering) + reg.register_template_string("template", template).unwrap(); + reg.render("template", &render_vars).map_err(|_| SubstitutionError::Rendering) } -fn resolve_values( - interactive: bool, - use_colors: bool, - trim: bool, - keys: HashSet, - vars: HashMap, -) -> Result, SubstitutionError> { - let diff: HashSet = - keys.difference(&vars.clone().into_keys().collect()).cloned().collect(); - - if diff.is_empty() { - Ok(vars) - } else if interactive { - let mut added: HashMap = HashMap::with_capacity(diff.len()); - let theme = dialoguer::theme::ColorfulTheme::default(); - for key in diff { - let value: String = if use_colors { - dialoguer::Input::with_theme(&theme) - .with_prompt(key.clone()) - .allow_empty(false) - .interact_text() - .unwrap() - } else { - dialoguer::Input::new() - .with_prompt(key.clone()) - .allow_empty(false) - .interact_text() - .unwrap() - }; - - let value: String = if trim { value.trim().into() } else { value }; - - added.insert(key, value); +/// Dynamic (command) fallback resolution. Authorization and the actual +/// command runner are implemented as a later, separate task; until then, +/// every command fallback occurrence is treated as unresolved so it falls +/// through to `SubstitutionError::MissingValue` rather than prompting. +fn resolve_dynamic(_command: &str) -> Option { + None +} + +fn internal_key(index: usize) -> String { + format!("__fire_ref_{index}") +} + +/// Rebuild `input` with every extended-fallback occurrence's span replaced by +/// its unique internal Handlebars key, leaving plain occurrences untouched so +/// existing normal-template rendering behavior is preserved. +fn rewrite_template(input: &str, refs: &[Reference], rewrites: &[Option]) -> String { + let mut output: String = String::with_capacity(input.len()); + let mut cursor: usize = 0; + + for (reference, rewrite) in refs.iter().zip(rewrites.iter()) { + output.push_str(&input[cursor..reference.span.start]); + match rewrite { + Some(key) => { + output.push_str("{{"); + output.push_str(key); + output.push_str("}}"); + } + None => output.push_str(&input[reference.span.start..reference.span.end]), } - let all = vars.into_iter().chain(added).collect(); - Ok(all) - } else { - let missing: String = diff.into_iter().next().unwrap(); - Err(SubstitutionError::MissingValue(missing)) + cursor = reference.span.end; } + + output.push_str(&input[cursor..]); + output +} + +fn prompt_for(names: HashSet, use_colors: bool, trim: bool) -> HashMap { + let mut added: HashMap = HashMap::with_capacity(names.len()); + let theme = dialoguer::theme::ColorfulTheme::default(); + + for name in names { + let value: String = if use_colors { + dialoguer::Input::with_theme(&theme) + .with_prompt(name.clone()) + .allow_empty(false) + .interact_text() + .unwrap() + } else { + dialoguer::Input::new() + .with_prompt(name.clone()) + .allow_empty(false) + .interact_text() + .unwrap() + }; + + let value: String = if trim { value.trim().into() } else { value }; + + added.insert(name, value); + } + + added } #[derive(Debug)] @@ -89,7 +161,7 @@ mod tests { use crate::prop::{ParsePropertyError, Property, Source}; - use super::merge; + use super::{merge, substitution, SubstitutionError}; #[test] fn test_merge_properties() -> Result<(), ParsePropertyError> { @@ -105,4 +177,98 @@ mod tests { Ok(()) } + + #[test] + fn static_fallback_used_when_property_absent() { + let input = String::from("{{FOO:bar}}"); + let result = substitution(input, vec![], false, false, false).unwrap(); + assert_eq!("bar", result); + } + + #[test] + fn sourced_value_overrides_static_fallback_for_every_source() -> Result<(), ParsePropertyError> + { + let sources = [ + Source::Arg, + Source::EnvVar, + Source::File(0), + Source::File(1), + ]; + + for source in sources { + let prop = Property::new(String::from("FOO"), String::from("value"), source)?; + let input = String::from("{{FOO:bar}}"); + let result = substitution(input, vec![prop], false, false, false).unwrap(); + assert_eq!("value", result, "source {:?} did not override fallback", source); + } + + Ok(()) + } + + #[test] + fn sourced_value_overrides_command_fallback_for_every_source() -> Result<(), ParsePropertyError> + { + let sources = [ + Source::Arg, + Source::EnvVar, + Source::File(0), + Source::File(1), + ]; + + for source in sources { + let prop = Property::new(String::from("FOO"), String::from("value"), source)?; + let input = String::from("{{FOO|echo bar}}"); + let result = substitution(input, vec![prop], false, false, false).unwrap(); + assert_eq!("value", result, "source {:?} did not override fallback", source); + } + + Ok(()) + } + + #[test] + fn empty_sourced_value_overrides_fallback() -> Result<(), ParsePropertyError> { + let prop = Property::new(String::from("FOO"), String::new(), Source::Arg)?; + let input = String::from("{{FOO:bar}}"); + let result = substitution(input, vec![prop], false, false, false).unwrap(); + assert_eq!("", result); + + Ok(()) + } + + #[test] + fn per_occurrence_fallbacks_resolve_independently_when_missing() { + let input = String::from("{{FOO:a}} {{FOO:b}}"); + let result = substitution(input, vec![], false, false, false).unwrap(); + assert_eq!("a b", result); + } + + #[test] + fn per_occurrence_supplied_value_replaces_every_occurrence() -> Result<(), ParsePropertyError> { + let prop = Property::new(String::from("FOO"), String::from("value"), Source::Arg)?; + let input = String::from("{{FOO}} {{FOO:a}} {{FOO:b}}"); + let result = substitution(input, vec![prop], false, false, false).unwrap(); + assert_eq!("value value value", result); + + Ok(()) + } + + #[test] + fn fallback_occurrence_never_triggers_interactive_prompt() { + // interactive is true, but the only occurrence has a static fallback, so no + // prompt must be attempted (which would otherwise hang/panic in a test + // without an interactive terminal attached). + let input = String::from("{{FOO:bar}}"); + let result = substitution(input, vec![], true, false, false).unwrap(); + assert_eq!("bar", result); + } + + #[test] + fn legacy_missing_value_behavior_is_preserved() { + let input = String::from("{{FOO}}"); + let err = substitution(input, vec![], false, false, false).unwrap_err(); + match err { + SubstitutionError::MissingValue(name) => assert_eq!("FOO", name), + other => panic!("expected MissingValue, got {:?}", other), + } + } } From 8ff5acb66752a9cf4709134828fe5ffe9bdc562a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:27:31 +0000 Subject: [PATCH 03/13] feat(template): gate command fallbacks behind --allow-command-fallbacks Add Args::allow_command_fallbacks(), backed by the opt-in --allow-command-fallbacks flag, and thread it from main.rs into substitution(). Command-fallback resolution now returns a distinct SubstitutionError::CommandFallbackNotAllowed when reached without permission, instead of silently treating it as a missing value. Isolate shell invocation behind a small src/runner.rs adapter (sh -c on unix, cmd /C on windows) that normalizes stdout by removing only trailing CR/LF and surfaces launch failures, non-zero exit statuses, and non-UTF-8 stdout as distinct RunnerError variants. FireError mapping for the new SubstitutionError variants is intentionally minimal plumbing; a dedicated variant and exit code are added by a later task. --- src/args.rs | 31 +++++++++++++++ src/main.rs | 19 ++++++++- src/runner.rs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++ src/template.rs | 85 +++++++++++++++++++++++++++++++--------- 4 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 src/runner.rs diff --git a/src/args.rs b/src/args.rs index e1d452a..c88f089 100644 --- a/src/args.rs +++ b/src/args.rs @@ -82,6 +82,14 @@ pub struct Args { #[clap(short, long)] pub trim: bool, + /// Allow dynamic command fallbacks + /// + /// Allow a `{{NAME|command}}` template reference to execute `command` via the system shell + /// when no value for `NAME` is otherwise supplied. This runs arbitrary shell commands found + /// in the request file, so only enable it for request files you trust. + #[clap(long = "allow-command-fallbacks")] + allow_command_fallbacks: bool, + /// Environments /// /// One or several environments which containins environment variables. If the environment is @@ -162,6 +170,10 @@ impl Args { self.interactive } + pub fn allow_command_fallbacks(&self) -> bool { + self.allow_command_fallbacks + } + pub fn env(&self) -> Result, ParsePropertyError> { let sys_envs: Vec = Self::read_sys_envs()?; let file_envs: Vec = self.read_file_envs()?; @@ -240,3 +252,22 @@ impl Args { .collect() } } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::Args; + + #[test] + fn allow_command_fallbacks_defaults_to_false() { + let args: Args = Args::parse_from(["fire", "request.yaml"]); + assert!(!args.allow_command_fallbacks()); + } + + #[test] + fn allow_command_fallbacks_flag_enables_it() { + let args: Args = Args::parse_from(["fire", "--allow-command-fallbacks", "request.yaml"]); + assert!(args.allow_command_fallbacks()); + } +} diff --git a/src/main.rs b/src/main.rs index 2899e42..f9787d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod format; mod io; mod logger; mod prop; +mod runner; mod templ; mod template; @@ -72,8 +73,14 @@ fn exec() -> Result<(), FireError> { log::debug!("Received properties {:?}", props); // Apply template substitution - let content: String = - substitution(file, props, args.interactive(), args.try_colors(), args.trim)?; + let content: String = substitution( + file, + props, + args.interactive(), + args.try_colors(), + args.trim, + args.allow_command_fallbacks(), + )?; // Parse Validate format of request let mut request: HttpRequest = HttpRequest::from_str(&content).unwrap(); @@ -210,6 +217,14 @@ impl From for FireError { match e { SubstitutionError::MissingValue(err) => FireError::TemplateKey(err), SubstitutionError::Rendering => FireError::TemplateRendering, + // Minimal plumbing only: a dedicated `FireError` variant and exit code for + // denied/failed dynamic fallback execution is added by a later task. + SubstitutionError::CommandFallbackNotAllowed => FireError::Other(String::from( + "Dynamic command fallback requires --allow-command-fallbacks", + )), + SubstitutionError::CommandFallbackFailed(err) => { + FireError::Other(format!("Dynamic command fallback command failed: {err:?}")) + } } } } diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..0c69f21 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,102 @@ +use std::process::{Command, Output}; + +/// Errors that can occur while invoking a dynamic (command) fallback's shell +/// command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunnerError { + /// The shell process itself could not be launched. + Launch(String), + /// The shell process launched but exited with a non-zero status. + ExitStatus(i32), + /// The command produced stdout bytes that are not valid UTF-8. + NonUtf8, +} + +/// Run `command` via the platform shell and return its normalized stdout. +/// +/// Only trailing CR/LF line endings are removed; all other whitespace is +/// preserved verbatim. A non-zero exit status, a failure to launch the +/// shell, or non-UTF-8 stdout each yield a distinct [`RunnerError`]. +#[cfg(unix)] +pub fn run_command(command: &str) -> Result { + run_with_shell("sh", "-c", command) +} + +#[cfg(windows)] +pub fn run_command(command: &str) -> Result { + run_with_shell("cmd", "/C", command) +} + +fn run_with_shell(shell: &str, shell_arg: &str, command: &str) -> Result { + let output: Output = Command::new(shell) + .arg(shell_arg) + .arg(command) + .output() + .map_err(|e| RunnerError::Launch(e.to_string()))?; + + normalize(output) +} + +fn normalize(output: Output) -> Result { + if !output.status.success() { + let code: i32 = output.status.code().unwrap_or(-1); + return Err(RunnerError::ExitStatus(code)); + } + + let stdout: String = String::from_utf8(output.stdout).map_err(|_| RunnerError::NonUtf8)?; + Ok(trim_trailing_line_ending(&stdout)) +} + +fn trim_trailing_line_ending(value: &str) -> String { + value.trim_end_matches(['\r', '\n']).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_failure_is_surfaced() { + let err = + run_with_shell("definitely-not-a-real-shell-binary-xyz", "-c", "echo hi").unwrap_err(); + match err { + RunnerError::Launch(_) => {} + other => panic!("expected Launch, got {:?}", other), + } + } + + #[cfg(unix)] + #[test] + fn trailing_crlf_is_removed_but_inner_whitespace_survives() { + let result = run_command("printf 'foo bar \\r\\n'").unwrap(); + assert_eq!("foo bar ", result); + } + + #[cfg(unix)] + #[test] + fn nonzero_exit_status_is_surfaced() { + let err = run_command("exit 3").unwrap_err(); + assert_eq!(RunnerError::ExitStatus(3), err); + } + + #[cfg(unix)] + #[test] + fn non_utf8_stdout_is_surfaced() { + let err = run_command("printf '\\xff'").unwrap_err(); + assert_eq!(RunnerError::NonUtf8, err); + } + + #[cfg(windows)] + #[test] + fn trailing_crlf_is_removed_but_inner_whitespace_survives_windows() { + let result = run_command("echo foo bar ").unwrap(); + assert_eq!("foo bar ", result); + } + + #[cfg(windows)] + #[test] + fn nonzero_exit_status_is_surfaced_windows() { + let err = run_command("exit 3").unwrap_err(); + assert_eq!(RunnerError::ExitStatus(3), err); + } +} diff --git a/src/template.rs b/src/template.rs index 759a1e2..7b916b1 100644 --- a/src/template.rs +++ b/src/template.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use std::collections::HashSet; use crate::prop::Property; +use crate::runner::{self, RunnerError}; use crate::templ::{self, Fallback, Reference}; pub fn substitution( @@ -11,6 +12,7 @@ pub fn substitution( interactive: bool, use_colors: bool, trim: bool, + allow_command_fallbacks: bool, ) -> Result { let refs: Vec = templ::scan(&input); let props: HashMap = merge(vars); @@ -21,12 +23,16 @@ pub fn substitution( let mut missing: Option = None; for (index, reference) in refs.iter().enumerate() { - let resolved: Option = - props.get(&reference.name).cloned().or_else(|| match &reference.fallback { + let resolved: Option = match props.get(&reference.name).cloned() { + Some(value) => Some(value), + None => match &reference.fallback { Fallback::None => None, Fallback::Static(value) => Some(value.clone()), - Fallback::Command(command) => resolve_dynamic(command), - }); + Fallback::Command(command) => { + Some(resolve_command_fallback(command, allow_command_fallbacks)?) + } + }, + }; match (&reference.fallback, resolved) { (Fallback::None, Some(value)) => { @@ -72,12 +78,17 @@ pub fn substitution( reg.render("template", &render_vars).map_err(|_| SubstitutionError::Rendering) } -/// Dynamic (command) fallback resolution. Authorization and the actual -/// command runner are implemented as a later, separate task; until then, -/// every command fallback occurrence is treated as unresolved so it falls -/// through to `SubstitutionError::MissingValue` rather than prompting. -fn resolve_dynamic(_command: &str) -> Option { - None +/// Resolve a dynamic (command) fallback, gated by `allow`. +/// +/// The command is never invoked unless `allow` is `true`; reaching this +/// point without permission yields a distinct, deterministic error instead +/// of attempting execution. +fn resolve_command_fallback(command: &str, allow: bool) -> Result { + if !allow { + return Err(SubstitutionError::CommandFallbackNotAllowed); + } + + runner::run_command(command).map_err(SubstitutionError::CommandFallbackFailed) } fn internal_key(index: usize) -> String { @@ -139,6 +150,8 @@ fn prompt_for(names: HashSet, use_colors: bool, trim: bool) -> HashMap) -> HashMap { @@ -181,7 +194,7 @@ mod tests { #[test] fn static_fallback_used_when_property_absent() { let input = String::from("{{FOO:bar}}"); - let result = substitution(input, vec![], false, false, false).unwrap(); + let result = substitution(input, vec![], false, false, false, false).unwrap(); assert_eq!("bar", result); } @@ -198,7 +211,7 @@ mod tests { for source in sources { let prop = Property::new(String::from("FOO"), String::from("value"), source)?; let input = String::from("{{FOO:bar}}"); - let result = substitution(input, vec![prop], false, false, false).unwrap(); + let result = substitution(input, vec![prop], false, false, false, false).unwrap(); assert_eq!("value", result, "source {:?} did not override fallback", source); } @@ -218,7 +231,7 @@ mod tests { for source in sources { let prop = Property::new(String::from("FOO"), String::from("value"), source)?; let input = String::from("{{FOO|echo bar}}"); - let result = substitution(input, vec![prop], false, false, false).unwrap(); + let result = substitution(input, vec![prop], false, false, false, false).unwrap(); assert_eq!("value", result, "source {:?} did not override fallback", source); } @@ -229,7 +242,7 @@ mod tests { fn empty_sourced_value_overrides_fallback() -> Result<(), ParsePropertyError> { let prop = Property::new(String::from("FOO"), String::new(), Source::Arg)?; let input = String::from("{{FOO:bar}}"); - let result = substitution(input, vec![prop], false, false, false).unwrap(); + let result = substitution(input, vec![prop], false, false, false, false).unwrap(); assert_eq!("", result); Ok(()) @@ -238,7 +251,7 @@ mod tests { #[test] fn per_occurrence_fallbacks_resolve_independently_when_missing() { let input = String::from("{{FOO:a}} {{FOO:b}}"); - let result = substitution(input, vec![], false, false, false).unwrap(); + let result = substitution(input, vec![], false, false, false, false).unwrap(); assert_eq!("a b", result); } @@ -246,7 +259,7 @@ mod tests { fn per_occurrence_supplied_value_replaces_every_occurrence() -> Result<(), ParsePropertyError> { let prop = Property::new(String::from("FOO"), String::from("value"), Source::Arg)?; let input = String::from("{{FOO}} {{FOO:a}} {{FOO:b}}"); - let result = substitution(input, vec![prop], false, false, false).unwrap(); + let result = substitution(input, vec![prop], false, false, false, false).unwrap(); assert_eq!("value value value", result); Ok(()) @@ -258,17 +271,53 @@ mod tests { // prompt must be attempted (which would otherwise hang/panic in a test // without an interactive terminal attached). let input = String::from("{{FOO:bar}}"); - let result = substitution(input, vec![], true, false, false).unwrap(); + let result = substitution(input, vec![], true, false, false, false).unwrap(); assert_eq!("bar", result); } #[test] fn legacy_missing_value_behavior_is_preserved() { let input = String::from("{{FOO}}"); - let err = substitution(input, vec![], false, false, false).unwrap_err(); + let err = substitution(input, vec![], false, false, false, false).unwrap_err(); match err { SubstitutionError::MissingValue(name) => assert_eq!("FOO", name), other => panic!("expected MissingValue, got {:?}", other), } } + + #[test] + fn sourced_value_bypasses_authorization_and_runner_for_command_fallback( + ) -> Result<(), ParsePropertyError> { + // If this fallback command were actually invoked, it would fail (the binary does not + // exist), regardless of the `--allow-command-fallbacks` permission. A sourced value must + // short-circuit both the authorization check and the runner entirely. + let prop = Property::new(String::from("FOO"), String::from("value"), Source::Arg)?; + let input = String::from("{{FOO|definitely-not-a-real-command-xyz}}"); + + let result = + substitution(input.clone(), vec![prop.clone()], false, false, false, false).unwrap(); + assert_eq!("value", result); + + let result = substitution(input, vec![prop], false, false, false, true).unwrap(); + assert_eq!("value", result); + + Ok(()) + } + + #[test] + fn command_fallback_denied_without_permission_yields_distinct_error() { + let input = String::from("{{FOO|echo bar}}"); + let err = substitution(input, vec![], false, false, false, false).unwrap_err(); + match err { + SubstitutionError::CommandFallbackNotAllowed => {} + other => panic!("expected CommandFallbackNotAllowed, got {:?}", other), + } + } + + #[test] + fn command_fallback_executes_when_allowed() { + let input = String::from("{{FOO|echo bar}}"); + let result = substitution(input, vec![], false, false, false, true).unwrap(); + assert_eq!("bar", result); + } } From f1b9ce6d4c755590f0c1e3524512dd33b4ce78c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:31:43 +0000 Subject: [PATCH 04/13] feat(error): expose command-fallback failures via dedicated FireError variants Add FireError::CommandFallbackNotAllowed and FireError::CommandFallbackFailed(RunnerError), each with a distinct, secret-free Display message and a stable exit code (12 and 13, respectively, the next codes free after the existing 1 and 3-11). main.rs now maps SubstitutionError's two command-fallback variants directly to these, instead of collapsing them into the generic FireError::Other/TemplateRendering. Termination::report is refactored around a private exit_code() method so tests can assert on the underlying u8 without relying on ExitCode's opaque representation. Tests cover both new Display messages (including all three RunnerError sub-cases), document why 12/13 were chosen, and assert every FireError variant's exit code stays pairwise unique. RunnerError never carries resolved fallback values or command stdout, so these messages cannot leak secrets. --- src/error.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 10 +--- 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/src/error.rs b/src/error.rs index 04d716a..c3060fb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,6 +8,7 @@ use url::Url; use crate::prop; use crate::prop::ParsePropertyError; +use crate::runner::RunnerError; pub trait Error: StdError + Termination {} @@ -21,6 +22,16 @@ pub enum FireError { TemplateRendering, TemplateKey(String), Environment(ParsePropertyError), + /// A dynamic (command) fallback was reached without the opt-in + /// `--allow-command-fallbacks` flag being present. + CommandFallbackNotAllowed, + /// A dynamic (command) fallback's shell command could not be launched, + /// exited with a non-zero status, or produced non-UTF-8 output. + /// + /// The variant only carries [`RunnerError`], which never contains + /// resolved fallback values or command stdout, so no secret material is + /// exposed by this error. + CommandFallbackFailed(RunnerError), Other(String), } @@ -47,6 +58,20 @@ impl Display for FireError { prop::ParsePropertyError::Value(value) => format!("Invalid value in environments file: {value}"), prop::ParsePropertyError::File(file) => format!("Invalid environments file: {file}"), }, + FireError::CommandFallbackNotAllowed => String::from( + "Dynamic command fallback requires --allow-command-fallbacks", + ), + FireError::CommandFallbackFailed(err) => match err { + RunnerError::Launch(msg) => { + format!("Unable to launch dynamic command fallback: {msg}") + } + RunnerError::ExitStatus(code) => { + format!("Dynamic command fallback exited with status {code}") + } + RunnerError::NonUtf8 => { + String::from("Dynamic command fallback produced non-UTF-8 output") + } + }, FireError::Other(err) => format!("Error: {err}"), }; @@ -54,24 +79,108 @@ impl Display for FireError { } } -impl Termination for FireError { - fn report(self) -> process::ExitCode { +impl FireError { + /// The stable exit code reported for this error. + /// + /// Kept as its own method (rather than inline in [`Termination::report`]) + /// so tests can assert on the concrete `u8` value without relying on + /// `ExitCode`'s opaque, non-comparable representation. + fn exit_code(&self) -> u8 { match self { - FireError::Timeout(_) => ExitCode::from(3), - FireError::Connection(_) => ExitCode::from(4), - FireError::FileNotFound(_) => ExitCode::from(5), - FireError::NoReadPermission(_) => ExitCode::from(6), - FireError::NotAFile(_) => ExitCode::from(7), - FireError::GenericIO(_) => ExitCode::from(8), - FireError::TemplateKey(_) => ExitCode::from(9), - FireError::TemplateRendering => ExitCode::from(10), - FireError::Environment(_) => ExitCode::from(11), - FireError::Other(_) => ExitCode::from(1), + FireError::Timeout(_) => 3, + FireError::Connection(_) => 4, + FireError::FileNotFound(_) => 5, + FireError::NoReadPermission(_) => 6, + FireError::NotAFile(_) => 7, + FireError::GenericIO(_) => 8, + FireError::TemplateKey(_) => 9, + FireError::TemplateRendering => 10, + FireError::Environment(_) => 11, + FireError::CommandFallbackNotAllowed => 12, + FireError::CommandFallbackFailed(_) => 13, + FireError::Other(_) => 1, } } } +impl Termination for FireError { + fn report(self) -> process::ExitCode { + ExitCode::from(self.exit_code()) + } +} + pub fn exit(err: FireError) -> ExitCode { eprintln!("{err}"); err.report() } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn sample(variant: &FireError) -> u8 { + variant.exit_code() + } + + #[test] + fn command_fallback_not_allowed_has_a_dedicated_message() { + let err = FireError::CommandFallbackNotAllowed; + assert_eq!("Dynamic command fallback requires --allow-command-fallbacks", err.to_string()); + } + + #[test] + fn command_fallback_launch_failure_message_excludes_stdout() { + let err = FireError::CommandFallbackFailed(RunnerError::Launch(String::from( + "No such file or directory", + ))); + assert_eq!( + "Unable to launch dynamic command fallback: No such file or directory", + err.to_string() + ); + } + + #[test] + fn command_fallback_exit_status_failure_message() { + let err = FireError::CommandFallbackFailed(RunnerError::ExitStatus(3)); + assert_eq!("Dynamic command fallback exited with status 3", err.to_string()); + } + + #[test] + fn command_fallback_non_utf8_failure_message() { + let err = FireError::CommandFallbackFailed(RunnerError::NonUtf8); + assert_eq!("Dynamic command fallback produced non-UTF-8 output", err.to_string()); + } + + /// Documents the stable exit codes chosen for the two new variants: 1 + /// and 3-11 are already used by other `FireError` variants, so the + /// dynamic-command-fallback variants claim the next free codes, 12 and + /// 13, and must never change once released. + #[test] + fn command_fallback_exit_codes_are_stable_and_documented() { + assert_eq!(12, sample(&FireError::CommandFallbackNotAllowed)); + assert_eq!(13, sample(&FireError::CommandFallbackFailed(RunnerError::NonUtf8))); + } + + #[test] + fn all_variant_exit_codes_are_unique() { + let variants: Vec = vec![ + FireError::Timeout(Url::parse("http://example.com").unwrap()), + FireError::Connection(Url::parse("http://example.com").unwrap()), + FireError::FileNotFound(PathBuf::from("x")), + FireError::NoReadPermission(PathBuf::from("x")), + FireError::NotAFile(PathBuf::from("x")), + FireError::GenericIO(String::from("io")), + FireError::TemplateRendering, + FireError::TemplateKey(String::from("KEY")), + FireError::Environment(ParsePropertyError::Entry(String::from("entry"))), + FireError::CommandFallbackNotAllowed, + FireError::CommandFallbackFailed(RunnerError::NonUtf8), + FireError::Other(String::from("other")), + ]; + + let codes: Vec = variants.iter().map(FireError::exit_code).collect(); + let unique: HashSet = codes.iter().copied().collect(); + assert_eq!(codes.len(), unique.len(), "exit codes must be pairwise distinct: {codes:?}"); + } +} diff --git a/src/main.rs b/src/main.rs index f9787d5..fd7ae07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -217,14 +217,8 @@ impl From for FireError { match e { SubstitutionError::MissingValue(err) => FireError::TemplateKey(err), SubstitutionError::Rendering => FireError::TemplateRendering, - // Minimal plumbing only: a dedicated `FireError` variant and exit code for - // denied/failed dynamic fallback execution is added by a later task. - SubstitutionError::CommandFallbackNotAllowed => FireError::Other(String::from( - "Dynamic command fallback requires --allow-command-fallbacks", - )), - SubstitutionError::CommandFallbackFailed(err) => { - FireError::Other(format!("Dynamic command fallback command failed: {err:?}")) - } + SubstitutionError::CommandFallbackNotAllowed => FireError::CommandFallbackNotAllowed, + SubstitutionError::CommandFallbackFailed(err) => FireError::CommandFallbackFailed(err), } } } From 373914b3fb22bfd193b2fc96d320bbf721285cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:35:23 +0000 Subject: [PATCH 05/13] docs(template): document default value syntax and add examples Document static and command defaults, source precedence, occurrence semantics, interactive ordering, command output behavior, platform shells, and the opt-in security warning. Add documentation-only example request files for both forms. --- README.md | 39 ++++++++++++++++++++++- examples/request_with_command_default.yml | 10 ++++++ examples/request_with_static_default.yml | 8 +++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 examples/request_with_command_default.yml create mode 100644 examples/request_with_static_default.yml diff --git a/README.md b/README.md index 43e0ce2..b03b5e3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ headers: authorization: Bearer {{TOKEN}} ``` -See [examples](examples/) directory for more examples of how to structure request files. +See [examples](examples/) directory for more examples of how to structure request files, including static and command default values (see [Default Values](#default-values) below). ## Templating and Variable Substitution Request files supports templating where variables can be substituted at execution time. This makes it very easy to have request @@ -73,6 +73,43 @@ REGION=europe USERNAME="quoted-username" ``` +### Default Values +A template key may declare a default value that is used only when no value can be resolved for it from any of the sources above. Two syntaxes are supported: + +- Static default: `{{NAME:value}}` - if `NAME` is not supplied, the literal `value` is used. +- Command (dynamic) default: `{{NAME|command}}` - if `NAME` is not supplied, `command` is executed through the system shell and its normalized output is used. + +```yaml +method: POST +url: https://example.com +headers: + content-type: {{CONTENT_TYPE:application/json}} + x-correlation-id: {{CORRELATION_ID|uuidgen}} +``` + +**Source precedence is unchanged by defaults.** A default is only a fallback of last resort before interactive prompting: any value found via a CLI `-E` argument, an environment/secret file, or an inherited system environment variable is always used instead of a default, regardless of which of those sources it came from. A sourced value that is explicitly empty (`""`) still counts as resolved and suppresses the default entirely, including a command default, so the command is never invoked in that case. + +**Defaults are resolved per occurrence, not per name.** Each `{{NAME...}}` occurrence in a request file is resolved independently, so the same `NAME` may appear multiple times with different (or no) fallback in each place: + +```yaml +# All three occurrences of ID render as "value" if ID is supplied. +# Otherwise the first has no default, so it raises the legacy missing-value +# error (or prompts, in interactive mode), the second renders "a", and the +# third renders "b". +headers: + x-a: {{ID}} + x-b: {{ID:a}} + x-c: {{ID:b}} +``` + +**Ordering with interactive mode.** Defaults are always resolved before interactive prompting (`-i`/`--interactive`). Any occurrence with a static or command default is therefore never prompted for, even in interactive mode; only a plain `{{NAME}}` occurrence with no default and no resolved value can trigger a prompt, and only as the last resort. + +**Command execution details.** A command default requires the explicit opt-in flag `--allow-command-fallbacks`; without it, reaching a command default is a hard error and the command is never invoked. The command itself is run as a single argument to the platform shell: `sh -c "command"` on Unix and `cmd /C "command"` on Windows. Its standard output is captured as UTF-8; only a trailing `\r` and/or `\n` sequence is stripped, all other whitespace in the output is preserved as-is. A shell that cannot be launched, exits with a non-zero status, or produces output that is not valid UTF-8 each produce a distinct, non-secret error instead of silently falling back to any other value. + +> **Security warning:** `--allow-command-fallbacks` causes `fire` to execute arbitrary shell commands found in the request file you are running, without confirmation. Only enable this flag for request files you trust, since a command default behaves the same as running that command yourself in a shell. + +See [`examples/request_with_static_default.yml`](examples/request_with_static_default.yml) and [`examples/request_with_command_default.yml`](examples/request_with_command_default.yml) for minimal, non-executing examples of each syntax. + ## Additional Documentation See `fire --help` for more documentation on how to use the application. diff --git a/examples/request_with_command_default.yml b/examples/request_with_command_default.yml new file mode 100644 index 0000000..426591d --- /dev/null +++ b/examples/request_with_command_default.yml @@ -0,0 +1,10 @@ +method: POST +url: https://example.com +headers: + # Command (dynamic) default: only invoked if CORRELATION_ID is not + # supplied via -E, an environment/secret file, or an inherited system + # environment variable. Requires the --allow-command-fallbacks flag; + # without it, running this request fails with a dedicated error instead + # of executing `uuidgen`. This file is documentation only and is never + # executed by the test suite. + x-correlation-id: {{CORRELATION_ID|uuidgen}} diff --git a/examples/request_with_static_default.yml b/examples/request_with_static_default.yml new file mode 100644 index 0000000..737cf66 --- /dev/null +++ b/examples/request_with_static_default.yml @@ -0,0 +1,8 @@ +method: POST +url: https://example.com +headers: + # Static default: used only when CONTENT_TYPE is not supplied via -E, an + # environment/secret file, or an inherited system environment variable. + # No command is ever invoked for this syntax, so it runs safely without + # --allow-command-fallbacks and never prompts in interactive mode. + content-type: {{CONTENT_TYPE:application/json}} From 18f7701c6b2aaf014974550f3ee468440b0ac2a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 11:38:31 +0000 Subject: [PATCH 06/13] fix(runner): use portable octal escape in non_utf8_stdout_is_surfaced test The test used printf's \xff hex escape to produce an invalid UTF-8 byte. Ubuntu's default /bin/sh (dash) does not support \xff and instead prints the literal bytes \xff, which is valid UTF-8, causing the test to fail on CI while passing locally under bash. Replace it with \377, a POSIX octal escape for 0xFF supported by both dash and bash, preserving the test's intent of exercising RunnerError::NonUtf8. --- src/runner.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 0c69f21..ab7ab28 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -82,7 +82,12 @@ mod tests { #[cfg(unix)] #[test] fn non_utf8_stdout_is_surfaced() { - let err = run_command("printf '\\xff'").unwrap_err(); + // `\377` is a POSIX octal escape (0xFF) supported by both dash and + // bash. The non-portable `\xff` hex escape is silently ignored by + // dash (Ubuntu's default `/bin/sh`), which prints the literal bytes + // `\xff` instead of a single invalid byte, so it must not be used + // here. + let err = run_command("printf '\\377'").unwrap_err(); assert_eq!(RunnerError::NonUtf8, err); } From 1478d4f8035ddef2708623c900d45ddfcb255cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Thu, 10 Sep 2026 23:47:22 +0200 Subject: [PATCH 07/13] feat: Use -F for dynamic fallback --- src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/args.rs b/src/args.rs index c88f089..202ad5a 100644 --- a/src/args.rs +++ b/src/args.rs @@ -87,7 +87,7 @@ pub struct Args { /// Allow a `{{NAME|command}}` template reference to execute `command` via the system shell /// when no value for `NAME` is otherwise supplied. This runs arbitrary shell commands found /// in the request file, so only enable it for request files you trust. - #[clap(long = "allow-command-fallbacks")] + #[clap(short = 'F', long = "allow-command-fallbacks")] allow_command_fallbacks: bool, /// Environments From 34934d839a9296568155b770c479479f27da1cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Sat, 12 Sep 2026 22:08:06 +0000 Subject: [PATCH 08/13] fix(runner): bound fallback execution --- README.md | 2 +- src/error.rs | 16 ++++++++ src/runner.rs | 106 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b03b5e3..668dd52 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ headers: **Ordering with interactive mode.** Defaults are always resolved before interactive prompting (`-i`/`--interactive`). Any occurrence with a static or command default is therefore never prompted for, even in interactive mode; only a plain `{{NAME}}` occurrence with no default and no resolved value can trigger a prompt, and only as the last resort. -**Command execution details.** A command default requires the explicit opt-in flag `--allow-command-fallbacks`; without it, reaching a command default is a hard error and the command is never invoked. The command itself is run as a single argument to the platform shell: `sh -c "command"` on Unix and `cmd /C "command"` on Windows. Its standard output is captured as UTF-8; only a trailing `\r` and/or `\n` sequence is stripped, all other whitespace in the output is preserved as-is. A shell that cannot be launched, exits with a non-zero status, or produces output that is not valid UTF-8 each produce a distinct, non-secret error instead of silently falling back to any other value. +**Command execution details.** A command default requires the explicit opt-in flag `--allow-command-fallbacks`; without it, reaching a command default is a hard error and the command is never invoked. The command itself is run as a single argument to the platform shell: `sh -c "command"` on Unix and `cmd /C "command"` on Windows. It has a 5-second execution limit and its standard output is capped at 64 KiB; standard error is discarded. Standard output is captured as UTF-8; only a trailing `\r` and/or `\n` sequence is stripped, all other whitespace in the output is preserved as-is. A shell that cannot be launched, exits with a non-zero status, times out, exceeds the output limit, or produces output that is not valid UTF-8 each produces a distinct, non-secret error instead of silently falling back to any other value. > **Security warning:** `--allow-command-fallbacks` causes `fire` to execute arbitrary shell commands found in the request file you are running, without confirmation. Only enable this flag for request files you trust, since a command default behaves the same as running that command yourself in a shell. diff --git a/src/error.rs b/src/error.rs index c3060fb..3cdc102 100644 --- a/src/error.rs +++ b/src/error.rs @@ -71,6 +71,10 @@ impl Display for FireError { RunnerError::NonUtf8 => { String::from("Dynamic command fallback produced non-UTF-8 output") } + RunnerError::Timeout => String::from("Dynamic command fallback timed out"), + RunnerError::OutputTooLarge => { + String::from("Dynamic command fallback produced too much output") + } }, FireError::Other(err) => format!("Error: {err}"), }; @@ -152,6 +156,18 @@ mod tests { assert_eq!("Dynamic command fallback produced non-UTF-8 output", err.to_string()); } + #[test] + fn command_fallback_timeout_failure_message() { + let err = FireError::CommandFallbackFailed(RunnerError::Timeout); + assert_eq!("Dynamic command fallback timed out", err.to_string()); + } + + #[test] + fn command_fallback_excessive_output_failure_message() { + let err = FireError::CommandFallbackFailed(RunnerError::OutputTooLarge); + assert_eq!("Dynamic command fallback produced too much output", err.to_string()); + } + /// Documents the stable exit codes chosen for the two new variants: 1 /// and 3-11 are already used by other `FireError` variants, so the /// dynamic-command-fallback variants claim the next free codes, 12 and diff --git a/src/runner.rs b/src/runner.rs index ab7ab28..3beb11d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,4 +1,11 @@ -use std::process::{Command, Output}; +use std::io::Read; +use std::process::{Command, Output, Stdio}; +use std::sync::mpsc::{self, TryRecvError}; +use std::thread; +use std::time::{Duration, Instant}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_STDOUT_BYTES: usize = 64 * 1024; /// Errors that can occur while invoking a dynamic (command) fallback's shell /// command. @@ -10,6 +17,10 @@ pub enum RunnerError { ExitStatus(i32), /// The command produced stdout bytes that are not valid UTF-8. NonUtf8, + /// The command did not finish before the configured timeout. + Timeout, + /// The command produced more stdout than the configured limit. + OutputTooLarge, } /// Run `command` via the platform shell and return its normalized stdout. @@ -28,13 +39,75 @@ pub fn run_command(command: &str) -> Result { } fn run_with_shell(shell: &str, shell_arg: &str, command: &str) -> Result { - let output: Output = Command::new(shell) + run_with_limits(shell, shell_arg, command, COMMAND_TIMEOUT, MAX_STDOUT_BYTES) +} + +fn run_with_limits( + shell: &str, + shell_arg: &str, + command: &str, + timeout: Duration, + max_stdout_bytes: usize, +) -> Result { + let mut child = Command::new(shell) .arg(shell_arg) .arg(command) - .output() - .map_err(|e| RunnerError::Launch(e.to_string()))?; + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| RunnerError::Launch(error.to_string()))?; + let stdout = child.stdout.take().expect("stdout is piped"); + let (sender, receiver) = mpsc::sync_channel(1); + + thread::spawn(move || { + let mut reader = stdout; + let mut bytes: Vec = Vec::with_capacity(max_stdout_bytes.saturating_add(1)); + let result = reader + .by_ref() + .take(max_stdout_bytes.saturating_add(1) as u64) + .read_to_end(&mut bytes) + .map(|_| bytes); + let _ = sender.send(result); + }); + + let deadline: Instant = Instant::now() + timeout; + let mut stdout: Option> = None; + + loop { + match receiver.try_recv() { + Ok(Ok(bytes)) if bytes.len() > max_stdout_bytes => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunnerError::OutputTooLarge); + } + Ok(Ok(bytes)) => stdout = Some(bytes), + Ok(Err(error)) => return Err(RunnerError::Launch(error.to_string())), + Err(TryRecvError::Disconnected) => { + return Err(RunnerError::Launch(String::from("stdout reader disconnected"))) + } + Err(TryRecvError::Empty) => {} + } + + if let Some(status) = + child.try_wait().map_err(|error| RunnerError::Launch(error.to_string()))? + { + if let Some(stdout) = stdout { + return normalize(Output { + status, + stdout, + stderr: Vec::new(), + }); + } + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunnerError::Timeout); + } - normalize(output) + thread::sleep(Duration::from_millis(1)); + } } fn normalize(output: Output) -> Result { @@ -104,4 +177,27 @@ mod tests { let err = run_command("exit 3").unwrap_err(); assert_eq!(RunnerError::ExitStatus(3), err); } + + #[cfg(unix)] + #[test] + fn command_is_killed_when_it_exceeds_the_timeout() { + let err = + run_with_limits("sh", "-c", "sleep 1", std::time::Duration::from_millis(5), 64 * 1024) + .unwrap_err(); + assert_eq!(RunnerError::Timeout, err); + } + + #[cfg(unix)] + #[test] + fn command_output_is_capped() { + let err = run_with_limits( + "sh", + "-c", + "yes | head -c 65537", + std::time::Duration::from_secs(1), + 64 * 1024, + ) + .unwrap_err(); + assert_eq!(RunnerError::OutputTooLarge, err); + } } From d1b2ac1f2a3149ac93529f6a52d52b4400c16294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Sat, 12 Sep 2026 22:08:59 +0000 Subject: [PATCH 09/13] fix(template): preserve Handlebars context --- src/template.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/template.rs b/src/template.rs index 7b916b1..4855593 100644 --- a/src/template.rs +++ b/src/template.rs @@ -17,7 +17,7 @@ pub fn substitution( let refs: Vec = templ::scan(&input); let props: HashMap = merge(vars); - let mut render_vars: HashMap = HashMap::new(); + let mut render_vars: HashMap = props.clone(); let mut rewrites: Vec> = Vec::with_capacity(refs.len()); let mut prompt_names: HashSet = HashSet::new(); let mut missing: Option = None; @@ -285,6 +285,18 @@ mod tests { } } + #[test] + fn handlebars_blocks_receive_supplied_properties() -> Result<(), ParsePropertyError> { + let enabled: Property = + Property::new(String::from("ENABLED"), String::from("true"), Source::Arg)?; + let input: String = String::from("{{#if ENABLED}}enabled{{/if}}"); + + let result: String = substitution(input, vec![enabled], false, false, false, false).unwrap(); + + assert_eq!("enabled", result); + Ok(()) + } + #[test] fn sourced_value_bypasses_authorization_and_runner_for_command_fallback( ) -> Result<(), ParsePropertyError> { From 60461aa7a3e1efb0b743a4c060c2e8728c78951a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Sat, 12 Sep 2026 22:12:50 +0000 Subject: [PATCH 10/13] docs(examples): add token command fallback --- examples/request_with_absent_template_key.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/request_with_absent_template_key.yml b/examples/request_with_absent_template_key.yml index d4da2e4..4290faa 100644 --- a/examples/request_with_absent_template_key.yml +++ b/examples/request_with_absent_template_key.yml @@ -4,4 +4,4 @@ method: GET url: https://api.github.com/users/{{USERNAME}}/followers headers: accept: application/vnd.github+json - authorization: Bearer {{TOKEN}} + authorization: Bearer {{TOKEN|fooooo}} From aebe9c2d07509613114478dcbd3ba650feba9b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Sat, 12 Sep 2026 22:15:54 +0000 Subject: [PATCH 11/13] fix(runner): await captured stdout --- src/runner.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 3beb11d..43dba77 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -82,9 +82,10 @@ fn run_with_limits( } Ok(Ok(bytes)) => stdout = Some(bytes), Ok(Err(error)) => return Err(RunnerError::Launch(error.to_string())), - Err(TryRecvError::Disconnected) => { + Err(TryRecvError::Disconnected) if stdout.is_none() => { return Err(RunnerError::Launch(String::from("stdout reader disconnected"))) } + Err(TryRecvError::Disconnected) => {} Err(TryRecvError::Empty) => {} } From 677650bba2259e8d59164cea6a0387c5af772ad2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Sat, 12 Sep 2026 22:20:23 +0000 Subject: [PATCH 12/13] build: remove unused parser dependencies --- Cargo.lock | 8 -------- Cargo.toml | 2 -- 2 files changed, 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b11173..d2d5cbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,9 +343,7 @@ dependencies = [ "handlebars", "http", "httpx", - "lazy_static", "log", - "regex", "serde", "serde_json", "serde_yaml", @@ -639,12 +637,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.180" diff --git a/Cargo.toml b/Cargo.toml index e5b282a..8a9a3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,8 +24,6 @@ http = "0.2.8" httpx = { path = "httpx" } giro = "0.1.1" dialoguer = { version = "0.11", default-features = false } -regex = "1.10" -lazy_static = "1.4.0" [build-dependencies] built = { version = "0.6" } From 23f06129d727a67535989b01667eb57564d3aa18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C3=96sterberg?= Date: Fri, 18 Sep 2026 13:30:18 +0000 Subject: [PATCH 13/13] fix(template): avoid fallback key collisions --- src/template.rs | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/template.rs b/src/template.rs index 4855593..477f7ca 100644 --- a/src/template.rs +++ b/src/template.rs @@ -18,11 +18,13 @@ pub fn substitution( let props: HashMap = merge(vars); let mut render_vars: HashMap = props.clone(); + let mut occupied_keys: HashSet = props.keys().cloned().collect(); + occupied_keys.extend(refs.iter().map(|reference: &Reference| reference.name.clone())); let mut rewrites: Vec> = Vec::with_capacity(refs.len()); let mut prompt_names: HashSet = HashSet::new(); let mut missing: Option = None; - for (index, reference) in refs.iter().enumerate() { + for reference in &refs { let resolved: Option = match props.get(&reference.name).cloned() { Some(value) => Some(value), None => match &reference.fallback { @@ -44,7 +46,7 @@ pub fn substitution( rewrites.push(None); } (_, Some(value)) => { - let key: String = internal_key(index); + let key: String = internal_key(&mut occupied_keys); render_vars.insert(key.clone(), value); rewrites.push(Some(key)); } @@ -91,8 +93,11 @@ fn resolve_command_fallback(command: &str, allow: bool) -> Result String { - format!("__fire_ref_{index}") +fn internal_key(occupied: &mut HashSet) -> String { + (0_usize..) + .map(|index: usize| format!("__fire_ref_{index}")) + .find(|candidate: &String| occupied.insert(candidate.clone())) + .unwrap() } /// Rebuild `input` with every extended-fallback occurrence's span replaced by @@ -297,6 +302,33 @@ mod tests { Ok(()) } + #[test] + fn fallback_rewrite_does_not_overwrite_a_supplied_internal_name( + ) -> Result<(), ParsePropertyError> { + let supplied: Property = + Property::new(String::from("__fire_ref_1"), String::from("provided"), Source::Arg)?; + let input: String = String::from("{{__fire_ref_1}} {{FOO:bar}}"); + + let result: String = + substitution(input, vec![supplied], false, false, false, false).unwrap(); + + assert_eq!("provided bar", result); + Ok(()) + } + + #[test] + fn supplied_internal_name_does_not_overwrite_a_fallback() -> Result<(), ParsePropertyError> { + let supplied: Property = + Property::new(String::from("__fire_ref_0"), String::from("provided"), Source::Arg)?; + let input: String = String::from("{{FOO:bar}} {{__fire_ref_0}}"); + + let result: String = + substitution(input, vec![supplied], false, false, false, false).unwrap(); + + assert_eq!("bar provided", result); + Ok(()) + } + #[test] fn sourced_value_bypasses_authorization_and_runner_for_command_fallback( ) -> Result<(), ParsePropertyError> {