diff --git a/Cargo.lock b/Cargo.lock index 42719d5..492dfa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -1872,6 +1887,12 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -2849,6 +2870,25 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -2878,6 +2918,12 @@ version = "2.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -2891,7 +2937,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2928,9 +2974,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3013,6 +3059,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rayon" version = "1.11.0" @@ -3184,6 +3239,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -3262,6 +3330,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3589,12 +3669,14 @@ dependencies = [ "pingora-http", "pingora-proxy", "prometheus", + "proptest", "regex", "reqwest", "rustc-hash", "rustls", "serde", "serde_json", + "tempfile", "tokio", "toml", "tracing", @@ -3651,6 +3733,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4121,6 +4216,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.9.0" @@ -4198,6 +4299,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3d01e48..573e76e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,8 @@ libc = "0.2" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +proptest = "1" +tempfile = "3" [[bench]] name = "scanner_bench" diff --git a/tests/proptest.rs b/tests/proptest.rs new file mode 100644 index 0000000..97af8ca --- /dev/null +++ b/tests/proptest.rs @@ -0,0 +1,879 @@ +use bytes::Bytes; +use proptest::prelude::*; +use std::collections::HashSet; +use sunbeam_proxy::config::{ + BodyRewrite, HeaderRule, PathRoute, RewriteRule, RouteConfig, TelemetryConfig, +}; +use sunbeam_proxy::proxy::{backend_addr, SunbeamProxy}; +use sunbeam_proxy::static_files::{cache_control_for, content_type_for}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +fn make_route( + host_prefix: &str, + backend: &str, + rewrites: Vec, + body_rewrites: Vec, + response_headers: Vec, +) -> RouteConfig { + RouteConfig { + host_prefix: host_prefix.into(), + backend: backend.into(), + websocket: false, + disable_secure_redirection: false, + paths: vec![], + static_root: None, + fallback: None, + rewrites, + body_rewrites, + response_headers, + cache: None, + } +} + +// ─── Strategies ────────────────────────────────────────────────────────────── + +fn extension_strategy() -> impl Strategy { + prop_oneof![ + // Known extensions + Just("html".into()), + Just("css".into()), + Just("js".into()), + Just("json".into()), + Just("svg".into()), + Just("png".into()), + Just("jpg".into()), + Just("jpeg".into()), + Just("gif".into()), + Just("ico".into()), + Just("webp".into()), + Just("avif".into()), + Just("woff".into()), + Just("woff2".into()), + Just("ttf".into()), + Just("otf".into()), + Just("eot".into()), + Just("xml".into()), + Just("txt".into()), + Just("map".into()), + Just("webmanifest".into()), + Just("mp4".into()), + Just("wasm".into()), + Just("pdf".into()), + Just("mjs".into()), + Just("htm".into()), + // Random unknown extensions + "[a-z]{1,10}", + // Empty + Just("".into()), + ] +} + +fn backend_url_strategy() -> impl Strategy { + prop_oneof![ + "http://[a-z]{1,15}\\.[a-z]{1,10}:[0-9]{2,5}", + "https://[a-z]{1,15}\\.[a-z]{1,10}:[0-9]{2,5}", + "http://127\\.0\\.0\\.1:[0-9]{2,5}", + "https://10\\.0\\.[0-9]{1,3}\\.[0-9]{1,3}:[0-9]{2,5}", + "[a-z]{1,10}://[a-z.]{1,20}:[0-9]{2,5}", + Just("http://localhost:8080".into()), + // No scheme at all + "[a-z.]{1,20}:[0-9]{2,5}", + ] +} + +fn find_replace_strategy() -> impl Strategy { + ( + "[a-zA-Z0-9./_-]{1,50}", + "[a-zA-Z0-9./_-]{0,50}", + ) +} + +fn body_content_strategy() -> impl Strategy { + prop_oneof![ + // HTML-like content + "[a-zA-Z0-9 <>/=.\"']{0,200}", + // JS-like content + "var [a-z]+ = \"[a-zA-Z0-9./_:-]{0,100}\";", + // Minimal + "[a-zA-Z0-9 <>/=\"'._-]{0,500}", + // Empty + Just("".into()), + ] +} + +// ─── content_type_for ──────────────────────────────────────────────────────── + +proptest! { + /// content_type_for never panics for any extension string. + #[test] + fn content_type_never_panics(ext in "[a-zA-Z0-9._]{0,20}") { + let ct = content_type_for(&ext); + prop_assert!(!ct.is_empty()); + } + + /// Known extensions always map to the right MIME category. + #[test] + fn content_type_known_extensions_correct(ext in extension_strategy()) { + let ct = content_type_for(&ext); + match ext.as_str() { + "html" | "htm" => prop_assert!(ct.starts_with("text/html")), + "css" => prop_assert!(ct.starts_with("text/css")), + "js" | "mjs" => prop_assert!(ct.starts_with("application/javascript")), + "json" | "map" => prop_assert!(ct.starts_with("application/json")), + "svg" => prop_assert!(ct.starts_with("image/svg")), + "png" => prop_assert_eq!(ct, "image/png"), + "jpg" | "jpeg" => prop_assert_eq!(ct, "image/jpeg"), + "gif" => prop_assert_eq!(ct, "image/gif"), + "woff2" => prop_assert_eq!(ct, "font/woff2"), + "wasm" => prop_assert_eq!(ct, "application/wasm"), + "pdf" => prop_assert_eq!(ct, "application/pdf"), + _ => { /* unknown extensions get octet-stream, that's fine */ } + } + } + + /// The return value always contains a `/` (valid MIME type format). + #[test] + fn content_type_always_valid_mime(ext in "\\PC{0,30}") { + let ct = content_type_for(&ext); + // All MIME types must have a slash separating type/subtype. + prop_assert!(ct.contains('/'), "MIME type missing /: {ct}"); + } +} + +// ─── cache_control_for ─────────────────────────────────────────────────────── + +proptest! { + /// cache_control_for never panics and returns non-empty. + #[test] + fn cache_control_never_panics(ext in "[a-zA-Z0-9._]{0,20}") { + let cc = cache_control_for(&ext); + prop_assert!(!cc.is_empty()); + } + + /// Hashed-asset extensions always get immutable cache headers. + #[test] + fn cache_control_immutable_for_assets( + ext in prop_oneof![ + Just("js"), Just("mjs"), Just("css"), + Just("woff"), Just("woff2"), Just("ttf"), + Just("otf"), Just("eot"), Just("wasm"), + ] + ) { + let cc = cache_control_for(ext); + prop_assert!(cc.contains("immutable"), "expected immutable for .{ext}: {cc}"); + prop_assert!(cc.contains("31536000"), "expected 1-year max-age for .{ext}: {cc}"); + } + + /// Image extensions get 1-day cache. + #[test] + fn cache_control_day_for_images( + ext in prop_oneof![ + Just("png"), Just("jpg"), Just("jpeg"), Just("gif"), + Just("webp"), Just("avif"), Just("svg"), Just("ico"), + ] + ) { + let cc = cache_control_for(ext); + prop_assert!(cc.contains("86400"), "expected 1-day max-age for .{ext}: {cc}"); + prop_assert!(!cc.contains("immutable"), "images should not be immutable: {cc}"); + } + + /// HTML and unknown extensions get no-cache. + #[test] + fn cache_control_no_cache_for_html(ext in prop_oneof![Just("html"), Just("htm"), Just("")]) { + let cc = cache_control_for(ext); + prop_assert_eq!(cc, "no-cache"); + } +} + +// ─── backend_addr ──────────────────────────────────────────────────────────── + +proptest! { + /// backend_addr never panics on arbitrary strings. + #[test] + fn backend_addr_never_panics(s in "\\PC{0,200}") { + let _ = backend_addr(&s); + } + + /// backend_addr strips http:// and https:// prefixes. + #[test] + fn backend_addr_strips_http(host in "[a-z.]{1,30}:[0-9]{2,5}") { + let http_url = format!("http://{host}"); + let https_url = format!("https://{host}"); + prop_assert_eq!(backend_addr(&http_url), host.as_str()); + prop_assert_eq!(backend_addr(&https_url), host.as_str()); + } + + /// backend_addr on strings without a scheme is identity. + #[test] + fn backend_addr_no_scheme_is_identity(host in "[a-z.]{1,30}:[0-9]{2,5}") { + prop_assert_eq!(backend_addr(&host), host.as_str()); + } + + /// backend_addr result never contains "://". + #[test] + fn backend_addr_result_no_scheme(url in backend_url_strategy()) { + let result = backend_addr(&url); + // Result should not start with http:// or https:// + prop_assert!(!result.starts_with("http://")); + prop_assert!(!result.starts_with("https://")); + } +} + +// ─── Request ID (UUID v4) ──────────────────────────────────────────────────── + +proptest! { + /// Generated UUIDs are always valid v4 and unique. + #[test] + fn request_ids_are_valid_uuid_v4(count in 1..100usize) { + let mut seen = HashSet::new(); + for _ in 0..count { + let id = uuid::Uuid::new_v4(); + prop_assert_eq!(id.get_version(), Some(uuid::Version::Random)); + prop_assert_eq!(id.to_string().len(), 36); + prop_assert!(seen.insert(id), "duplicate UUID generated"); + } + } + + /// UUID string format is always parseable back. + #[test] + fn request_id_roundtrip(_seed in 0u64..10000) { + let id = uuid::Uuid::new_v4(); + let s = id.to_string(); + let parsed = uuid::Uuid::parse_str(&s).unwrap(); + prop_assert_eq!(id, parsed); + } +} + +// ─── Rewrite rule compilation ──────────────────────────────────────────────── + +proptest! { + /// compile_rewrites never panics, even with invalid regex patterns. + #[test] + fn compile_rewrites_never_panics( + pattern in "[a-zA-Z0-9^$.*/\\[\\](){},?+|\\\\-]{0,50}", + target in "[a-zA-Z0-9/_.-]{0,50}", + ) { + let routes = vec![make_route( + "test", + "http://localhost:8080", + vec![RewriteRule { pattern, target }], + vec![], + vec![], + )]; + // Should not panic — invalid regexes are logged and skipped. + let compiled = SunbeamProxy::compile_rewrites(&routes); + prop_assert!(compiled.len() <= 1); + } + + /// Valid regex patterns compile and can be matched against paths. + #[test] + fn compile_rewrites_valid_patterns_work( + prefix in "[a-z]{1,10}", + path_segment in "[a-z0-9]{1,20}", + ) { + let pattern = format!("^/{path_segment}$"); + let routes = vec![make_route( + &prefix, + "http://localhost:8080", + vec![RewriteRule { + pattern: pattern.clone(), + target: "/rewritten.html".into(), + }], + vec![], + vec![], + )]; + let compiled = SunbeamProxy::compile_rewrites(&routes); + prop_assert_eq!(compiled.len(), 1); + prop_assert_eq!(compiled[0].1.len(), 1); + let test_path = format!("/{path_segment}"); + prop_assert!(compiled[0].1[0].pattern.is_match(&test_path)); + } + + /// Routes without rewrites produce no compiled entries. + #[test] + fn compile_rewrites_empty_for_no_rules(prefix in "[a-z]{1,10}") { + let routes = vec![make_route( + &prefix, + "http://localhost:8080", + vec![], + vec![], + vec![], + )]; + let compiled = SunbeamProxy::compile_rewrites(&routes); + prop_assert!(compiled.is_empty()); + } + + /// Multiple rewrite rules on one route all compile. + #[test] + fn compile_rewrites_multiple_rules( + n in 1..10usize, + prefix in "[a-z]{1,5}", + ) { + let rules: Vec = (0..n) + .map(|i| RewriteRule { + pattern: format!("^/path{i}$"), + target: format!("/target{i}.html"), + }) + .collect(); + let routes = vec![make_route( + &prefix, + "http://localhost:8080", + rules, + vec![], + vec![], + )]; + let compiled = SunbeamProxy::compile_rewrites(&routes); + prop_assert_eq!(compiled.len(), 1); + prop_assert_eq!(compiled[0].1.len(), n); + } + + /// Rewrite rules with complex UUID-matching patterns compile. + #[test] + fn compile_rewrites_uuid_pattern(_i in 0..50u32) { + let routes = vec![make_route( + "docs", + "http://localhost:8080", + vec![RewriteRule { + pattern: r"^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$".into(), + target: "/docs/[id]/index.html".into(), + }], + vec![], + vec![], + )]; + let compiled = SunbeamProxy::compile_rewrites(&routes); + prop_assert_eq!(compiled.len(), 1); + prop_assert!(compiled[0].1[0].pattern.is_match("/docs/550e8400-e29b-41d4-a716-446655440000/")); + prop_assert!(!compiled[0].1[0].pattern.is_match("/docs/not-a-uuid/")); + } +} + +// ─── Body rewriting ────────────────────────────────────────────────────────── + +/// Simulate the body rewrite logic from response_body_filter without needing +/// a Pingora session. This mirrors the exact algorithm in proxy.rs. +fn simulate_body_rewrite( + chunks: &[&[u8]], + rules: &[(String, String)], +) -> Vec { + let mut buffer = Vec::new(); + for chunk in chunks { + buffer.extend_from_slice(chunk); + } + let mut result = String::from_utf8_lossy(&buffer).into_owned(); + for (find, replace) in rules { + result = result.replace(find.as_str(), replace.as_str()); + } + result.into_bytes() +} + +proptest! { + /// Body rewriting with a single find/replace works on arbitrary content. + #[test] + fn body_rewrite_single_rule( + (find, replace) in find_replace_strategy(), + body in body_content_strategy(), + ) { + let expected = body.replace(&find, &replace); + let result = simulate_body_rewrite( + &[body.as_bytes()], + &[(find, replace)], + ); + prop_assert_eq!(String::from_utf8_lossy(&result), expected); + } + + /// Body rewriting with multiple rules is applied in order. + #[test] + fn body_rewrite_multiple_rules( + body in "[a-zA-Z0-9 ._<>/=\"'-]{0,200}", + rules in proptest::collection::vec(find_replace_strategy(), 1..5), + ) { + let mut expected = body.clone(); + for (find, replace) in &rules { + expected = expected.replace(find.as_str(), replace.as_str()); + } + let result = simulate_body_rewrite(&[body.as_bytes()], &rules); + prop_assert_eq!(String::from_utf8_lossy(&result), expected); + } + + /// Body rewriting across multiple chunks produces same result as single chunk. + #[test] + fn body_rewrite_chunked_matches_single( + body in "[a-zA-Z0-9 ._<>/=\"'-]{10,200}", + split_at in 1..9usize, + (find, replace) in find_replace_strategy(), + ) { + let split_point = split_at.min(body.len() - 1); + let (chunk1, chunk2) = body.as_bytes().split_at(split_point); + + let single_result = simulate_body_rewrite( + &[body.as_bytes()], + &[(find.clone(), replace.clone())], + ); + let chunked_result = simulate_body_rewrite( + &[chunk1, chunk2], + &[(find, replace)], + ); + + prop_assert_eq!(single_result, chunked_result); + } + + /// Body rewriting with empty find string doesn't loop infinitely. + /// (String::replace with empty find inserts between every character, + /// which is valid Rust behavior — we just verify it terminates.) + #[test] + fn body_rewrite_empty_find( + body in "[a-z]{0,20}", + replace in "[a-z]{0,5}", + ) { + // String::replace("", x) inserts x between every char and at start/end. + // We just need to verify it doesn't hang. + let result = simulate_body_rewrite( + &[body.as_bytes()], + &[("".into(), replace)], + ); + prop_assert!(!result.is_empty() || body.is_empty()); + } + + /// Body rewriting is idempotent when find and replace don't overlap. + #[test] + fn body_rewrite_no_find_is_identity( + body in "[a-z]{0,100}", + find in "[A-Z]{1,10}", + replace in "[0-9]{1,10}", + ) { + // find is uppercase, body is lowercase → no match → identity. + let result = simulate_body_rewrite( + &[body.as_bytes()], + &[(find, replace)], + ); + prop_assert_eq!(String::from_utf8_lossy(&result), body); + } +} + +// ─── Config TOML deserialization ───────────────────────────────────────────── + +proptest! { + /// TelemetryConfig with arbitrary metrics_port deserializes correctly. + #[test] + fn telemetry_config_metrics_port(port in 0u16..=65535) { + let toml_str = format!( + r#"otlp_endpoint = "" +metrics_port = {port}"# + ); + let cfg: TelemetryConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.metrics_port, port); + } + + /// TelemetryConfig without metrics_port defaults to 9090. + #[test] + fn telemetry_config_default_port(_i in 0..10u32) { + let toml_str = r#"otlp_endpoint = """#; + let cfg: TelemetryConfig = toml::from_str(toml_str).unwrap(); + prop_assert_eq!(cfg.metrics_port, 9090); + } + + /// RouteConfig with all new optional fields present deserializes. + #[test] + fn route_config_with_all_fields( + host in "[a-z]{1,10}", + static_root in "/[a-z]{1,20}", + fallback in "[a-z]{1,10}\\.html", + ) { + let toml_str = format!( + r#"host_prefix = "{host}" +backend = "http://localhost:8080" +static_root = "{static_root}" +fallback = "{fallback}" +"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.host_prefix, host); + prop_assert_eq!(cfg.static_root.as_deref(), Some(static_root.as_str())); + prop_assert_eq!(cfg.fallback.as_deref(), Some(fallback.as_str())); + prop_assert!(cfg.rewrites.is_empty()); + prop_assert!(cfg.body_rewrites.is_empty()); + prop_assert!(cfg.response_headers.is_empty()); + } + + /// RouteConfig without optional fields defaults to None/empty. + #[test] + fn route_config_minimal(host in "[a-z]{1,10}") { + let toml_str = format!( + r#"host_prefix = "{host}" +backend = "http://localhost:8080" +"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert!(cfg.static_root.is_none()); + prop_assert!(cfg.fallback.is_none()); + prop_assert!(cfg.rewrites.is_empty()); + prop_assert!(cfg.body_rewrites.is_empty()); + prop_assert!(cfg.response_headers.is_empty()); + prop_assert!(cfg.paths.is_empty()); + } + + /// PathRoute with auth fields deserializes correctly. + #[test] + fn path_route_auth_fields( + prefix in "/[a-z]{1,10}", + auth_url in "http://[a-z]{1,10}:[0-9]{4}/[a-z/]{1,20}", + ) { + let toml_str = format!( + r#"prefix = "{prefix}" +backend = "http://localhost:8080" +auth_request = "{auth_url}" +auth_capture_headers = ["Authorization", "X-Amz-Date"] +upstream_path_prefix = "/bucket/" +"# + ); + let cfg: PathRoute = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.auth_request.as_deref(), Some(auth_url.as_str())); + prop_assert_eq!(cfg.auth_capture_headers.len(), 2); + prop_assert_eq!(cfg.upstream_path_prefix.as_deref(), Some("/bucket/")); + } + + /// RewriteRule TOML roundtrip. + #[test] + fn rewrite_rule_toml( + pattern in "[a-zA-Z0-9^$/.-]{1,30}", + target in "/[a-z/.-]{1,30}", + ) { + let toml_str = format!( + r#"pattern = "{pattern}" +target = "{target}" +"# + ); + let cfg: RewriteRule = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.pattern, pattern); + prop_assert_eq!(cfg.target, target); + } + + /// BodyRewrite TOML deserialization. + #[test] + fn body_rewrite_toml( + find in "[a-zA-Z0-9./-]{1,30}", + replace in "[a-zA-Z0-9./-]{1,30}", + ) { + let toml_str = format!( + r#"find = "{find}" +replace = "{replace}" +types = ["text/html", "application/javascript"] +"# + ); + let cfg: BodyRewrite = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.find, find); + prop_assert_eq!(cfg.replace, replace); + prop_assert_eq!(cfg.types.len(), 2); + } + + /// HeaderRule TOML deserialization. + #[test] + fn header_rule_toml( + name in "[A-Z][a-zA-Z-]{1,20}", + value in "[a-zA-Z0-9 ;=,_/-]{1,50}", + ) { + let toml_str = format!( + r#"name = "{name}" +value = "{value}" +"# + ); + let cfg: HeaderRule = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.name, name); + prop_assert_eq!(cfg.value, value); + } +} + +// ─── Path traversal rejection ──────────────────────────────────────────────── + +proptest! { + /// Any path containing ".." is rejected by the traversal check. + #[test] + fn path_traversal_always_rejected( + prefix in "/[a-z]{0,10}", + suffix in "/[a-z]{0,10}", + ) { + let path = format!("{prefix}/../{suffix}"); + // The static file serving checks path.contains("..") + prop_assert!(path.contains("..")); + } + + /// Paths without ".." are not falsely rejected as traversal. + #[test] + fn safe_paths_not_rejected(path in "/[a-zA-Z0-9._/-]{0,100}") { + // A regex-generated path with only safe chars should never contain ".." + // unless the regex accidentally generates it, which is fine — we're testing + // that our check is "contains .." + if !path.contains("..") { + prop_assert!(!path.contains("..")); + } + } + + /// Paths with single dots are not mistaken for traversal. + #[test] + fn single_dot_not_traversal( + name in "[a-z]{1,10}", + ext in "[a-z]{1,5}", + ) { + let path = format!("/{name}.{ext}"); + prop_assert!(!path.contains("..")); + } +} + +// ─── Metrics label safety ──────────────────────────────────────────────────── + +proptest! { + /// Prometheus labels with arbitrary method/host/status/backend don't panic. + #[test] + fn metrics_labels_no_panic( + method in "[A-Z]{1,10}", + host in "[a-z.]{1,30}", + status in "[0-9]{3}", + backend in "[a-z.:-]{1,40}", + ) { + // Accessing with_label_values should never panic, just create new series. + sunbeam_proxy::metrics::REQUESTS_TOTAL + .with_label_values(&[&method, &host, &status, &backend]) + .inc(); + } + + /// DDoS decision metric with arbitrary decision labels doesn't panic. + #[test] + fn ddos_metric_no_panic(decision in "(allow|block)") { + sunbeam_proxy::metrics::DDOS_DECISIONS + .with_label_values(&[&decision]) + .inc(); + } + + /// Scanner decision metric with arbitrary reason doesn't panic. + #[test] + fn scanner_metric_no_panic( + decision in "(allow|block)", + reason in "[a-zA-Z0-9:_]{1,30}", + ) { + sunbeam_proxy::metrics::SCANNER_DECISIONS + .with_label_values(&[&decision, &reason]) + .inc(); + } + + /// Rate limit decision metric doesn't panic. + #[test] + fn rate_limit_metric_no_panic(decision in "(allow|block)") { + sunbeam_proxy::metrics::RATE_LIMIT_DECISIONS + .with_label_values(&[&decision]) + .inc(); + } + + /// Active connections gauge can be incremented and decremented. + #[test] + fn active_connections_inc_dec(n in 1..100u32) { + for _ in 0..n { + sunbeam_proxy::metrics::ACTIVE_CONNECTIONS.inc(); + } + for _ in 0..n { + sunbeam_proxy::metrics::ACTIVE_CONNECTIONS.dec(); + } + // Gauge can go negative, which is fine for prometheus. + } + + /// Histogram observe never panics for non-negative durations. + #[test] + fn duration_histogram_no_panic(secs in 0.0f64..3600.0) { + sunbeam_proxy::metrics::REQUEST_DURATION.observe(secs); + } +} + +// ─── Static file serving (filesystem-based) ────────────────────────────────── + +proptest! { + /// read_static_file integration: create a temp file, verify content_type/cache_control. + #[test] + fn static_file_content_type_matches_extension(ext in extension_strategy()) { + if ext.is_empty() { + return Ok(()); + } + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join(format!("test.{ext}")); + std::fs::write(&file_path, b"test content").unwrap(); + + let expected_ct = content_type_for(&ext); + let expected_cc = cache_control_for(&ext); + + // Verify the mapping is consistent. + prop_assert!(!expected_ct.is_empty()); + prop_assert!(!expected_cc.is_empty()); + + // The actual try_serve needs a Pingora session (can't unit test), + // but we can verify the mapping functions are consistent. + let ct2 = content_type_for(&ext); + let cc2 = cache_control_for(&ext); + prop_assert_eq!(expected_ct, ct2, "content_type_for not deterministic"); + prop_assert_eq!(expected_cc, cc2, "cache_control_for not deterministic"); + } + + /// Directories are never served as files. + #[test] + fn directories_not_served(_i in 0..10u32) { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("subdir"); + std::fs::create_dir(&sub).unwrap(); + + // tokio::fs::metadata would report is_file() = false for directories. + // We can verify the sync check at least. + let meta = std::fs::metadata(&sub).unwrap(); + prop_assert!(!meta.is_file()); + } +} + +// ─── Bytes body rewrite integration ────────────────────────────────────────── + +proptest! { + /// The Bytes-based body rewrite logic mirrors String::replace semantics. + #[test] + fn bytes_body_rewrite_matches_string_replace( + body in "[a-zA-Z0-9 <>/=._-]{0,300}", + find in "[a-zA-Z]{1,10}", + replace in "[a-zA-Z0-9]{0,10}", + ) { + // Simulate the exact flow from response_body_filter. + let mut body_opt: Option = Some(Bytes::from(body.clone())); + let mut buffer = Vec::new(); + + // Accumulate (single chunk). + if let Some(data) = body_opt.take() { + buffer.extend_from_slice(&data); + } + + // End of stream → apply rewrite. + let mut result = String::from_utf8_lossy(&buffer).into_owned(); + result = result.replace(&find, &replace); + let result_bytes = Bytes::from(result.clone()); + + // Compare with direct String::replace. + let expected = body.replace(&find, &replace); + prop_assert_eq!(result, expected.clone()); + prop_assert_eq!(result_bytes.len(), expected.len()); + } +} + +// ─── RouteConfig response_headers round-trip ───────────────────────────────── + +proptest! { + /// response_headers survive TOML serialization/deserialization. + #[test] + fn response_headers_roundtrip( + hdr_name in "[A-Z][a-zA-Z-]{1,15}", + hdr_value in "[a-zA-Z0-9 ;=/_-]{1,30}", + ) { + let toml_str = format!( + r#"host_prefix = "test" +backend = "http://localhost:8080" + +[[response_headers]] +name = "{hdr_name}" +value = "{hdr_value}" +"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.response_headers.len(), 1); + prop_assert_eq!(&cfg.response_headers[0].name, &hdr_name); + prop_assert_eq!(&cfg.response_headers[0].value, &hdr_value); + } + + /// Multiple response headers in TOML. + #[test] + fn multiple_response_headers(n in 1..10usize) { + let headers: String = (0..n) + .map(|i| format!( + r#" +[[response_headers]] +name = "X-Custom-{i}" +value = "value-{i}" +"# + )) + .collect(); + let toml_str = format!( + r#"host_prefix = "test" +backend = "http://localhost:8080" +{headers}"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.response_headers.len(), n); + for (i, hdr) in cfg.response_headers.iter().enumerate() { + prop_assert_eq!(&hdr.name, &format!("X-Custom-{i}")); + prop_assert_eq!(&hdr.value, &format!("value-{i}")); + } + } +} + +// ─── body_rewrites TOML ────────────────────────────────────────────────────── + +proptest! { + /// body_rewrites in RouteConfig TOML. + #[test] + fn body_rewrites_in_route( + find in "[a-zA-Z0-9.]{1,20}", + replace in "[a-zA-Z0-9.]{1,20}", + ) { + let toml_str = format!( + r#"host_prefix = "people" +backend = "http://localhost:8080" + +[[body_rewrites]] +find = "{find}" +replace = "{replace}" +types = ["text/html"] +"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.body_rewrites.len(), 1); + prop_assert_eq!(&cfg.body_rewrites[0].find, &find); + prop_assert_eq!(&cfg.body_rewrites[0].replace, &replace); + prop_assert_eq!(&cfg.body_rewrites[0].types, &vec!["text/html".to_string()]); + } +} + +// ─── rewrites TOML ─────────────────────────────────────────────────────────── + +proptest! { + /// rewrites in RouteConfig TOML. + #[test] + fn rewrites_in_route( + pattern in "[a-zA-Z0-9^$/.-]{1,20}", + target in "/[a-z/.-]{1,20}", + ) { + let toml_str = format!( + r#"host_prefix = "docs" +backend = "http://localhost:8080" +static_root = "/srv/docs" + +[[rewrites]] +pattern = "{pattern}" +target = "{target}" +"# + ); + let cfg: RouteConfig = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.rewrites.len(), 1); + prop_assert_eq!(&cfg.rewrites[0].pattern, &pattern); + prop_assert_eq!(&cfg.rewrites[0].target, &target); + } +} + +// ─── PathRoute upstream_path_prefix ────────────────────────────────────────── + +proptest! { + /// upstream_path_prefix field deserializes. + #[test] + fn path_route_upstream_prefix(prefix in "/[a-z-]{1,20}/") { + let toml_str = format!( + r#"prefix = "/media" +backend = "http://localhost:8333" +strip_prefix = true +upstream_path_prefix = "{prefix}" +"# + ); + let cfg: PathRoute = toml::from_str(&toml_str).unwrap(); + prop_assert_eq!(cfg.upstream_path_prefix.as_deref(), Some(prefix.as_str())); + prop_assert!(cfg.strip_prefix); + } +}