| 1 | //! `{{variable}}` substitution for paths, params, headers and bodies. |
| 2 | //! |
| 3 | //! Two kinds of variable resolve here: |
| 4 | //! |
| 5 | //! - **Collection variables** — looked up by (trimmed) name in the Variables tab. |
| 6 | //! - **Dynamic variables** — computed at send time: `{{uuid}}`, `{{timestamp}}`, |
| 7 | //! `{{randomInt(1,100)}}` … see [`DYNAMIC_VARS`]. |
| 8 | //! |
| 9 | //! A collection variable shadows a dynamic one of the same name, so `uuid` can |
| 10 | //! be pinned to a fixed value for a debugging session. Prefixing with `$` |
| 11 | //! (`{{$uuid}}`, Postman's spelling) always takes the dynamic one. |
| 12 | //! |
| 13 | //! Unknown variables are left untouched so the user can see what failed to |
| 14 | //! resolve. |
| 15 | |
| 16 | use std::collections::HashMap; |
| 17 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 18 | |
| 19 | use uuid::Uuid; |
| 20 | |
| 21 | /// Dynamic variable names and their help text, in the order the help popup |
| 22 | /// lists them. Names are matched case-insensitively, ignoring `_`, so |
| 23 | /// `isoTimestamp`, `iso_timestamp` and `ISOTIMESTAMP` are the same variable. |
| 24 | pub const DYNAMIC_VARS: [(&str, &str); 8] = [ |
| 25 | ("uuid", "UUID v4, fresh per occurrence"), |
| 26 | ("timestamp", "Unix time in seconds"), |
| 27 | ("timestampMs", "Unix time in milliseconds"), |
| 28 | ("isoTimestamp", "RFC 3339 UTC, e.g. 2026-08-06T12:34:56Z"), |
| 29 | ("randomInt", "0–1000, or randomInt(min,max) inclusive"), |
| 30 | ("randomHex", "16 hex chars, or randomHex(n)"), |
| 31 | ("randomString", "16 alphanumerics, or randomString(n)"), |
| 32 | ("randomBool", "true or false"), |
| 33 | ]; |
| 34 | |
| 35 | pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String { |
| 36 | let mut out = String::with_capacity(input.len()); |
| 37 | let mut rest = input; |
| 38 | while let Some(start) = rest.find("{{") { |
| 39 | out.push_str(&rest[..start]); |
| 40 | let after = &rest[start + 2..]; |
| 41 | match after.find("}}") { |
| 42 | Some(end) => { |
| 43 | let key = after[..end].trim(); |
| 44 | match resolve(key, vars) { |
| 45 | Some(v) => out.push_str(&v), |
| 46 | // Unknown variable: keep the placeholder as-is. |
| 47 | None => out.push_str(&rest[..start + 2 + end + 2]), |
| 48 | } |
| 49 | rest = &after[end + 2..]; |
| 50 | } |
| 51 | None => { |
| 52 | out.push_str(&rest[start..]); |
| 53 | rest = ""; |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | out.push_str(rest); |
| 58 | out |
| 59 | } |
| 60 | |
| 61 | /// A `$` prefix forces the dynamic variable; otherwise the collection wins. |
| 62 | fn resolve(key: &str, vars: &HashMap<String, String>) -> Option<String> { |
| 63 | match key.strip_prefix('$') { |
| 64 | Some(name) => dynamic(name.trim()), |
| 65 | None => vars.get(key).cloned().or_else(|| dynamic(key)), |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// Evaluate a dynamic variable, with optional `name(arg,arg)` arguments. |
| 70 | /// Returns `None` for an unknown name or unusable arguments — the caller then |
| 71 | /// leaves the placeholder visible rather than silently emitting junk. |
| 72 | fn dynamic(spec: &str) -> Option<String> { |
| 73 | let (name, args) = split_call(spec)?; |
| 74 | let name = normalize(&name); |
| 75 | Some(match (name.as_str(), args.as_slice()) { |
| 76 | ("uuid", []) => Uuid::new_v4().to_string(), |
| 77 | ("timestamp", []) => unix_secs().to_string(), |
| 78 | ("timestampms", []) => unix_millis().to_string(), |
| 79 | ("isotimestamp", []) => iso_timestamp(unix_secs()), |
| 80 | ("randomint", []) => random_int(0, 1000).to_string(), |
| 81 | ("randomint", [min, max]) => { |
| 82 | let (min, max) = (min.parse::<i64>().ok()?, max.parse::<i64>().ok()?); |
| 83 | if min > max { |
| 84 | return None; |
| 85 | } |
| 86 | random_int(min, max).to_string() |
| 87 | } |
| 88 | ("randomhex", []) => random_hex(16), |
| 89 | ("randomhex", [n]) => random_hex(parse_len(n)?), |
| 90 | ("randomstring", []) => random_string(16), |
| 91 | ("randomstring", [n]) => random_string(parse_len(n)?), |
| 92 | ("randombool", []) => (random_int(0, 1) == 1).to_string(), |
| 93 | _ => return None, |
| 94 | }) |
| 95 | } |
| 96 | |
| 97 | /// `name` or `name(a, b)` → `("name", ["a", "b"])`. Empty args are rejected so |
| 98 | /// `randomHex()` doesn't quietly mean `randomHex`. |
| 99 | fn split_call(spec: &str) -> Option<(String, Vec<String>)> { |
| 100 | let Some(open) = spec.find('(') else { |
| 101 | return Some((spec.to_string(), Vec::new())); |
| 102 | }; |
| 103 | let inner = spec.strip_suffix(')')?.get(open + 1..)?; |
| 104 | let args: Vec<String> = inner.split(',').map(|a| a.trim().to_string()).collect(); |
| 105 | if args.iter().any(|a| a.is_empty()) { |
| 106 | return None; |
| 107 | } |
| 108 | Some((spec[..open].trim().to_string(), args)) |
| 109 | } |
| 110 | |
| 111 | fn normalize(name: &str) -> String { |
| 112 | name.chars() |
| 113 | .filter(|c| *c != '_') |
| 114 | .flat_map(char::to_lowercase) |
| 115 | .collect() |
| 116 | } |
| 117 | |
| 118 | /// Length arguments are capped: a stray `randomString(999999999)` shouldn't |
| 119 | /// build a gigabyte of request body. |
| 120 | fn parse_len(s: &str) -> Option<usize> { |
| 121 | let n = s.parse::<usize>().ok()?; |
| 122 | (1..=4096).contains(&n).then_some(n) |
| 123 | } |
| 124 | |
| 125 | // ----- clock ----- |
| 126 | |
| 127 | fn unix_millis() -> u128 { |
| 128 | SystemTime::now() |
| 129 | .duration_since(UNIX_EPOCH) |
| 130 | .map(|d| d.as_millis()) |
| 131 | .unwrap_or(0) |
| 132 | } |
| 133 | |
| 134 | fn unix_secs() -> i64 { |
| 135 | (unix_millis() / 1000) as i64 |
| 136 | } |
| 137 | |
| 138 | /// RFC 3339 in UTC. Hand-rolled rather than pulling in a date crate: the only |
| 139 | /// calendar work cielago does is stamping a request. |
| 140 | fn iso_timestamp(secs: i64) -> String { |
| 141 | let days = secs.div_euclid(86_400); |
| 142 | let time = secs.rem_euclid(86_400); |
| 143 | let (y, m, d) = civil_from_days(days); |
| 144 | let (h, min, s) = (time / 3600, (time % 3600) / 60, time % 60); |
| 145 | format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}Z") |
| 146 | } |
| 147 | |
| 148 | /// Days since 1970-01-01 → (year, month, day). Hinnant's civil-from-days. |
| 149 | fn civil_from_days(z: i64) -> (i64, u32, u32) { |
| 150 | let z = z + 719_468; |
| 151 | let era = z.div_euclid(146_097); |
| 152 | let doe = z.rem_euclid(146_097); // [0, 146096] |
| 153 | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] |
| 154 | let y = yoe + era * 400; |
| 155 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] |
| 156 | let mp = (5 * doy + 2) / 153; // [0, 11], March-based |
| 157 | let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] |
| 158 | let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] |
| 159 | (if m <= 2 { y + 1 } else { y }, m, d) |
| 160 | } |
| 161 | |
| 162 | // ----- randomness ----- |
| 163 | |
| 164 | /// Random bytes borrowed from UUID v4 generation, so no extra RNG dependency. |
| 165 | /// Bytes 6 and 8 carry the version/variant bits and are dropped. |
| 166 | fn random_bytes(n: usize) -> Vec<u8> { |
| 167 | let mut out = Vec::with_capacity(n + 14); |
| 168 | while out.len() < n { |
| 169 | let id = *Uuid::new_v4().as_bytes(); |
| 170 | out.extend( |
| 171 | id.iter() |
| 172 | .enumerate() |
| 173 | .filter(|(i, _)| *i != 6 && *i != 8) |
| 174 | .map(|(_, b)| *b), |
| 175 | ); |
| 176 | } |
| 177 | out.truncate(n); |
| 178 | out |
| 179 | } |
| 180 | |
| 181 | fn random_u64() -> u64 { |
| 182 | let b = random_bytes(8); |
| 183 | u64::from_le_bytes(b.try_into().expect("8 bytes requested")) |
| 184 | } |
| 185 | |
| 186 | /// Uniform-ish over `[min, max]`; the modulo bias is irrelevant for test data. |
| 187 | /// Widened to i128 so a full-range `randomInt(i64::MIN, i64::MAX)` can't wrap. |
| 188 | fn random_int(min: i64, max: i64) -> i64 { |
| 189 | let span = (max as i128 - min as i128 + 1) as u128; |
| 190 | (min as i128 + (random_u64() as u128 % span) as i128) as i64 |
| 191 | } |
| 192 | |
| 193 | fn random_hex(n: usize) -> String { |
| 194 | random_bytes(n.div_ceil(2)) |
| 195 | .iter() |
| 196 | .map(|b| format!("{b:02x}")) |
| 197 | .collect::<String>() |
| 198 | .chars() |
| 199 | .take(n) |
| 200 | .collect() |
| 201 | } |
| 202 | |
| 203 | fn random_string(n: usize) -> String { |
| 204 | const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; |
| 205 | random_bytes(n) |
| 206 | .iter() |
| 207 | .map(|b| ALPHABET[*b as usize % ALPHABET.len()] as char) |
| 208 | .collect() |
| 209 | } |
| 210 | |
| 211 | #[cfg(test)] |
| 212 | mod tests { |
| 213 | use super::*; |
| 214 | |
| 215 | fn vars() -> HashMap<String, String> { |
| 216 | HashMap::from([ |
| 217 | ("tenant".to_string(), "acme".to_string()), |
| 218 | ("version".to_string(), "v2".to_string()), |
| 219 | ]) |
| 220 | } |
| 221 | |
| 222 | #[test] |
| 223 | fn substitutes_named_vars() { |
| 224 | assert_eq!( |
| 225 | substitute("/{{tenant}}/{{ version }}/x", &vars()), |
| 226 | "/acme/v2/x" |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn uuid_is_fresh_per_occurrence() { |
| 232 | let out = substitute("{{uuid}}-{{uuid}}", &vars()); |
| 233 | assert_eq!(out.len(), 36 + 1 + 36); |
| 234 | let (first, rest) = out.split_at(36); |
| 235 | let second = &rest[1..]; |
| 236 | assert_eq!(&rest[..1], "-"); |
| 237 | assert!(uuid::Uuid::parse_str(first).is_ok()); |
| 238 | assert!(uuid::Uuid::parse_str(second).is_ok()); |
| 239 | assert_ne!(first, second); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn unknown_vars_left_intact() { |
| 244 | assert_eq!(substitute("{{nope}}", &vars()), "{{nope}}"); |
| 245 | assert_eq!(substitute("{{$nope}}", &vars()), "{{$nope}}"); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn unclosed_brace_left_intact() { |
| 250 | assert_eq!(substitute("a {{oops", &vars()), "a {{oops"); |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn collection_var_shadows_dynamic_unless_dollar_prefixed() { |
| 255 | let vars = HashMap::from([("uuid".to_string(), "pinned".to_string())]); |
| 256 | assert_eq!(substitute("{{uuid}}", &vars), "pinned"); |
| 257 | assert_eq!(substitute("{{$uuid}}", &vars).len(), 36); |
| 258 | } |
| 259 | |
| 260 | #[test] |
| 261 | fn timestamps_are_plausible() { |
| 262 | let secs: i64 = substitute("{{timestamp}}", &vars()).parse().unwrap(); |
| 263 | // Somewhere after 2020 and before 2100. |
| 264 | assert!((1_577_836_800..4_102_444_800).contains(&secs)); |
| 265 | let ms: i64 = substitute("{{timestampMs}}", &vars()).parse().unwrap(); |
| 266 | assert_eq!(ms / 1000, secs); |
| 267 | |
| 268 | let iso = substitute("{{isoTimestamp}}", &vars()); |
| 269 | assert_eq!(iso.len(), 20, "{iso}"); |
| 270 | assert!(iso.ends_with('Z')); |
| 271 | assert_eq!(&iso[4..5], "-"); |
| 272 | assert_eq!(&iso[10..11], "T"); |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn iso_timestamp_matches_known_instants() { |
| 277 | assert_eq!(iso_timestamp(0), "1970-01-01T00:00:00Z"); |
| 278 | assert_eq!(iso_timestamp(1_000_000_000), "2001-09-09T01:46:40Z"); |
| 279 | // Leap day. |
| 280 | assert_eq!(iso_timestamp(1_709_164_800), "2024-02-29T00:00:00Z"); |
| 281 | assert_eq!(iso_timestamp(1_754_484_896), "2025-08-06T12:54:56Z"); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn name_matching_ignores_case_and_underscores() { |
| 286 | for name in ["isoTimestamp", "iso_timestamp", "ISO_TIMESTAMP"] { |
| 287 | assert!( |
| 288 | substitute(&format!("{{{{{name}}}}}"), &vars()).ends_with('Z'), |
| 289 | "{name}" |
| 290 | ); |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | #[test] |
| 295 | fn random_int_respects_bounds() { |
| 296 | for _ in 0..200 { |
| 297 | let n: i64 = substitute("{{randomInt(5, 7)}}", &vars()).parse().unwrap(); |
| 298 | assert!((5..=7).contains(&n), "{n}"); |
| 299 | } |
| 300 | assert_eq!(substitute("{{randomInt(-1,-1)}}", &vars()), "-1"); |
| 301 | let d: i64 = substitute("{{randomInt}}", &vars()).parse().unwrap(); |
| 302 | assert!((0..=1000).contains(&d)); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn random_strings_have_requested_length() { |
| 307 | assert_eq!(substitute("{{randomHex}}", &vars()).len(), 16); |
| 308 | assert_eq!(substitute("{{randomHex(7)}}", &vars()).len(), 7); |
| 309 | assert_eq!(substitute("{{randomString}}", &vars()).len(), 16); |
| 310 | assert_eq!(substitute("{{randomString(40)}}", &vars()).len(), 40); |
| 311 | assert!( |
| 312 | substitute("{{randomHex(9)}}", &vars()) |
| 313 | .chars() |
| 314 | .all(|c| c.is_ascii_hexdigit()) |
| 315 | ); |
| 316 | assert!( |
| 317 | substitute("{{randomString(64)}}", &vars()) |
| 318 | .chars() |
| 319 | .all(|c| c.is_ascii_alphanumeric()) |
| 320 | ); |
| 321 | } |
| 322 | |
| 323 | #[test] |
| 324 | fn random_bool_is_a_bool_and_varies() { |
| 325 | let mut seen = std::collections::HashSet::new(); |
| 326 | for _ in 0..100 { |
| 327 | let v = substitute("{{randomBool}}", &vars()); |
| 328 | assert!(v == "true" || v == "false", "{v}"); |
| 329 | seen.insert(v); |
| 330 | } |
| 331 | assert_eq!(seen.len(), 2, "randomBool never flipped"); |
| 332 | } |
| 333 | |
| 334 | #[test] |
| 335 | fn bad_arguments_leave_the_placeholder() { |
| 336 | for bad in [ |
| 337 | "{{randomInt(9,1)}}", |
| 338 | "{{randomInt(a,b)}}", |
| 339 | "{{randomInt(1)}}", |
| 340 | "{{randomHex(0)}}", |
| 341 | "{{randomHex(99999)}}", |
| 342 | "{{randomString()}}", |
| 343 | "{{uuid(2)}}", |
| 344 | ] { |
| 345 | assert_eq!(substitute(bad, &vars()), bad); |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | #[test] |
| 350 | fn every_documented_dynamic_var_resolves() { |
| 351 | for (name, _) in DYNAMIC_VARS { |
| 352 | let out = substitute(&format!("{{{{${name}}}}}"), &vars()); |
| 353 | assert!(!out.contains("{{"), "{name} did not resolve: {out}"); |
| 354 | } |
| 355 | } |
| 356 | } |