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" } diff --git a/README.md b/README.md index 43e0ce2..668dd52 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. 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. + +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_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}} 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}} diff --git a/src/args.rs b/src/args.rs index e1d452a..202ad5a 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(short = 'F', 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/error.rs b/src/error.rs index 04d716a..3cdc102 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,24 @@ 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") + } + 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}"), }; @@ -54,24 +83,120 @@ 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()); + } + + #[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 + /// 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 2899e42..fd7ae07 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,8 @@ impl From for FireError { match e { SubstitutionError::MissingValue(err) => FireError::TemplateKey(err), SubstitutionError::Rendering => FireError::TemplateRendering, + SubstitutionError::CommandFallbackNotAllowed => FireError::CommandFallbackNotAllowed, + SubstitutionError::CommandFallbackFailed(err) => FireError::CommandFallbackFailed(err), } } } diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..43dba77 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,204 @@ +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. +#[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, + /// 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. +/// +/// 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 { + 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) + .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) if stdout.is_none() => { + return Err(RunnerError::Launch(String::from("stdout reader disconnected"))) + } + Err(TryRecvError::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); + } + + thread::sleep(Duration::from_millis(1)); + } +} + +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() { + // `\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); + } + + #[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); + } + + #[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); + } +} diff --git a/src/templ.rs b/src/templ.rs index 1d4827d..0a23356 100644 --- a/src/templ.rs +++ b/src/templ.rs @@ -1,33 +1,229 @@ -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`]. `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 { - 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); diff --git a/src/template.rs b/src/template.rs index 8565b18..477f7ca 100644 --- a/src/template.rs +++ b/src/template.rs @@ -2,7 +2,9 @@ use handlebars::{no_escape, Handlebars}; use std::collections::HashMap; use std::collections::HashSet; -use crate::{prop::Property, templ}; +use crate::prop::Property; +use crate::runner::{self, RunnerError}; +use crate::templ::{self, Fallback, Reference}; pub fn substitution( input: String, @@ -10,63 +12,151 @@ pub fn substitution( interactive: bool, use_colors: bool, trim: bool, + allow_command_fallbacks: 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 = 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 reference in &refs { + 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) => { + Some(resolve_command_fallback(command, allow_command_fallbacks)?) + } + }, + }; + + 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(&mut occupied_keys); + 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); +/// 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(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 +/// 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)] pub enum SubstitutionError { MissingValue(String), Rendering, + CommandFallbackNotAllowed, + CommandFallbackFailed(RunnerError), } fn merge(mut maps: Vec) -> HashMap { @@ -89,7 +179,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 +195,173 @@ 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, 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, 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, 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, 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, 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, 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, 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, false).unwrap_err(); + match err { + SubstitutionError::MissingValue(name) => assert_eq!("FOO", name), + other => panic!("expected MissingValue, got {:?}", other), + } + } + + #[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 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> { + // 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); + } }