From eed144d386fb80a2defd896f384f54f74590185d Mon Sep 17 00:00:00 2001 From: cbaugus Date: Thu, 24 Jul 2025 12:39:16 -0500 Subject: [PATCH] 1. Added escape sequence support: Modified the header parsing logic to handle escaped commas (\,) in header values 2. Created a new parsing function: parse_headers_with_escapes() that properly handles the escape sequences 3. Added comprehensive tests: Created 7 test cases covering various scenarios including: - Simple headers without commas - Headers with escaped commas (like Keep-Alive) - Multiple escaped commas in a single value - Backslashes that aren't escape sequences - Edge cases with whitespace and trailing commas 4. Updated documentation: Added clear examples in the README showing how to use the escape sequence feature The solution allows users to include commas in header values by escaping them with a backslash. For example: -e CUSTOM_HEADERS="Connection:keep-alive,Keep-Alive:timeout=5\\,max=200" This will correctly parse as two headers: - Connection: keep-alive - Keep-Alive: timeout=5,max=200 --- README.md | 33 +++++++++++++ src/main.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ceecb70..8b90e3f 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,39 @@ cbaugus/rust-loadtester:latest This will send the specified Authorization and X-Api-Key headers with every request made by the load tester. +#### Escaping commas in header values + +If your header values contain commas (e.g., Keep-Alive headers), you need to escape them with a backslash (`\,`). The escaped comma will be included in the header value as a literal comma. + +**Example with Keep-Alive headers:** + +```bash +docker run --rm \\ +-e TARGET_URL="http://your-target.com/api" \\ +-e CUSTOM_HEADERS="Connection:keep-alive,Keep-Alive:timeout=5\\,max=200" \\ +# ... other environment variables ... +cbaugus/rust-loadtester:latest +``` + +This will send: +- `Connection: keep-alive` +- `Keep-Alive: timeout=5,max=200` + +**Other examples of escaped commas:** + +```bash +# Multiple comma-separated values in Accept header +-e CUSTOM_HEADERS="Accept:text/html\\,application/xml\\,application/json" + +# Complex Keep-Alive with multiple parameters +-e CUSTOM_HEADERS="Keep-Alive:timeout=5\\,max=1000\\,custom=value" + +# Multiple headers with some containing commas +-e CUSTOM_HEADERS="Accept:text/html\\,application/json,User-Agent:MyApp/1.0,Cache-Control:no-cache\\,no-store" +``` + +**Note:** Only commas that are part of header values need to be escaped. Commas that separate different headers should not be escaped. + **Important Note on Private Key Format:** If your private key is not in PKCS#8 format (e.g., it's a traditional PKCS#1 RSA key), you'll need to convert it. You can do this using OpenSSL: diff --git a/src/main.rs b/src/main.rs index ae066de..5b3d14f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,6 +204,47 @@ fn parse_duration_string(s: &str) -> Result { } // --- END Function to parse the duration string --- +// --- Function to parse headers with escape support --- +fn parse_headers_with_escapes(headers_str: &str) -> Vec { + let mut headers = Vec::new(); + let mut current_header = String::new(); + let mut chars = headers_str.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\\' => { + // Check if the next character is a comma + if chars.peek() == Some(&',') { + // This is an escaped comma, add it to the current header + current_header.push(','); + chars.next(); // Consume the comma + } else { + // Not escaping a comma, keep the backslash + current_header.push('\\'); + } + } + ',' => { + // This is a header separator + if !current_header.trim().is_empty() { + headers.push(current_header.clone()); + } + current_header.clear(); + } + _ => { + current_header.push(ch); + } + } + } + + // Don't forget the last header + if !current_header.trim().is_empty() { + headers.push(current_header); + } + + headers +} +// --- END Function to parse headers with escape support --- + #[tokio::main] async fn main() -> Result<(), Box> { @@ -347,10 +388,14 @@ async fn main() -> Result<(), Box> { if !custom_headers_str.is_empty() { println!("Attempting to parse CUSTOM_HEADERS: {}", custom_headers_str); - for header_pair_str in custom_headers_str.split(',') { + + // Parse headers with support for escaped commas + let header_pairs = parse_headers_with_escapes(&custom_headers_str); + + for header_pair_str in header_pairs { let header_pair_str_trimmed = header_pair_str.trim(); if header_pair_str_trimmed.is_empty() { - continue; // Skip empty parts (e.g., due to trailing comma or multiple commas) + continue; // Skip empty parts } let parts: Vec<&str> = header_pair_str_trimmed.splitn(2, ':').collect(); if parts.len() == 2 { @@ -361,10 +406,13 @@ async fn main() -> Result<(), Box> { return Err(format!("Invalid header format: Header name cannot be empty in '{}'.", header_pair_str_trimmed).into()); } + // Unescape the header value (replace \, with ,) + let unescaped_value = value_str.replace("\\,", ","); + let header_name = HeaderName::from_str(name_str) .map_err(|e| format!("Invalid header name: {}. Name: '{}'", e, name_str))?; - let header_value = HeaderValue::from_str(value_str) - .map_err(|e| format!("Invalid header value for '{}': {}. Value: '{}'", name_str, e, value_str))?; + let header_value = HeaderValue::from_str(&unescaped_value) + .map_err(|e| format!("Invalid header value for '{}': {}. Value: '{}'", name_str, e, unescaped_value))?; parsed_headers.insert(header_name, header_value); } else { return Err(format!("Invalid header format in CUSTOM_HEADERS: '{}'. Expected 'Name:Value'.", header_pair_str_trimmed).into()); @@ -669,3 +717,78 @@ async fn metrics_handler( Ok(response) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_headers_simple() { + let headers_str = "Content-Type:application/json,Authorization:Bearer token"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Content-Type:application/json"); + assert_eq!(result[1], "Authorization:Bearer token"); + } + + #[test] + fn test_parse_headers_with_escaped_comma() { + let headers_str = "Connection:keep-alive,Keep-Alive:timeout=5\\,max=200"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Connection:keep-alive"); + assert_eq!(result[1], "Keep-Alive:timeout=5,max=200"); + } + + #[test] + fn test_parse_headers_multiple_escaped_commas() { + let headers_str = "Accept:text/html\\,application/xml\\,application/json,User-Agent:Mozilla/5.0"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Accept:text/html,application/xml,application/json"); + assert_eq!(result[1], "User-Agent:Mozilla/5.0"); + } + + #[test] + fn test_parse_headers_backslash_not_before_comma() { + let headers_str = "Path:C:\\Users\\test,Host:example.com"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Path:C:\\Users\\test"); + assert_eq!(result[1], "Host:example.com"); + } + + #[test] + fn test_parse_headers_empty_and_whitespace() { + let headers_str = " Header1:value1 , , Header2:value2 "; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], " Header1:value1 "); + assert_eq!(result[1], " Header2:value2 "); + } + + #[test] + fn test_parse_headers_trailing_comma() { + let headers_str = "Header1:value1,Header2:value2,"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Header1:value1"); + assert_eq!(result[1], "Header2:value2"); + } + + #[test] + fn test_parse_headers_complex_keep_alive() { + let headers_str = "Connection:keep-alive\\,close,Keep-Alive:timeout=5\\,max=1000\\,custom=value"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Connection:keep-alive,close"); + assert_eq!(result[1], "Keep-Alive:timeout=5,max=1000,custom=value"); + } +}