Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion examples/request_with_absent_template_key.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
10 changes: 10 additions & 0 deletions examples/request_with_command_default.yml
Original file line number Diff line number Diff line change
@@ -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}}
8 changes: 8 additions & 0 deletions examples/request_with_static_default.yml
Original file line number Diff line number Diff line change
@@ -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}}
31 changes: 31 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@
#[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
Expand Down Expand Up @@ -132,10 +140,10 @@
}

pub fn try_colors(&self) -> bool {
match self.use_colors() {
ColorChoice::Never => false,
_ => true,
}

Check warning on line 146 in src/args.rs

View workflow job for this annotation

GitHub Actions / lint

match expression looks like `matches!` macro

warning: match expression looks like `matches!` macro --> src/args.rs:143:9 | 143 | / match self.use_colors() { 144 | | ColorChoice::Never => false, 145 | | _ => true, 146 | | } | |_________^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.98.0/index.html#match_like_matches_macro = note: `#[warn(clippy::match_like_matches_macro)]` on by default help: use `matches!` directly | 143 - match self.use_colors() { 144 - ColorChoice::Never => false, 145 - _ => true, 146 - } 143 + !matches!(self.use_colors(), ColorChoice::Never) |
}

pub fn file(&self) -> &std::path::Path {
Expand All @@ -162,6 +170,10 @@
self.interactive
}

pub fn allow_command_fallbacks(&self) -> bool {
self.allow_command_fallbacks
}

pub fn env(&self) -> Result<Vec<Property>, ParsePropertyError> {
let sys_envs: Vec<Property> = Self::read_sys_envs()?;
let file_envs: Vec<Property> = self.read_file_envs()?;
Expand Down Expand Up @@ -240,3 +252,22 @@
.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());
}
}
149 changes: 137 additions & 12 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,30 @@

use crate::prop;
use crate::prop::ParsePropertyError;
use crate::runner::RunnerError;

pub trait Error: StdError + Termination {}

Check warning on line 13 in src/error.rs

View workflow job for this annotation

GitHub Actions / lint

trait `Error` is never used

warning: trait `Error` is never used --> src/error.rs:13:11 | 13 | pub trait Error: StdError + Termination {} | ^^^^^ | = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default

pub enum FireError {
Timeout(Url),
Connection(Url),
FileNotFound(PathBuf),
NoReadPermission(PathBuf),
NotAFile(PathBuf),

Check warning on line 20 in src/error.rs

View workflow job for this annotation

GitHub Actions / lint

variant `NotAFile` is never constructed

warning: variant `NotAFile` is never constructed --> src/error.rs:20:5 | 15 | pub enum FireError { | --------- variant in this enum ... 20 | NotAFile(PathBuf), | ^^^^^^^^
GenericIO(String),
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),
}

Expand All @@ -47,31 +58,145 @@
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}"),
};

f.write_str(&msg)
}
}

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<FireError> = 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<u8> = variants.iter().map(FireError::exit_code).collect();
let unique: HashSet<u8> = codes.iter().copied().collect();
assert_eq!(codes.len(), unique.len(), "exit codes must be pairwise distinct: {codes:?}");
}
}
13 changes: 11 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod format;
mod io;
mod logger;
mod prop;
mod runner;
mod templ;
mod template;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -210,6 +217,8 @@ impl From<SubstitutionError> for FireError {
match e {
SubstitutionError::MissingValue(err) => FireError::TemplateKey(err),
SubstitutionError::Rendering => FireError::TemplateRendering,
SubstitutionError::CommandFallbackNotAllowed => FireError::CommandFallbackNotAllowed,
SubstitutionError::CommandFallbackFailed(err) => FireError::CommandFallbackFailed(err),
}
}
}
Expand Down
Loading
Loading