use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RequestFacts {
pub method: String,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: String,
pub name: String,
}
impl RequestFacts {
pub fn of(entry: &crate::hurl::HurlEntry) -> Self {
Self {
method: entry.method.clone(),
url: entry.url.clone(),
headers: entry
.headers
.iter()
.filter(|h| h.enabled)
.map(|h| (h.key.clone(), h.value.clone()))
.collect(),
body: entry.body_wire().unwrap_or_default().into_owned(),
name: entry.title.clone(),
}
}
pub fn path(&self, url: &str) -> String {
let after_scheme = match url.find("://") {
Some(i) => &url[i + 3..],
None => url,
};
let end = after_scheme.find(['?', '#']).unwrap_or(after_scheme.len());
match after_scheme[..end].find('/') {
Some(i) => after_scheme[..end][i..].to_string(),
None => String::new(),
}
}
pub fn query(&self, url: &str) -> String {
match url.find('?') {
Some(i) => {
let rest = &url[i + 1..];
rest[..rest.find('#').unwrap_or(rest.len())].to_string()
}
None => String::new(),
}
}
}
pub trait GenSource {
fn now(&self) -> (i64, u32);
fn fill_random(&self, buf: &mut [u8]);
fn counter(&self, name: &str) -> u64;
fn request(&self) -> Option<&RequestFacts> {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenError {
Empty { name: String },
Syntax { name: String, detail: String },
UnknownFunction { name: String, function: String },
NoRequest { name: String, function: String },
Arity {
name: String,
function: String,
expected: String,
got: usize,
},
BadArgument {
name: String,
function: String,
detail: String,
},
UndefinedReference { name: String, reference: String },
Cycle { name: String },
NameMissing,
NameInvalid { name: String },
NameDuplicate { name: String },
FailedDependency { name: String, reference: String },
}
impl GenError {
#[cfg_attr(not(test), allow(dead_code))]
pub fn row(&self) -> &str {
match self {
GenError::Empty { name }
| GenError::Syntax { name, .. }
| GenError::UnknownFunction { name, .. }
| GenError::NoRequest { name, .. }
| GenError::Arity { name, .. }
| GenError::BadArgument { name, .. }
| GenError::UndefinedReference { name, .. }
| GenError::FailedDependency { name, .. }
| GenError::NameInvalid { name }
| GenError::NameDuplicate { name }
| GenError::Cycle { name } => name,
GenError::NameMissing => "",
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Expr {
Text(String),
Number(String),
Reference(String),
Call { function: String, args: Vec<Expr> },
}
const MAX_DEPTH: usize = 256;
fn braces_fault(text: &str) -> String {
match braced_name(text) {
Some(name) if !name.is_empty() => {
format!("write `{name}`, not `{{{{{name}}}}}`: an expression names a variable directly")
}
_ => "a variable is named directly here, not written in `{{ }}`".to_string(),
}
}
fn braced_name(text: &str) -> Option<&str> {
let rest = &text[text.find("{{")? + 2..];
Some(rest[..rest.find("}}")?].trim())
}
struct Parser<'a> {
rest: &'a str,
}
impl<'a> Parser<'a> {
fn new(src: &'a str) -> Self {
Parser { rest: src }
}
fn skip_space(&mut self) {
self.rest = self.rest.trim_start();
}
fn parse_all(mut self) -> Result<Expr, String> {
let expr = self.expr(0)?;
self.skip_space();
if !self.rest.is_empty() {
return Err(format!("unexpected `{}`", self.rest.trim()));
}
Ok(expr)
}
fn expr(&mut self, depth: usize) -> Result<Expr, String> {
if depth > MAX_DEPTH {
return Err("expression nests too deeply".to_string());
}
self.skip_space();
match self.rest.chars().next() {
None => Err("expression is empty".to_string()),
Some('"') => self.string(),
Some(c) if c == '-' || c.is_ascii_digit() => self.number(),
Some(c) if is_name_char(c) => self.ident_or_call(depth),
Some('{') => Err(braces_fault(self.rest)),
Some(c) => Err(format!("unexpected `{c}`")),
}
}
fn string(&mut self) -> Result<Expr, String> {
let mut out = String::new();
let mut chars = self.rest.char_indices();
chars.next(); let mut braced = false;
let mut prev_open_brace = false;
while let Some((i, c)) = chars.next() {
match c {
'"' => {
self.rest = &self.rest[i + 1..];
if braced {
return Err(braces_fault(&out));
}
return Ok(Expr::Text(out));
}
'\\' => {
prev_open_brace = false;
match chars.next() {
Some((_, 'n')) => out.push('\n'),
Some((_, 't')) => out.push('\t'),
Some((_, 'r')) => out.push('\r'),
Some((_, '"')) => out.push('"'),
Some((_, '\\')) => out.push('\\'),
Some((_, '{')) => out.push('{'),
Some((_, other)) => return Err(format!("unknown escape `\\{other}`")),
None => return Err("string ends in a backslash".to_string()),
}
}
_ => {
braced |= c == '{' && prev_open_brace;
prev_open_brace = c == '{';
out.push(c);
}
}
}
Err("unterminated string".to_string())
}
fn number(&mut self) -> Result<Expr, String> {
let end = self
.rest
.char_indices()
.position(|(i, c)| !(c.is_ascii_digit() || (i == 0 && c == '-')))
.unwrap_or(self.rest.len());
let (num, rest) = self.rest.split_at(end);
if num == "-" {
return Err("`-` is not a number".to_string());
}
self.rest = rest;
Ok(Expr::Number(num.to_string()))
}
fn ident_or_call(&mut self, depth: usize) -> Result<Expr, String> {
let end = self
.rest
.find(|c: char| !is_name_char(c))
.unwrap_or(self.rest.len());
let (name, rest) = self.rest.split_at(end);
self.rest = rest;
let name = name.to_string();
self.skip_space();
if !self.rest.starts_with('(') {
return Ok(Expr::Reference(name));
}
self.rest = &self.rest[1..];
let mut args = Vec::new();
self.skip_space();
if self.rest.starts_with(')') {
self.rest = &self.rest[1..];
return Ok(Expr::Call {
function: name,
args,
});
}
loop {
args.push(self.expr(depth + 1)?);
self.skip_space();
match self.rest.chars().next() {
Some(',') => self.rest = &self.rest[1..],
Some(')') => {
self.rest = &self.rest[1..];
return Ok(Expr::Call {
function: name,
args,
});
}
Some(c) => return Err(format!("expected `,` or `)`, found `{c}`")),
None => return Err(format!("`{name}(` is never closed")),
}
}
}
}
fn is_name_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
#[derive(Default)]
pub struct SystemSource {
request: Option<RequestFacts>,
}
impl SystemSource {
pub fn new() -> Self {
Self::default()
}
pub fn for_request(facts: RequestFacts) -> Self {
Self {
request: Some(facts),
}
}
}
fn process_counters() -> &'static std::sync::Mutex<HashMap<String, u64>> {
static COUNTERS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, u64>>> =
std::sync::OnceLock::new();
COUNTERS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
}
impl GenSource for SystemSource {
fn now(&self) -> (i64, u32) {
let now = chrono::Utc::now();
(now.timestamp(), now.timestamp_subsec_nanos())
}
fn fill_random(&self, buf: &mut [u8]) {
getrandom::fill(buf).expect("the operating system's random source");
}
fn request(&self) -> Option<&RequestFacts> {
self.request.as_ref()
}
fn counter(&self, name: &str) -> u64 {
let mut counters = process_counters().lock().unwrap_or_else(|e| e.into_inner());
let next = counters.entry(name.to_string()).or_insert(0);
*next += 1;
*next
}
}
pub struct DryRunSource;
impl GenSource for DryRunSource {
fn now(&self) -> (i64, u32) {
SystemSource::new().now()
}
fn fill_random(&self, buf: &mut [u8]) {
SystemSource::new().fill_random(buf)
}
fn counter(&self, name: &str) -> u64 {
let counters = process_counters().lock().unwrap_or_else(|e| e.into_inner());
counters.get(name).copied().unwrap_or(0) + 1
}
}
fn name_faults(rows: &[(String, String)]) -> (Vec<GenError>, Vec<bool>) {
let mut errors = Vec::new();
let mut refused = vec![false; rows.len()];
let mut seen: Vec<&str> = Vec::new();
let mut reported: Vec<&str> = Vec::new();
for (i, (name, source)) in rows.iter().enumerate() {
let n = name.trim();
if n.is_empty() && source.trim().is_empty() {
continue;
}
refused[i] = true;
if n.is_empty() {
errors.push(GenError::NameMissing);
} else if !crate::hurl::is_variable_name(n) {
errors.push(GenError::NameInvalid {
name: n.to_string(),
});
} else if seen.contains(&n) {
if !reported.contains(&n) {
reported.push(n);
errors.push(GenError::NameDuplicate {
name: n.to_string(),
});
}
} else {
seen.push(n);
refused[i] = false;
}
}
(errors, refused)
}
pub fn expand(
rows: &[(String, String)],
vars: &mut HashMap<String, String>,
src: &dyn GenSource,
) -> Vec<GenError> {
let declared: Vec<&str> = rows.iter().map(|(n, _)| n.as_str()).collect();
let (mut errors, refused) = name_faults(rows);
let mut done: Vec<&str> = Vec::new();
let mut failed: Vec<&str> = Vec::new();
for (i, (name, source)) in rows.iter().enumerate() {
if refused[i] {
continue;
}
if name.trim().is_empty() && source.trim().is_empty() {
continue;
}
if source.trim().is_empty() {
errors.push(GenError::Empty { name: name.clone() });
continue;
}
let expr = match Parser::new(source).parse_all() {
Ok(e) => e,
Err(detail) => {
errors.push(GenError::Syntax {
name: name.clone(),
detail,
});
continue;
}
};
match eval(&expr, name, vars, &declared, &done, &failed, src) {
Ok(value) => {
vars.insert(name.clone(), value);
done.push(name.as_str());
}
Err(e) => {
errors.push(e);
failed.push(name.as_str());
}
}
}
errors
}
pub fn check(rows: &[(String, String)]) -> Vec<GenError> {
fn walk(expr: &Expr, row: &str, out: &mut Vec<GenError>) {
match expr {
Expr::Text(_) | Expr::Number(_) => {}
Expr::Reference(name) => {
if let Some(f) = function(name)
&& f.min_args > 0
{
out.push(GenError::Arity {
name: row.to_string(),
function: name.clone(),
expected: expected_arity(f),
got: 0,
});
}
}
Expr::Call {
function: fname,
args,
} => {
match function(fname) {
None => out.push(GenError::UnknownFunction {
name: row.to_string(),
function: fname.clone(),
}),
Some(f) => {
if args.len() < f.min_args || f.max_args.is_some_and(|m| args.len() > m) {
out.push(GenError::Arity {
name: row.to_string(),
function: fname.clone(),
expected: expected_arity(f),
got: args.len(),
});
}
}
}
for a in args {
walk(a, row, out);
}
}
}
}
let (mut out, refused) = name_faults(rows);
for (i, (name, source)) in rows.iter().enumerate() {
if refused[i] || (name.trim().is_empty() && source.trim().is_empty()) {
continue;
}
if source.trim().is_empty() {
out.push(GenError::Empty { name: name.clone() });
continue;
}
match Parser::new(source).parse_all() {
Err(detail) => out.push(GenError::Syntax {
name: name.clone(),
detail,
}),
Ok(expr) => walk(&expr, name, &mut out),
}
}
out
}
fn expected_arity(f: &GenFunction) -> String {
match (f.min_args, f.max_args) {
(lo, Some(hi)) if lo == hi => lo.to_string(),
(lo, Some(hi)) => format!("{lo} or {hi}"),
(lo, None) => format!("{lo} or more"),
}
}
fn eval(
expr: &Expr,
row: &str,
vars: &HashMap<String, String>,
declared: &[&str],
done: &[&str],
failed: &[&str],
src: &dyn GenSource,
) -> Result<String, GenError> {
match expr {
Expr::Text(t) => Ok(t.clone()),
Expr::Number(n) => Ok(n.clone()),
Expr::Reference(name) => {
if is_function(name.as_str()) {
return call(name, &[], row, vars, src);
}
if failed.contains(&name.as_str()) {
return Err(GenError::FailedDependency {
name: row.to_string(),
reference: name.clone(),
});
}
if declared.contains(&name.as_str()) && !done.contains(&name.as_str()) {
return Err(GenError::Cycle {
name: row.to_string(),
});
}
vars.get(name)
.cloned()
.ok_or_else(|| GenError::UndefinedReference {
name: row.to_string(),
reference: name.clone(),
})
}
Expr::Call { function, args } => {
if !is_function(function.as_str()) {
return Err(GenError::UnknownFunction {
name: row.to_string(),
function: function.clone(),
});
}
let mut values = Vec::with_capacity(args.len());
for a in args {
values.push(eval(a, row, vars, declared, done, failed, src)?);
}
call(function, &values, row, vars, src)
}
}
}
fn call(
function: &str,
args: &[String],
row: &str,
vars: &HashMap<String, String>,
src: &dyn GenSource,
) -> Result<String, GenError> {
let arity = |expected: &str, ok: bool| -> Result<(), GenError> {
if ok {
Ok(())
} else {
Err(GenError::Arity {
name: row.to_string(),
function: function.to_string(),
expected: expected.to_string(),
got: args.len(),
})
}
};
let bad = |detail: String| GenError::BadArgument {
name: row.to_string(),
function: function.to_string(),
detail,
};
let count = |s: &String| -> Result<usize, GenError> {
s.parse::<usize>()
.map_err(|_| bad("the length must be a whole number".to_string()))
};
match function {
"timestamp" => {
arity("0 or 1", args.len() <= 1)?;
let offset = match args.first() {
None => 0,
Some(a) => a
.parse::<i64>()
.map_err(|_| bad("the offset must be a whole number of seconds".to_string()))?,
};
let stamp = src
.now()
.0
.checked_add(offset)
.ok_or_else(|| bad("the offset is too large".to_string()))?;
Ok(stamp.to_string())
}
"timestamp_ms" => {
arity("0", args.is_empty())?;
let (secs, nanos) = src.now();
Ok((secs * 1000 + i64::from(nanos / 1_000_000)).to_string())
}
"iso8601" => {
arity("0", args.is_empty())?;
Ok(utc(src).to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
"date" => {
arity("1", args.len() == 1)?;
Ok(utc(src).format(&args[0]).to_string())
}
"uuid" => {
arity("0", args.is_empty())?;
Ok(uuid::Uuid::new_v4().to_string())
}
"counter" => {
arity("1", args.len() == 1)?;
Ok(src.counter(&args[0]).to_string())
}
"random_int" => {
arity("2", args.len() == 2)?;
let lo = args[0]
.parse::<i64>()
.map_err(|_| bad("the low bound must be a whole number".to_string()))?;
let hi = args[1]
.parse::<i64>()
.map_err(|_| bad("the high bound must be a whole number".to_string()))?;
if lo > hi {
return Err(bad(format!("{lo} is greater than {hi}")));
}
Ok(random_int(lo, hi, src).to_string())
}
"random_hex" => {
arity("1", args.len() == 1)?;
let n = count(&args[0])?;
let mut bytes = vec![0u8; n.div_ceil(2)];
src.fill_random(&mut bytes);
let mut out = to_hex(&bytes);
out.truncate(n);
Ok(out)
}
"random_alnum" => {
arity("1", args.len() == 1)?;
Ok(random_from(
count(&args[0])?,
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
src,
))
}
"random_base64" => {
arity("1", args.len() == 1)?;
let mut bytes = vec![0u8; count(&args[0])?];
src.fill_random(&mut bytes);
Ok(b64(&bytes, false))
}
"base64" => {
arity("1", args.len() == 1)?;
Ok(b64(args[0].as_bytes(), false))
}
"base64url" => {
arity("1", args.len() == 1)?;
Ok(b64(args[0].as_bytes(), true))
}
"base64_decode" => {
arity("1", args.len() == 1)?;
use base64::Engine;
let raw = base64::engine::general_purpose::STANDARD
.decode(args[0].as_bytes())
.map_err(|e| bad(format!("not valid base64 ({e})")))?;
String::from_utf8(raw).map_err(|_| bad("decodes to bytes that aren't text".to_string()))
}
"hex" => {
arity("1", args.len() == 1)?;
Ok(to_hex(args[0].as_bytes()))
}
"urlencode" => {
arity("1", args.len() == 1)?;
Ok(percent_encode(&args[0]))
}
"urldecode" => {
arity("1", args.len() == 1)?;
percent_decode(&args[0]).map_err(bad)
}
"json_string" => {
arity("1", args.len() == 1)?;
Ok(serde_json::Value::String(args[0].clone()).to_string())
}
"jsonpath" => {
arity("2", args.len() == 2)?;
let doc: serde_json::Value =
serde_json::from_str(&args[0]).map_err(|e| bad(format!("not valid JSON ({e})")))?;
json_path(&doc, &args[1]).map_err(bad)
}
"md5" | "sha1" | "sha256" | "sha512" | "md5_b64" | "sha1_b64" | "sha256_b64"
| "sha512_b64" | "md5_b64url" | "sha1_b64url" | "sha256_b64url" | "sha512_b64url" => {
arity("1", args.len() == 1)?;
let (alg, as_b64) = split_encoding(function);
Ok(encode_digest(&hash_bytes(alg, args[0].as_bytes()), as_b64))
}
"hmac_sha1" | "hmac_sha256" | "hmac_sha512" | "hmac_sha1_b64" | "hmac_sha256_b64"
| "hmac_sha512_b64" | "hmac_sha1_b64url" | "hmac_sha256_b64url" | "hmac_sha512_b64url" => {
arity("2", args.len() == 2)?;
let (alg, as_b64) = split_encoding(function);
let alg = alg.strip_prefix("hmac_").expect("matched an hmac_ name");
let mac = hmac_bytes(alg, args[0].as_bytes(), args[1].as_bytes());
Ok(encode_digest(&mac, as_b64))
}
"concat" => Ok(args.concat()),
"upper" => {
arity("1", args.len() == 1)?;
Ok(args[0].to_uppercase())
}
"lower" => {
arity("1", args.len() == 1)?;
Ok(args[0].to_lowercase())
}
"trim" => {
arity("1", args.len() == 1)?;
Ok(args[0].trim().to_string())
}
"split" => {
arity("3", args.len() == 3)?;
let pieces: Vec<&str> = if args[1].is_empty() {
return Err(bad("the separator cannot be empty".to_string()));
} else {
args[0].split(args[1].as_str()).collect()
};
let n = args[2]
.trim()
.parse::<i64>()
.map_err(|_| bad("the piece number must be a whole number".to_string()))?;
let idx = if n < 0 { pieces.len() as i64 + n } else { n };
usize::try_from(idx)
.ok()
.and_then(|i| pieces.get(i))
.map(|p| p.to_string())
.ok_or_else(|| {
bad(format!(
"there is no piece {n}; the text splits into {}",
pieces.len()
))
})
}
"regex" => {
arity("2", args.len() == 2)?;
let re = regex::Regex::new(&args[1])
.map_err(|e| bad(format!("the pattern is not valid: {e}")))?;
let caps = re
.captures(&args[0])
.ok_or_else(|| bad("the pattern matched nothing".to_string()))?;
Ok(caps
.get(1)
.or_else(|| caps.get(0))
.map(|m| m.as_str().to_string())
.unwrap_or_default())
}
"method" | "url" | "path" | "query" | "body" | "request_name" => {
arity("0", args.is_empty())?;
let facts = src.request().ok_or_else(|| GenError::NoRequest {
name: row.to_string(),
function: function.to_string(),
})?;
let fill = |t: &str| crate::environment::substitute(t, vars);
Ok(match function {
"method" => facts.method.to_ascii_uppercase(),
"url" => fill(&facts.url),
"path" => facts.path(&fill(&facts.url)),
"query" => facts.query(&fill(&facts.url)),
"body" => fill(&facts.body),
_ => facts.name.clone(),
})
}
"header" => {
arity("1", args.len() == 1)?;
let facts = src.request().ok_or_else(|| GenError::NoRequest {
name: row.to_string(),
function: function.to_string(),
})?;
Ok(facts
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(args[0].trim()))
.map(|(_, v)| crate::environment::substitute(v, vars))
.unwrap_or_default())
}
_ => Err(GenError::UnknownFunction {
name: row.to_string(),
function: function.to_string(),
}),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct GenFunction {
pub name: &'static str,
pub signature: &'static str,
pub min_args: usize,
pub max_args: Option<usize>,
pub examples: &'static [&'static str],
}
pub const FUNCTIONS: &[GenFunction] = &[
GenFunction {
name: "base64",
signature: "base64(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "base64_decode",
signature: "base64_decode(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "base64url",
signature: "base64url(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "body",
signature: "body()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "concat",
signature: "concat(a, b, …)",
min_args: 0,
max_args: None,
examples: &[],
},
GenFunction {
name: "counter",
signature: "counter(name)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "date",
signature: "date(format)",
min_args: 1,
max_args: Some(1),
examples: &[
r#"date("%Y-%m-%d")"#,
r#"date("%d/%m/%Y")"#,
r#"date("%d %b %Y")"#,
r#"date("%Y-%m-%dT%H:%M:%SZ")"#,
r#"date("%H:%M:%S")"#,
r#"date("%Y%m%d%H%M%S")"#,
],
},
GenFunction {
name: "header",
signature: "header(name)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "hex",
signature: "hex(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "hmac_sha1",
signature: "hmac_sha1(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha1_b64",
signature: "hmac_sha1_b64(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha1_b64url",
signature: "hmac_sha1_b64url(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha256",
signature: "hmac_sha256(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha256_b64",
signature: "hmac_sha256_b64(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha256_b64url",
signature: "hmac_sha256_b64url(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha512",
signature: "hmac_sha512(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha512_b64",
signature: "hmac_sha512_b64(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "hmac_sha512_b64url",
signature: "hmac_sha512_b64url(key, message)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "iso8601",
signature: "iso8601()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "json_string",
signature: "json_string(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "jsonpath",
signature: "jsonpath(text, path)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "lower",
signature: "lower(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "md5",
signature: "md5(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "md5_b64",
signature: "md5_b64(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "md5_b64url",
signature: "md5_b64url(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "method",
signature: "method()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "path",
signature: "path()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "query",
signature: "query()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "random_alnum",
signature: "random_alnum(length)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "random_base64",
signature: "random_base64(bytes)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "random_hex",
signature: "random_hex(length)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "random_int",
signature: "random_int(low, high)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "regex",
signature: "regex(text, pattern)",
min_args: 2,
max_args: Some(2),
examples: &[],
},
GenFunction {
name: "request_name",
signature: "request_name()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "sha1",
signature: "sha1(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha1_b64",
signature: "sha1_b64(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha1_b64url",
signature: "sha1_b64url(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha256",
signature: "sha256(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha256_b64",
signature: "sha256_b64(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha256_b64url",
signature: "sha256_b64url(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha512",
signature: "sha512(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha512_b64",
signature: "sha512_b64(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "sha512_b64url",
signature: "sha512_b64url(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "split",
signature: "split(text, separator, n)",
min_args: 3,
max_args: Some(3),
examples: &[],
},
GenFunction {
name: "timestamp",
signature: "timestamp([offset_seconds])",
min_args: 0,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "timestamp_ms",
signature: "timestamp_ms()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "trim",
signature: "trim(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "upper",
signature: "upper(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "url",
signature: "url()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
GenFunction {
name: "urldecode",
signature: "urldecode(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "urlencode",
signature: "urlencode(text)",
min_args: 1,
max_args: Some(1),
examples: &[],
},
GenFunction {
name: "uuid",
signature: "uuid()",
min_args: 0,
max_args: Some(0),
examples: &[],
},
];
pub fn function(name: &str) -> Option<&'static GenFunction> {
FUNCTIONS.iter().find(|f| f.name == name)
}
pub fn is_function(name: &str) -> bool {
FUNCTIONS.iter().any(|f| f.name == name)
}
pub fn functions_starting_with(prefix: &str) -> impl Iterator<Item = &'static GenFunction> {
let prefix = prefix.to_ascii_lowercase();
FUNCTIONS
.iter()
.filter(move |f| f.name.starts_with(prefix.as_str()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypedWord {
pub start: usize,
pub end: usize,
pub wrap_end: usize,
pub prefix: String,
pub wrapped: String,
pub whole: String,
}
pub fn typed_word_at(text: &str, caret: Option<usize>) -> TypedWord {
let chars: Vec<char> = text.chars().collect();
let at = caret.unwrap_or(chars.len()).min(chars.len());
let is_word = |c: &char| c.is_ascii_alphanumeric() || *c == '_';
let mut start = at;
while start > 0 && is_word(&chars[start - 1]) {
start -= 1;
}
let mut end = at;
while end < chars.len() && is_word(&chars[end]) {
end += 1;
}
let mut wrap_end = end;
if chars.get(end) == Some(&'(') {
let mut depth = 0usize;
for (i, c) in chars.iter().enumerate().skip(end) {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
wrap_end = i + 1;
break;
}
}
_ => {}
}
}
}
TypedWord {
prefix: chars[start..at].iter().collect(),
wrapped: chars[at..wrap_end].iter().collect(),
whole: chars[start..end].iter().collect(),
start,
end,
wrap_end,
}
}
pub fn suggestions_for_word(prefix: &str, whole: &str, browse: bool) -> Option<Vec<&'static str>> {
if prefix.is_empty() && !browse {
return None;
}
let sugs: Vec<&'static str> = functions_starting_with(prefix)
.flat_map(|f| std::iter::once(f.signature).chain(f.examples.iter().copied()))
.collect();
let done = sugs.len() == 1 && is_function(whole);
(!sugs.is_empty() && !done).then_some(sugs)
}
pub fn completion(f: &GenFunction) -> (String, usize) {
if f.min_args == 0 {
(f.name.to_string(), f.name.chars().count())
} else {
(format!("{}()", f.name), f.name.chars().count() + 1)
}
}
pub fn can_wrap(f: &GenFunction) -> bool {
f.max_args != Some(0)
}
pub fn function_for_suggestion(row: &str) -> Option<&'static GenFunction> {
FUNCTIONS
.iter()
.find(|f| f.signature == row)
.or_else(|| FUNCTIONS.iter().find(|f| f.examples.contains(&row)))
}
fn utc(src: &dyn GenSource) -> chrono::DateTime<chrono::Utc> {
let (secs, nanos) = src.now();
chrono::DateTime::from_timestamp(secs, nanos).unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DigestEncoding {
Hex,
B64,
B64Url,
}
fn split_encoding(function: &str) -> (&str, DigestEncoding) {
if let Some(alg) = function.strip_suffix("_b64url") {
(alg, DigestEncoding::B64Url)
} else if let Some(alg) = function.strip_suffix("_b64") {
(alg, DigestEncoding::B64)
} else {
(function, DigestEncoding::Hex)
}
}
fn encode_digest(bytes: &[u8], how: DigestEncoding) -> String {
match how {
DigestEncoding::Hex => to_hex(bytes),
DigestEncoding::B64 => b64(bytes, false),
DigestEncoding::B64Url => b64(bytes, true),
}
}
fn hash_bytes(alg: &str, msg: &[u8]) -> Vec<u8> {
use sha2::Digest;
match alg {
"md5" => md5::Md5::digest(msg).to_vec(),
"sha1" => sha1::Sha1::digest(msg).to_vec(),
"sha256" => sha2::Sha256::digest(msg).to_vec(),
"sha512" => sha2::Sha512::digest(msg).to_vec(),
other => unreachable!("hash_bytes called with {other}, which `call` does not dispatch"),
}
}
fn hmac_bytes(alg: &str, key: &[u8], msg: &[u8]) -> Vec<u8> {
use hmac::Mac;
macro_rules! mac {
($d:ty) => {{
let mut m =
hmac::Hmac::<$d>::new_from_slice(key).expect("HMAC accepts a key of any length");
m.update(msg);
m.finalize().into_bytes().to_vec()
}};
}
match alg {
"sha1" => mac!(sha1::Sha1),
"sha256" => mac!(sha2::Sha256),
"sha512" => mac!(sha2::Sha512),
other => unreachable!("hmac_bytes called with {other}, which `call` does not dispatch"),
}
}
fn b64(bytes: &[u8], url_safe: bool) -> String {
use base64::Engine;
if url_safe {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
} else {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
}
fn json_path(doc: &serde_json::Value, path: &str) -> Result<String, String> {
let path = path.trim();
let unsupported = |what: &str| {
Err(format!(
"{what} is not supported here — use a `[Captures]` row, which has \
Hurl's full JSONPath"
))
};
if !path.starts_with('$') {
return Err(format!("a path starts with `$`, not {path:?}"));
}
if path.contains("..") {
return unsupported("recursive descent (`..`)");
}
if path.contains('*') {
return unsupported("a wildcard");
}
for part in path.split('[').skip(1) {
let inside = part.split(']').next().unwrap_or_default().trim();
if inside.starts_with('?') {
continue;
}
if inside.starts_with('\'') || inside.starts_with('"') {
continue;
}
if inside.contains(':') {
return unsupported("a slice");
}
if inside.contains(',') {
return unsupported("a union");
}
}
let found = crate::report::run::json_path_get(doc, path)
.ok_or_else(|| format!("there is nothing at {path}"))?;
Ok(match found {
serde_json::Value::String(s) => s,
serde_json::Value::Null => return Err(format!("the value at {path} is null")),
other => other.to_string(),
})
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn random_int(lo: i64, hi: i64, src: &dyn GenSource) -> i64 {
let span = (hi as i128 - lo as i128 + 1) as u128;
if span == 1 {
return lo;
}
let limit = u128::MAX - (u128::MAX % span) - 1;
loop {
let mut buf = [0u8; 16];
src.fill_random(&mut buf);
let draw = u128::from_le_bytes(buf);
if draw <= limit {
return (lo as i128 + (draw % span) as i128) as i64;
}
}
}
fn random_from(n: usize, alphabet: &[u8], src: &dyn GenSource) -> String {
let len = alphabet.len() as u8;
let limit = u8::MAX - (u8::MAX % len) - 1;
let mut out = String::with_capacity(n);
let mut buf = [0u8; 64];
while out.len() < n {
src.fill_random(&mut buf);
for b in buf {
if b <= limit {
out.push(alphabet[(b % len) as usize] as char);
if out.len() == n {
break;
}
}
}
}
out
}
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn percent_decode(s: &str) -> Result<String, String> {
let raw = s.as_bytes();
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
if raw[i] == b'%' {
let hex = raw
.get(i + 1..i + 3)
.ok_or_else(|| "ends in an incomplete `%` escape".to_string())?;
let hex = std::str::from_utf8(hex).map_err(|_| "invalid `%` escape".to_string())?;
out.push(
u8::from_str_radix(hex, 16).map_err(|_| format!("`%{hex}` is not a hex escape"))?,
);
i += 3;
} else {
out.push(raw[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| "decodes to bytes that aren't text".to_string())
}
#[cfg(test)]
mod tests {
#[test]
fn the_function_table_is_in_alphabetical_order() {
let names: Vec<&str> = FUNCTIONS.iter().map(|f| f.name).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "FUNCTIONS is out of alphabetical order");
}
#[test]
fn a_word_is_split_at_the_caret() {
let w = typed_word_at("tuuid", Some(1));
assert_eq!(w.prefix, "t", "only the typed part filters the list");
assert_eq!(w.wrapped, "uuid");
assert_eq!((w.start, w.end, w.wrap_end), (0, 5, 5));
}
#[test]
fn the_call_after_the_caret_is_wrapped_whole() {
let w = typed_word_at("bsha256(md5(x))", Some(1));
assert_eq!(w.prefix, "b");
assert_eq!(w.wrapped, "sha256(md5(x))");
assert_eq!(w.wrap_end, 15);
}
#[test]
fn an_unclosed_call_is_not_wrapped() {
let w = typed_word_at("bsha256(md5(", Some(1));
assert_eq!(w.wrapped, "sha256", "only the name, not the open call");
assert_eq!(w.wrap_end, w.end);
}
use super::*;
fn parse(src: &str) -> Result<Expr, String> {
Parser::new(src).parse_all()
}
struct FakeSource {
secs: i64,
counters: std::sync::Mutex<HashMap<String, u64>>,
request: Option<RequestFacts>,
}
impl FakeSource {
fn at(secs: i64) -> Self {
FakeSource {
secs,
counters: std::sync::Mutex::new(HashMap::new()),
request: None,
}
}
fn sending(mut self, request: RequestFacts) -> Self {
self.request = Some(request);
self
}
}
impl GenSource for FakeSource {
fn now(&self) -> (i64, u32) {
(self.secs, 123_000_000)
}
fn fill_random(&self, buf: &mut [u8]) {
for (i, b) in buf.iter_mut().enumerate() {
*b = (i % 251) as u8;
}
}
fn request(&self) -> Option<&RequestFacts> {
self.request.as_ref()
}
fn counter(&self, name: &str) -> u64 {
let mut c = self.counters.lock().unwrap();
let n = c.entry(name.to_string()).or_insert(0);
*n += 1;
*n
}
}
fn run(rows: &[(&str, &str)]) -> (HashMap<String, String>, Vec<GenError>) {
run_with(rows, HashMap::new())
}
fn run_sending(
request: RequestFacts,
rows: &[(&str, &str)],
) -> (HashMap<String, String>, Vec<GenError>) {
let rows: Vec<(String, String)> = rows
.iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect();
let mut vars = HashMap::new();
let errors = expand(
&rows,
&mut vars,
&FakeSource::at(1_700_000_000).sending(request),
);
(vars, errors)
}
fn facts() -> RequestFacts {
RequestFacts {
method: "post".to_string(),
url: "https://api.example.net/v2/orders?page=2&size=10#top".to_string(),
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("X-Trace".to_string(), "first".to_string()),
("X-Trace".to_string(), "second".to_string()),
],
body: r#"{"id":7}"#.to_string(),
name: "Create order".to_string(),
}
}
fn run_with(
rows: &[(&str, &str)],
mut vars: HashMap<String, String>,
) -> (HashMap<String, String>, Vec<GenError>) {
let rows: Vec<(String, String)> = rows
.iter()
.map(|(n, e)| (n.to_string(), e.to_string()))
.collect();
let errors = expand(&rows, &mut vars, &FakeSource::at(1_700_000_000));
(vars, errors)
}
#[test]
fn the_digests_match_their_published_vectors() {
let (v, e) = run(&[
("md5", r#"md5("abc")"#),
("sha1", r#"sha1("abc")"#),
("sha256", r#"sha256("abc")"#),
("sha512", r#"sha512("abc")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["md5"], "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(v["sha1"], "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(
v["sha256"],
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert_eq!(
v["sha512"],
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a\
2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"
);
}
#[test]
fn the_macs_match_their_published_vectors() {
let (v, e) = run(&[
("s1", r#"hmac_sha1("Jefe", "what do ya want for nothing?")"#),
(
"s256",
r#"hmac_sha256("Jefe", "what do ya want for nothing?")"#,
),
(
"s512",
r#"hmac_sha512("Jefe", "what do ya want for nothing?")"#,
),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["s1"], "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79");
assert_eq!(
v["s256"],
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
assert_eq!(
v["s512"],
"164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea250554\
9758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"
);
}
#[test]
fn the_b64_variants_encode_the_digest_not_its_hex() {
let (v, e) = run(&[
("hex", r#"sha256("abc")"#),
("b64", r#"sha256_b64("abc")"#),
("wrong", r#"base64(sha256("abc"))"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["b64"], "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=");
assert_ne!(v["b64"], b64(v["hex"].as_bytes(), false));
assert_eq!(
v["wrong"],
b64(v["hex"].as_bytes(), false),
"base64() of a hash still encodes the hex text — the reason the \
_b64 variants exist"
);
}
#[test]
fn the_url_safe_digests_use_the_alphabet_a_jwt_needs() {
let (v, e) = run(&[
("padded", r#"sha256_b64("abc")"#),
("safe", r#"sha256_b64url("abc")"#),
("mac", r#"hmac_sha256_b64url("key", "message")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["padded"], "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=");
assert_eq!(v["safe"], "ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0");
assert_eq!(
v["mac"],
encode_digest(
&hmac_bytes("sha256", b"key", b"message"),
DigestEncoding::B64Url
)
);
for name in ["safe", "mac"] {
assert!(
!v[name].contains(['+', '/', '=']),
"{name} produced {:?}, which cannot go in a URL or a JWT",
v[name]
);
}
}
#[test]
fn split_counts_from_either_end_and_refuses_to_guess() {
let (v, e) = run(&[
("first", r#"split("a/b/c", "/", 0)"#),
("last", r#"split("a/b/c", "/", -1)"#),
("but_one", r#"split("a/b/c", "/", -2)"#),
("token", r#"split("Bearer abc123", " ", 1)"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["first"], "a");
assert_eq!(v["last"], "c");
assert_eq!(v["but_one"], "b");
assert_eq!(v["token"], "abc123");
for expr in [
r#"split("a/b", "/", 5)"#,
r#"split("a/b", "/", -5)"#,
r#"split("a/b", "", 0)"#,
r#"split("a/b", "/", "last")"#,
] {
let (v, e) = run(&[("v", expr)]);
assert!(
matches!(e.as_slice(), [GenError::BadArgument { .. }]),
"{expr} gave {e:?}"
);
assert!(!v.contains_key("v"), "{expr} still set a value");
}
}
fn as_literal(json: &str) -> String {
format!("\"{}\"", json.replace('\\', "\\\\").replace('"', "\\\""))
}
#[test]
fn jsonpath_reaches_into_a_document_the_block_already_has() {
let doc = r#"{"a":{"b":"x"},"items":[{"id":7},{"id":8}],"odd key":"k",
"n":42,"ok":true,"sub":{"z":1}}"#;
let (v, e) = run(&[
("doc", &as_literal(doc)),
("field", r#"jsonpath(doc, "$.a.b")"#),
("element", r#"jsonpath(doc, "$.items[1].id")"#),
("bracketed", r#"jsonpath(doc, "$['odd key']")"#),
("number", r#"jsonpath(doc, "$.n")"#),
("boolean", r#"jsonpath(doc, "$.ok")"#),
("whole", r#"jsonpath(doc, "$.sub")"#),
("root", r#"jsonpath(doc, "$")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["field"], "x");
assert_eq!(v["element"], "8");
assert_eq!(v["bracketed"], "k");
assert_eq!(v["number"], "42");
assert_eq!(v["boolean"], "true");
assert_eq!(v["whole"], r#"{"z":1}"#);
assert!(v["root"].starts_with('{'));
}
#[test]
fn jsonpath_can_pick_an_element_out_of_a_list_by_one_of_its_fields() {
let doc = r#"{"CardInfo":[{"key":"full_name","value":"Ada"},
{"key":"dob","value":"1815-12-10"}]}"#;
let (v, e) = run(&[
("doc", &as_literal(doc)),
(
"name",
r#"jsonpath(doc, "$.CardInfo[?(@.key=='full_name')].value")"#,
),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["name"], "Ada");
}
#[test]
fn jsonpath_refuses_rather_than_answering_with_nothing() {
let doc = r#"{"a":{"b":"x"},"items":[1],"nothing":null}"#;
let (v, e) = run(&[
("doc", &as_literal(doc)),
("missing", r#"jsonpath(doc, "$.a.nope")"#),
("past_end", r#"jsonpath(doc, "$.items[3]")"#),
("into_scalar", r#"jsonpath(doc, "$.a.b.c")"#),
("null_value", r#"jsonpath(doc, "$.nothing")"#),
("no_dollar", r#"jsonpath(doc, "a.b")"#),
("not_json", r#"jsonpath("<html>", "$.a")"#),
("bad_index", r#"jsonpath(doc, "$.items[x]")"#),
]);
for name in [
"missing",
"past_end",
"into_scalar",
"null_value",
"no_dollar",
"not_json",
"bad_index",
] {
assert!(!v.contains_key(name), "{name} was given a value: {v:?}");
}
assert_eq!(e.len(), 7, "{e:?}");
assert!(
e.iter()
.all(|err| matches!(err, GenError::BadArgument { .. })),
"{e:?}"
);
}
#[test]
fn jsonpath_refuses_the_notation_it_does_not_share_with_hurl() {
let doc = r#"{"items":[{"id":1},{"id":2}]}"#;
for path in [
"$..id",
"$.items[*].id",
"$.items[0:1]",
"$.items[0,1]",
"$.*",
] {
let (v, e) = run(&[
("doc", &as_literal(doc)),
("v", &format!(r#"jsonpath(doc, "{path}")"#)),
]);
assert!(!v.contains_key("v"), "{path} was answered");
let detail = match e.as_slice() {
[GenError::BadArgument { detail, .. }] => detail.clone(),
other => panic!("{path} gave {other:?}"),
};
assert!(
detail.contains("[Captures]"),
"{path} should point at the row that can do it, said {detail:?}"
);
}
}
#[test]
fn regex_answers_with_the_capture_group_when_the_pattern_names_one() {
let (v, e) = run(&[
("whole", r#"regex("order-4711-x", "[0-9]+")"#),
("part", r#"regex("order-4711-x", "order-([0-9]+)")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["whole"], "4711");
assert_eq!(v["part"], "4711");
for expr in [
r#"regex("nothing here", "[0-9]+")"#,
r#"regex("text", "([")"#,
] {
let (_, e) = run(&[("v", expr)]);
assert!(
matches!(e.as_slice(), [GenError::BadArgument { .. }]),
"{expr} gave {e:?}"
);
}
}
#[test]
fn a_row_may_be_a_plain_literal() {
let (v, e) = run(&[("expected", r#""APPROVED""#), ("n", "3")]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["expected"], "APPROVED");
assert_eq!(v["n"], "3");
}
#[test]
fn a_placeholder_in_an_expression_is_refused_not_signed() {
for expr in [
"{{VAR}}",
r#""{{VAR}}""#,
r#"hmac_sha256("{{SECRET}}", "m")"#,
r#"concat("x-", "{{VAR}}")"#,
] {
let detail = parse(expr).expect_err(&format!("{expr} should not parse"));
assert!(
detail.contains("VAR") || detail.contains("SECRET"),
"{expr} said {detail:?}, which does not name the variable"
);
assert!(
detail.contains("not `{{"),
"{expr} said {detail:?}, which does not say what to write instead"
);
}
assert!(
parse(r#""{{oops""#)
.expect_err("unclosed braces")
.contains("named directly"),
);
}
#[test]
fn an_escaped_brace_is_a_brace() {
let (v, e) = run(&[("a", r#""\{{VAR}}""#)]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["a"], "{{VAR}}");
}
#[test]
fn a_block_reads_the_request_it_belongs_to() {
let (v, e) = run_sending(
facts(),
&[
("m", "method()"),
("u", "url()"),
("p", "path()"),
("q", "query()"),
("b", "body()"),
("n", "request_name()"),
("ct", r#"header("content-type")"#),
("trace", r#"header("X-Trace")"#),
("absent", r#"header("X-Nope")"#),
],
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["m"], "POST");
assert_eq!(
v["u"],
"https://api.example.net/v2/orders?page=2&size=10#top"
);
assert_eq!(v["p"], "/v2/orders");
assert_eq!(v["q"], "page=2&size=10");
assert_eq!(v["b"], r#"{"id":7}"#);
assert_eq!(v["n"], "Create order");
assert_eq!(v["ct"], "application/json");
assert_eq!(v["trace"], "first");
assert_eq!(v["absent"], "");
}
#[test]
fn a_templated_url_still_yields_the_part_that_is_written_down() {
let mut r = facts();
r.url = "{{base}}/v2/orders?page=2".to_string();
let (v, e) = run_sending(r, &[("p", "path()"), ("q", "query()")]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["p"], "/v2/orders");
assert_eq!(v["q"], "page=2");
let mut bare = facts();
bare.url = "{{base}}".to_string();
let (v, e) = run_sending(bare, &[("p", "path()"), ("q", "query()")]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["p"], "");
assert_eq!(v["q"], "");
}
#[test]
fn a_row_reading_the_request_sees_the_rows_above_it_filled_in() {
let mut r = facts();
r.body = r#"{"first":"{{a}}","second":"{{z}}"}"#.to_string();
let (v, e) = run_sending(
r,
&[
("a", r#"hex("41")"#),
("snapshot", "body()"),
("z", "hex(\"5A\")"),
],
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["snapshot"], r#"{"first":"3431","second":"{{z}}"}"#);
}
#[test]
fn a_request_function_with_no_request_behind_it_says_so() {
for expr in [
"method()",
"url()",
"path()",
"query()",
"body()",
"request_name()",
r#"header("Accept")"#,
] {
let (v, e) = run(&[("v", expr)]);
assert!(
matches!(e.as_slice(), [GenError::NoRequest { .. }]),
"{expr} gave {e:?}"
);
assert!(!v.contains_key("v"), "{expr} still set a value");
}
}
#[test]
fn the_request_a_block_reads_is_the_one_that_will_be_sent() {
let entry = crate::hurl::HurlEntry {
title: "Create order".to_string(),
method: "POST".to_string(),
url: "https://api.example.net/v2/orders".to_string(),
headers: vec![
crate::hurl::KvRow::new("Accept", "application/json"),
crate::hurl::KvRow::toggled("X-Debug", "1", false),
],
body_src: Some("{\n // the id the server assigns\n \"id\": 7\n}".to_string()),
..Default::default()
};
let f = RequestFacts::of(&entry);
assert_eq!(f.name, "Create order");
assert_eq!(f.method, "POST");
assert_eq!(
f.headers,
vec![("Accept".to_string(), "application/json".to_string())],
"a switched-off header is not sent, so it is not part of what is signed"
);
assert!(
!f.body.contains("//"),
"the block signed the editor's comments, not the bytes on the wire: {:?}",
f.body
);
assert!(f.body.contains("\"id\""), "{:?}", f.body);
}
#[test]
fn a_block_can_sign_the_values_it_just_computed() {
let mut vars = HashMap::new();
vars.insert("SECRET".to_string(), "s3cr3t".to_string());
let (v, e) = run_with(
&[
("nonce", "random_hex(16)"),
("stamp", "timestamp"),
(
"sig",
r#"hmac_sha256_b64(SECRET, concat(nonce, ":", stamp))"#,
),
],
vars,
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["stamp"], "1700000000");
let expected = encode_digest(
&hmac_bytes(
"sha256",
b"s3cr3t",
format!("{}:{}", v["nonce"], v["stamp"]).as_bytes(),
),
DigestEncoding::B64,
);
assert_eq!(
v["sig"], expected,
"the signature covers both computed rows"
);
}
#[test]
fn a_mac_with_the_wrong_number_of_arguments_is_refused() {
let (v, e) = run(&[("sig", r#"hmac_sha256("only-a-key")"#)]);
assert!(!v.contains_key("sig"), "nothing is bound");
assert!(
matches!(&e[..], [GenError::Arity { function, expected, got, .. }]
if function == "hmac_sha256" && expected == "2" && *got == 1),
"{e:?}"
);
}
#[test]
fn the_grammar_reads_calls_references_and_literals() {
assert_eq!(parse("uuid"), Ok(Expr::Reference("uuid".into())));
assert_eq!(
parse("timestamp(-30)"),
Ok(Expr::Call {
function: "timestamp".into(),
args: vec![Expr::Number("-30".into())]
})
);
assert_eq!(
parse(r#"concat("a", B)"#),
Ok(Expr::Call {
function: "concat".into(),
args: vec![Expr::Text("a".into()), Expr::Reference("B".into())]
})
);
assert_eq!(
parse(r#"base64(hmac_sha256(K, concat("GET\n", P)))"#),
Ok(Expr::Call {
function: "base64".into(),
args: vec![Expr::Call {
function: "hmac_sha256".into(),
args: vec![
Expr::Reference("K".into()),
Expr::Call {
function: "concat".into(),
args: vec![Expr::Text("GET\n".into()), Expr::Reference("P".into())]
}
]
}]
})
);
assert_eq!(
parse("uuid()"),
Ok(Expr::Call {
function: "uuid".into(),
args: vec![]
})
);
}
#[test]
fn trailing_rubbish_is_an_error_not_something_to_ignore() {
assert!(parse("uuid junk").is_err());
assert!(parse("timestamp() extra").is_err());
assert!(parse(r#"concat("a") "b""#).is_err());
}
#[test]
fn malformed_expressions_are_rejected_with_a_reason() {
for bad in [
"",
" ",
"concat(",
"concat(a",
r#"concat("a)"#,
"concat(a b)",
"-",
r#""\q""#,
] {
assert!(parse(bad).is_err(), "{bad:?} should not parse");
}
}
#[test]
fn a_string_carries_the_escapes_a_signing_string_needs() {
assert_eq!(parse(r#""a\nb""#), Ok(Expr::Text("a\nb".into())));
assert_eq!(parse(r#""a\"b""#), Ok(Expr::Text("a\"b".into())));
assert_eq!(parse(r#""a\\b""#), Ok(Expr::Text("a\\b".into())));
assert_eq!(parse(r#""a#b:c""#), Ok(Expr::Text("a#b:c".into())));
}
#[test]
fn every_offered_example_is_a_call_that_works() {
for f in FUNCTIONS {
for ex in f.examples {
assert!(
ex.starts_with(f.name),
"{ex:?} is offered under {} but does not call it",
f.name
);
let (vars, errors) = run(&[("v", ex)]);
assert!(errors.is_empty(), "{ex:?} did not run: {errors:?}");
assert!(
!vars["v"].is_empty(),
"{ex:?} ran but produced nothing at all"
);
assert!(
!vars["v"].contains('%'),
"{ex:?} produced {:?}, so part of the format was not understood",
vars["v"]
);
}
}
}
#[test]
fn time_functions_read_the_injected_clock() {
let (v, e) = run(&[
("a", "timestamp"),
("b", "timestamp(-30)"),
("c", "timestamp_ms"),
("d", "iso8601"),
("f", r#"date("%Y-%m-%d")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["a"], "1700000000");
assert_eq!(v["b"], "1699999970");
assert_eq!(v["c"], "1700000000123");
assert_eq!(v["d"], "2023-11-14T22:13:20Z");
assert_eq!(v["f"], "2023-11-14");
}
#[test]
fn encoding_functions_produce_the_expected_bytes() {
let (v, e) = run(&[
("a", r#"base64("hello")"#),
("b", r#"base64_decode("aGVsbG8=")"#),
("c", r#"hex("AB")"#),
("d", r#"urlencode("a b&c=d")"#),
("f", r#"urldecode("a%20b%26c")"#),
("g", r#"json_string("a\"b")"#),
("h", r#"base64url("~~~")"#),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["a"], "aGVsbG8=");
assert_eq!(v["b"], "hello");
assert_eq!(v["c"], "4142");
assert_eq!(v["d"], "a%20b%26c%3Dd");
assert_eq!(v["f"], "a b&c");
assert_eq!(v["g"], r#""a\"b""#);
assert_eq!(v["h"], "fn5-");
}
#[test]
fn text_functions_build_a_canonical_string() {
let (v, e) = run_with(
&[(
"s",
r#"concat(upper(METHOD), "\n", lower(HOST), "\n", trim(P))"#,
)],
HashMap::from([
("METHOD".to_string(), "get".to_string()),
("HOST".to_string(), "API.Example.COM".to_string()),
("P".to_string(), " /orders ".to_string()),
]),
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["s"], "GET\napi.example.com\n/orders");
}
#[test]
fn random_functions_respect_the_length_and_range_asked_for() {
let (v, e) = run(&[
("a", "random_hex(32)"),
("b", "random_hex(7)"),
("c", "random_alnum(12)"),
("d", "random_base64(16)"),
("f", "random_int(5, 5)"),
("g", "counter(\"page\")"),
("h", "counter(\"page\")"),
]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["a"].len(), 32);
assert_eq!(v["b"].len(), 7);
assert!(v["a"].chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(v["c"].len(), 12);
assert!(v["c"].chars().all(|c| c.is_ascii_alphanumeric()));
assert_eq!(v["d"].len(), 24, "16 bytes is 24 base64 characters");
assert_eq!(v["f"], "5");
assert_eq!((&v["g"], &v["h"]), (&"1".to_string(), &"2".to_string()));
}
#[test]
fn a_row_can_build_on_earlier_rows_and_on_the_environment() {
let (v, e) = run_with(
&[
("nonce", "random_hex(8)"),
("stamp", "timestamp"),
("payload", r#"concat(nonce, ":", stamp, ":", TENANT)"#),
("encoded", "base64(payload)"),
],
HashMap::from([("TENANT".to_string(), "acme".to_string())]),
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["payload"], format!("{}:1700000000:acme", v["nonce"]));
assert_eq!(v["encoded"], b64(v["payload"].as_bytes(), false));
}
#[test]
fn a_generator_is_evaluated_once_and_reused() {
let (v, e) = run(&[("n", "random_hex(16)"), ("copy", "n"), ("again", "n")]);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["n"], v["copy"]);
assert_eq!(v["n"], v["again"]);
}
#[test]
fn a_failed_row_binds_nothing_and_the_others_still_run() {
let (v, e) = run(&[
("good", r#"upper("a")"#),
("bad", "no_such_function(1)"),
("after", r#"lower("B")"#),
]);
assert_eq!(v.get("bad"), None, "a failed row must not bind a value");
assert_eq!(v["good"], "A");
assert_eq!(v["after"], "b", "a later row still runs");
assert_eq!(
e,
vec![GenError::UnknownFunction {
name: "bad".into(),
function: "no_such_function".into()
}]
);
}
#[test]
fn each_kind_of_mistake_names_the_row_it_is_in() {
let (_, e) = run_with(
&[
("a", "timestamp(1, 2)"),
("b", "random_hex(\"lots\")"),
("c", "MISSING"),
("d", "concat("),
],
HashMap::new(),
);
assert_eq!(e.len(), 4, "{e:?}");
assert_eq!(
e.iter().map(GenError::row).collect::<Vec<_>>(),
vec!["a", "b", "c", "d"]
);
assert!(matches!(e[0], GenError::Arity { .. }), "{:?}", e[0]);
assert!(matches!(e[1], GenError::BadArgument { .. }), "{:?}", e[1]);
assert_eq!(
e[2],
GenError::UndefinedReference {
name: "c".into(),
reference: "MISSING".into()
}
);
assert!(matches!(e[3], GenError::Syntax { .. }), "{:?}", e[3]);
}
#[test]
fn a_row_referring_to_itself_or_to_one_below_it_is_refused() {
let (v, e) = run_with(
&[("a", "concat(a)")],
HashMap::from([("a".to_string(), "from-the-environment".to_string())]),
);
assert_eq!(e, vec![GenError::Cycle { name: "a".into() }]);
assert_eq!(
v["a"], "from-the-environment",
"the environment's value is untouched, not overwritten"
);
let (_, e) = run(&[("first", "concat(second)"), ("second", "timestamp")]);
assert_eq!(
e,
vec![GenError::Cycle {
name: "first".into()
}]
);
}
#[test]
fn a_row_reading_a_failed_row_is_not_accused_of_a_cycle() {
let (_, e) = run(&[("a", "no_such_function()"), ("b", "concat(a)")]);
assert_eq!(
e,
vec![
GenError::UnknownFunction {
name: "a".into(),
function: "no_such_function".into()
},
GenError::FailedDependency {
name: "b".into(),
reference: "a".into()
}
],
"one mistake, and its consequence named as one"
);
}
#[test]
fn a_failed_row_does_not_fall_back_to_the_environment() {
let (v, e) = run_with(
&[("a", "no_such_function()"), ("b", "concat(a)")],
HashMap::from([("a".to_string(), "from-the-environment".to_string())]),
);
assert!(
matches!(e[1], GenError::FailedDependency { .. }),
"{:?}",
e[1]
);
assert!(!v.contains_key("b"), "and nothing is bound for b");
}
#[test]
fn a_generated_value_is_never_treated_as_a_template() {
let (v, e) = run_with(
&[("out", "concat(SECRET)")],
HashMap::from([("SECRET".to_string(), "{{OTHER}}".to_string())]),
);
assert!(e.is_empty(), "{e:?}");
assert_eq!(v["out"], "{{OTHER}}");
}
#[test]
fn every_function_is_called_the_way_the_table_says() {
let src = FakeSource::at(1_700_000_000);
let arg = |n: usize| vec!["1".to_string(); n];
let is_arity = |e: &GenError| matches!(e, GenError::Arity { .. });
for f in FUNCTIONS {
assert!(
f.signature.starts_with(f.name),
"{}'s signature must name it: {}",
f.name,
f.signature
);
let inside = f
.signature
.split_once('(')
.and_then(|(_, rest)| rest.strip_suffix(')'))
.unwrap_or("");
let written = inside.split(',').filter(|a| !a.trim().is_empty()).count();
let optional = inside.matches('[').count();
assert!(
written.saturating_sub(optional) >= f.min_args,
"{}: the editors write `{}`, which `call` refuses — it wants {} argument(s)",
f.name,
f.signature,
f.min_args
);
if f.min_args > 0 {
let e = call(f.name, &arg(f.min_args - 1), "row", &HashMap::new(), &src)
.expect_err(&format!("{} accepted too few arguments", f.name));
assert!(is_arity(&e), "{}: {e:?}", f.name);
}
if let Some(max) = f.max_args {
let e = call(f.name, &arg(max + 1), "row", &HashMap::new(), &src)
.expect_err(&format!("{} accepted too many arguments", f.name));
assert!(is_arity(&e), "{}: {e:?}", f.name);
}
if let Err(e) = call(f.name, &arg(f.min_args), "row", &HashMap::new(), &src) {
assert!(!is_arity(&e), "{} rejected its own arity: {e:?}", f.name);
}
}
}
#[test]
fn checking_a_block_finds_typos_but_not_missing_variables() {
let rows: Vec<(String, String)> = [
("a", "hmac_sha526(k, m)"),
("b", "random_int(1)"),
("c", "sha256("),
("d", "hmac_sha256(api_key, nothing_defines_this)"),
("e", "uuid"),
("", ""),
]
.iter()
.map(|(n, x)| (n.to_string(), x.to_string()))
.collect();
let found = check(&rows);
let named: Vec<&str> = found.iter().map(|e| e.row()).collect();
assert_eq!(
named,
vec!["a", "b", "c"],
"an unknown function, a wrong arity and a syntax error — no more: {found:?}"
);
}
#[test]
fn check_and_expand_agree_about_a_blank_row() {
let rows = vec![(String::new(), String::new())];
let found = check(&rows);
let mut vars = HashMap::new();
let raised = expand(&rows, &mut vars, &FakeSource::at(1_700_000_000));
assert_eq!(
found.len(),
raised.len(),
"check(): {found:?}\nexpand(): {raised:?}"
);
assert!(found.is_empty() && raised.is_empty());
}
#[test]
fn a_row_with_no_name_is_refused_by_both() {
let rows = vec![(String::new(), "uuid".to_string())];
let found = check(&rows);
assert_eq!(found, vec![GenError::NameMissing], "{found:?}");
let mut vars = HashMap::new();
let raised = expand(&rows, &mut vars, &FakeSource::at(0));
assert_eq!(found, raised, "the editor and the send say the same thing");
assert!(
vars.is_empty(),
"and nothing is computed under an empty key: {vars:?}"
);
let english = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
assert!(
crate::i18n::describe_gen_errors(&english, &found)[0].starts_with("A generated row"),
"{found:?}"
);
}
#[test]
fn a_name_hurl_cannot_carry_is_refused() {
let rows = vec![("my name".to_string(), "uuid".to_string())];
let found = check(&rows);
assert!(
matches!(found.as_slice(), [GenError::NameInvalid { name }] if name == "my name"),
"{found:?}"
);
let mut vars = HashMap::new();
assert_eq!(expand(&rows, &mut vars, &FakeSource::at(0)), found);
assert!(vars.is_empty(), "{vars:?}");
let fine = vec![("kunde-id_2".to_string(), "uuid".to_string())];
assert!(check(&fine).is_empty(), "{:?}", check(&fine));
}
#[test]
fn one_name_on_two_rows_is_reported_once() {
let rows: Vec<(String, String)> = [
("token", "\"first\""),
("other", "uuid"),
("token", "\"second\""),
("token", "\"third\""),
]
.iter()
.map(|(n, x)| (n.to_string(), x.to_string()))
.collect();
let found = check(&rows);
assert!(
matches!(found.as_slice(), [GenError::NameDuplicate { name }] if name == "token"),
"one sentence about one name, however many copies there are: {found:?}"
);
let mut vars = HashMap::new();
let raised = expand(&rows, &mut vars, &FakeSource::at(0));
assert_eq!(raised, found);
assert_eq!(
vars.get("token").map(String::as_str),
Some("first"),
"the row that read first is the one that stands: {vars:?}"
);
assert!(
vars.contains_key("other"),
"and the rows around it still evaluate: {vars:?}"
);
}
#[test]
fn a_named_row_with_no_expression_says_what_is_missing() {
let rows = vec![("token".to_string(), " ".to_string())];
let found = check(&rows);
assert!(
matches!(found.as_slice(), [GenError::Empty { name }] if name == "token"),
"{found:?}"
);
let mut vars = HashMap::new();
let raised = expand(&rows, &mut vars, &FakeSource::at(0));
assert_eq!(found, raised);
let english = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
assert_eq!(
crate::i18n::describe_gen_errors(&english, &found),
vec!["token: needs an expression".to_string()]
);
}
#[test]
fn deep_nesting_is_an_error_not_an_abort() {
let expr = format!("{}\"x\"{}", "concat(".repeat(4000), ")".repeat(4000));
let rows = vec![("a".to_string(), expr)];
let found = check(&rows);
assert!(
matches!(found.first(), Some(GenError::Syntax { .. })),
"deep nesting must be a syntax error: {found:?}"
);
let mut vars = HashMap::new();
let raised = expand(&rows, &mut vars, &FakeSource::at(0));
assert!(matches!(raised.first(), Some(GenError::Syntax { .. })));
}
#[test]
fn a_huge_timestamp_offset_is_an_error_not_a_panic() {
let rows = vec![("t".to_string(), format!("timestamp({})", i64::MAX))];
let mut vars = HashMap::new();
let errors = expand(&rows, &mut vars, &FakeSource::at(1_700_000_000));
assert!(
matches!(errors.first(), Some(GenError::BadArgument { .. })),
"{errors:?}"
);
}
#[test]
fn an_error_message_never_quotes_a_secrets_value() {
let rows = vec![("n".to_string(), "random_hex(API_SECRET)".to_string())];
let mut vars = HashMap::new();
vars.insert("API_SECRET".to_string(), "hunter2-the-real-key".to_string());
let errors = expand(&rows, &mut vars, &FakeSource::at(0));
let english = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
let said = crate::i18n::describe_gen_errors(&english, &errors).join("; ");
assert!(
!said.contains("hunter2-the-real-key"),
"the message quotes the secret it was given: {said}"
);
}
#[test]
fn every_signature_in_the_table_is_a_call_that_works() {
for f in FUNCTIONS {
let inside = f
.signature
.split_once('(')
.and_then(|(_, rest)| rest.strip_suffix(')'))
.unwrap_or("");
let written = inside.split(',').filter(|a| !a.trim().is_empty()).count();
let optional = inside.matches('[').count();
assert!(
written.saturating_sub(optional) >= f.min_args,
"{}: the editors write `{}`, which `call` refuses — it wants {} argument(s)",
f.name,
f.signature,
f.min_args
);
}
}
}