From 5ca9ea4e751880d313e4469296780d87d91fcf5f Mon Sep 17 00:00:00 2001 From: Martin Taillefer Date: Tue, 28 Jul 2026 05:40:26 +0000 Subject: [PATCH] perf: optimize header parsing and encoding Reduce parser, iterator, and formatter work without changing the public API. Optimizations: - Parse Content-Length and shared second values directly from bytes, with an overflow-free short-number path and checked fallback. - Remove the redundant HeaderValue clone from Host decoding. - Specialize quote-free Cache-Control splitting, fold directives directly, and emit directives without a temporary option array. - Parse Content-Range with byte delimiters and direct integer conversion. - Parse Range bounds from bytes, streamline satisfiable-range iteration, and consume constructed Strings without copying. - Use the standard byte-search implementation for ETag validation and scan valid ETag lists once. - Replace the STS iterator pipeline with a direct byte parser. - Use byte suffix and prefix scans for Transfer-Encoding and Content-Disposition queries. - Scan Referrer-Policy values backward to find the last recognized policy. - Parse Cookie pairs in one pass with documented checked invariants around the retained unchecked slices. - Preallocate formatting Strings and move them into HeaderValue without copying. - Add matching 49-case Gungraun and nightly wall-clock benchmark suites. Benchmark results: | Benchmark | Instructions before | Instructions after | Wall ns before | Wall ns after | |---|---:|---:|---:|---:| | Content-Length decode | 354 | 236 | 14.59 | 6.89 | | Age decode | 254 | 193 | 7.62 | 3.92 | | Host decode | 790 | 486 | 50.68 | 44.56 | | Cache-Control decode | 2121 | 1451 | 163.96 | 130.64 | | Cache-Control quoted decode | 2628 | 2366 | 266.87 | 174.87 | | Cache-Control 16-directive decode | 6314 | 5364 | 528.41 | 686.60 | | Content-Range decode | 1405 | 528 | 91.59 | 38.48 | | STS decode | 1684 | 1182 | 107.53 | 72.70 | | STS quoted decode | 1741 | 1356 | 111.65 | 92.73 | | Referrer-Policy decode | 2768 | 1556 | 200.70 | 151.34 | | ETag 32-byte decode | 407 | 369 | 35.95 | 37.97 | | ETag 64-byte decode | 567 | 403 | 51.47 | 40.37 | | ETag 128-byte decode | 887 | 471 | 79.86 | 40.85 | | ETag 256-byte decode | 1527 | 607 | 115.84 | 51.63 | | Transfer-Encoding single query | 425 | 282 | 25.45 | 13.67 | | Transfer-Encoding list query | 683 | 515 | 57.61 | 29.59 | | If-Match single query | 1425 | 807 | 97.39 | 49.73 | | If-Match list query | 2333 | 1392 | 162.46 | 88.20 | | Range satisfiable iteration | 2692 | 1547 | 222.45 | 117.73 | | Cookie lookup | 3418 | 2080 | 245.11 | 146.14 | | Cookie length | 3248 | 2038 | 222.18 | 126.66 | | Content-Disposition query | 1245 | 1104 | 78.34 | 61.66 | | Range construction | 1099 | 1032 | 97.22 | 91.62 | | Cache-Control encode | 2114 | 1610 | 214.40 | 125.50 | | Cache-Control all-directive encode | 6576 | 5685 | 595.82 | 588.73 | | STS encode | 1732 | 1416 | 184.39 | 116.87 | | Content-Range encode | 2256 | 1928 | 216.36 | 151.52 | Instruction counts are isolated Callgrind measurements. Wall-clock values are representative nightly libtest estimates and are inherently noisier. --- Cargo.toml | 10 + benches/gungraun_bench.rs | 356 ++++++++++++++++++++++++ src/common/age.rs | 6 + src/common/benchmarks.rs | 303 ++++++++++++++++++++ src/common/cache_control.rs | 278 ++++++++++-------- src/common/connection.rs | 6 + src/common/content_disposition.rs | 39 ++- src/common/content_length.rs | 13 +- src/common/content_range.rs | 86 ++++-- src/common/cookie.rs | 122 +++++++- src/common/date.rs | 6 + src/common/etag.rs | 6 + src/common/host.rs | 7 +- src/common/if_match.rs | 7 + src/common/mod.rs | 3 + src/common/range.rs | 88 ++++-- src/common/referrer_policy.rs | 46 ++- src/common/strict_transport_security.rs | 118 +++++--- src/common/transfer_encoding.rs | 35 ++- src/util/csv.rs | 60 ---- src/util/entity.rs | 119 ++++++-- src/util/fmt.rs | 8 +- src/util/mod.rs | 66 ++++- src/util/seconds.rs | 6 +- 24 files changed, 1440 insertions(+), 354 deletions(-) create mode 100644 benches/gungraun_bench.rs create mode 100644 src/common/benchmarks.rs delete mode 100644 src/util/csv.rs diff --git a/Cargo.toml b/Cargo.toml index ad3fa580..c47dad80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,13 @@ httpdate = "1" [features] nightly = [] + +[dev-dependencies] +gungraun = "0.19.4" + +[[bench]] +name = "gungraun_bench" +harness = false + +[profile.bench] +debug = true diff --git a/benches/gungraun_bench.rs b/benches/gungraun_bench.rs new file mode 100644 index 00000000..a818e07f --- /dev/null +++ b/benches/gungraun_bench.rs @@ -0,0 +1,356 @@ +//! Callgrind benchmarks for header parsing, encoding, and queries. + +use gungraun::prelude::*; +use std::hint::black_box; + +use headers::{ + Age, CacheControl, Connection, ContentDisposition, ContentLength, ContentRange, Cookie, Date, + ETag, Header, HeaderValue, Host, IfMatch, Range, ReferrerPolicy, StrictTransportSecurity, + TransferEncoding, +}; + +fn vals(raw: &str) -> Vec { + vec![HeaderValue::from_bytes(raw.as_bytes()).unwrap()] +} + +fn vals2(a: &str, b: &str) -> Vec { + vec![ + HeaderValue::from_bytes(a.as_bytes()).unwrap(), + HeaderValue::from_bytes(b.as_bytes()).unwrap(), + ] +} + +fn cookie_values_8() -> Vec { + [ + "a=11111111", + "b=22222222", + "c=33333333", + "d=44444444", + "e=55555555", + "f=66666666", + "g=77777777", + "h=88888888", + ] + .iter() + .map(|value| HeaderValue::from_static(value)) + .collect() +} + +fn typed(raw: &str) -> H { + H::decode(&mut vals(raw).iter()).ok().unwrap() +} + +#[library_benchmark(setup = vals)] +#[bench::content_length("1234567")] +#[bench::duplicates(args = ("1234567", "1234567"), setup = vals2)] +#[bench::one_digit("7")] +#[bench::nineteen_digits("9999999999999999999")] +#[bench::max_u64("18446744073709551615")] +#[bench::overflow("18446744073709551616")] +#[bench::leading_zeroes("00000000000000000000000000000000000000001")] +fn decode_content_length(values: Vec) -> Option { + black_box(ContentLength::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::age("3600")] +fn decode_age(values: Vec) -> Option { + black_box(Age::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark] +#[bench::single(args = ("SID=31d4d96e407aad42; lang=en-US"), setup = vals)] +#[bench::multi(args = ("SID=31d4d96e407aad42", "lang=en-US"), setup = vals2)] +#[bench::many(setup = cookie_values_8)] +fn decode_cookie(values: Vec) -> Option { + black_box(Cookie::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::cache_control("max-age=100, private, no-cache")] +#[bench::quoted("community=\"UCI,group\", max-age=100, private")] +fn decode_cache_control(values: Vec) -> Option { + black_box(CacheControl::decode(&mut black_box(values).iter()).ok()) +} + +// Unknown directives keep the benchmark focused on CSV splitting. +#[library_benchmark(setup = vals)] +#[bench::cache_control_many( + "a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10, k=11, l=12, m=13, n=14, o=15, p=16" +)] +fn decode_cache_control_many(values: Vec) -> Option { + black_box(CacheControl::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::connection("keep-alive")] +fn decode_connection(values: Vec) -> Option { + black_box(Connection::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::etag("\"0123456789abcdef0123456789abcdef\"")] +#[bench::bytes_64("\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"")] +#[bench::bytes_128( + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" +)] +#[bench::long( + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" +)] +fn decode_etag(values: Vec) -> Option { + black_box(ETag::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::date("Sun, 06 Nov 1994 08:49:37 GMT")] +fn decode_date(values: Vec) -> Option { + black_box(Date::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::sts("max-age=31536000; includeSubdomains")] +#[bench::quoted(" MAX-AGE = \"31536000\" ; includeSubDomains ")] +fn decode_sts(values: Vec) -> Option { + black_box(StrictTransportSecurity::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::host("example.com:8080")] +fn decode_host(values: Vec) -> Option { + black_box(Host::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::range("bytes=0-1023,2048-4095,8192-,-4096")] +fn decode_range(values: Vec) -> Option { + black_box(Range::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::content_range("bytes 12345-67890/1000000")] +fn decode_content_range(values: Vec) -> Option { + black_box(ContentRange::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = vals)] +#[bench::referrer_policy( + "unknown, no-referrer, same-origin, origin-when-cross-origin, strict-origin-when-cross-origin" +)] +fn decode_referrer_policy(values: Vec) -> Option { + black_box(ReferrerPolicy::decode(&mut black_box(values).iter()).ok()) +} + +#[library_benchmark(setup = typed::)] +#[bench::content_length("1234567")] +fn encode_content_length(header: ContentLength) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::age("3600")] +fn encode_age(header: Age) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::cookie("SID=31d4d96e407aad42; lang=en-US")] +fn encode_cookie(header: Cookie) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::cache_control("max-age=100, private, no-cache")] +#[bench::all_directives( + "no-cache, no-store, no-transform, only-if-cached, must-revalidate, must-understand, public, private, immutable, proxy-revalidate, max-age=100, max-stale=200, min-fresh=300, s-maxage=400" +)] +fn encode_cache_control(header: CacheControl) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::connection("keep-alive")] +fn encode_connection(header: Connection) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::etag("\"0123456789abcdef0123456789abcdef\"")] +fn encode_etag(header: ETag) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::date("Sun, 06 Nov 1994 08:49:37 GMT")] +fn encode_date(header: Date) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::sts("max-age=31536000; includeSubdomains")] +fn encode_sts(header: StrictTransportSecurity) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::host("example.com:8080")] +fn encode_host(header: Host) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::range("bytes=0-1023,2048-4095,8192-,-4096")] +fn encode_range(header: Range) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::content_range("bytes 12345-67890/1000000")] +fn encode_content_range(header: ContentRange) -> Vec { + let mut out = Vec::new(); + black_box(&header).encode(&mut out); + black_box(out) +} + +#[library_benchmark(setup = typed::)] +#[bench::single("chunked")] +#[bench::multiple("gzip, deflate, br, chunked")] +fn transfer_encoding_is_chunked(header: TransferEncoding) -> bool { + black_box(header).is_chunked() +} + +#[library_benchmark(setup = typed::)] +#[bench::strong("\"0123456789abcdef0123456789abcdef\"")] +#[bench::weak("W/\"0123456789abcdef0123456789abcdef\"")] +fn etag_is_weak(header: ETag) -> bool { + black_box(header).is_weak() +} + +fn matching_etags(raw: &str) -> (IfMatch, ETag) { + let etag = typed::(raw); + (IfMatch::from(etag.clone()), etag) +} + +fn matching_etag_list(list: &str, target: &str) -> (IfMatch, ETag) { + (typed::(list), typed::(target)) +} + +#[library_benchmark(setup = matching_etags)] +#[bench::matching("\"0123456789abcdef0123456789abcdef\"")] +fn if_match_precondition_passes((condition, etag): (IfMatch, ETag)) -> bool { + black_box(condition).precondition_passes(black_box(&etag)) +} + +#[library_benchmark] +#[bench::matching_last( + args = ( + "\"first\", \"second\", \"0123456789abcdef0123456789abcdef\"", + "\"0123456789abcdef0123456789abcdef\"" + ), + setup = matching_etag_list +)] +fn if_match_list_precondition_passes((condition, etag): (IfMatch, ETag)) -> bool { + black_box(condition).precondition_passes(black_box(&etag)) +} + +#[library_benchmark(setup = typed::)] +#[bench::ranges("bytes=0-1023,2048-4095,8192-,-4096")] +fn range_satisfiable_ranges(header: Range) -> Vec<(std::ops::Bound, std::ops::Bound)> { + black_box(header) + .satisfiable_ranges(black_box(16384)) + .collect() +} + +#[library_benchmark(setup = typed::)] +#[bench::attachment( + "attachment; filename=\"a-very-long-file-name-used-to-exercise-prefix-parsing.tar.gz\"" +)] +fn content_disposition_is_attachment(header: ContentDisposition) -> bool { + black_box(header).is_attachment() +} + +#[library_benchmark] +fn construct_range_bytes() -> Range { + black_box(Range::bytes(black_box(0)..=black_box(1023)).unwrap()) +} + +#[library_benchmark(setup = typed::)] +#[bench::last("a=1; b=2; c=3; d=4; e=5; target=abcdefgh")] +fn cookie_get_last(header: Cookie) -> bool { + black_box(header).get(black_box("target")) == Some("abcdefgh") +} + +#[library_benchmark(setup = typed::)] +#[bench::six("a=1; b=2; c=3; d=4; e=5; target=abcdefgh")] +fn cookie_len(header: Cookie) -> usize { + black_box(header).len() +} + +library_benchmark_group!( + name = decode; + benchmarks = + decode_content_length, + decode_age, + decode_cookie, + decode_cache_control, + decode_cache_control_many, + decode_connection, + decode_etag, + decode_date, + decode_sts, + decode_host, + decode_range, + decode_content_range, + decode_referrer_policy +); + +library_benchmark_group!( + name = encode; + benchmarks = + encode_content_length, + encode_age, + encode_cookie, + encode_cache_control, + encode_connection, + encode_etag, + encode_date, + encode_sts, + encode_host, + encode_range, + encode_content_range +); + +library_benchmark_group!( + name = query; + benchmarks = + transfer_encoding_is_chunked, + etag_is_weak, + if_match_precondition_passes, + if_match_list_precondition_passes, + range_satisfiable_ranges, + content_disposition_is_attachment, + construct_range_bytes, + cookie_get_last, + cookie_len +); + +main!(library_benchmark_groups = decode, encode, query); diff --git a/src/common/age.rs b/src/common/age.rs index 59763932..2fc94ef3 100644 --- a/src/common/age.rs +++ b/src/common/age.rs @@ -66,3 +66,9 @@ impl From for Duration { age.0.into() } } + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::Age; + bench_header!(bench, Age, "3600"); +} diff --git a/src/common/benchmarks.rs b/src/common/benchmarks.rs new file mode 100644 index 00000000..3c5ed99e --- /dev/null +++ b/src/common/benchmarks.rs @@ -0,0 +1,303 @@ +//! Wall-clock counterparts to the Callgrind benchmarks. + +use std::ops::Bound; + +use test::{black_box, Bencher}; + +use super::{ + Age, CacheControl, Connection, ContentDisposition, ContentLength, ContentRange, Cookie, Date, + ETag, Host, IfMatch, Range, ReferrerPolicy, StrictTransportSecurity, TransferEncoding, +}; +use crate::{Header, HeaderValue}; + +fn vals(raw: &str) -> Vec { + vec![HeaderValue::from_bytes(raw.as_bytes()).unwrap()] +} + +fn vals2(a: &str, b: &str) -> Vec { + vec![ + HeaderValue::from_bytes(a.as_bytes()).unwrap(), + HeaderValue::from_bytes(b.as_bytes()).unwrap(), + ] +} + +fn cookie_values_8() -> Vec { + [ + "a=11111111", + "b=22222222", + "c=33333333", + "d=44444444", + "e=55555555", + "f=66666666", + "g=77777777", + "h=88888888", + ] + .iter() + .map(|value| HeaderValue::from_static(value)) + .collect() +} + +fn typed(raw: &str) -> H { + H::decode(&mut vals(raw).iter()).ok().unwrap() +} + +fn bench_decode(b: &mut Bencher, values: Vec) { + b.bytes = values + .iter() + .map(|value| value.as_bytes().len() as u64) + .sum(); + b.iter(|| { + let mut iter = black_box(values.as_slice()).iter(); + black_box(H::decode(&mut iter).ok()) + }); +} + +fn bench_encode(b: &mut Bencher, raw: &str) { + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| { + let mut values = Vec::new(); + black_box(&header).encode(&mut values); + black_box(values) + }); +} + +macro_rules! decode { + ($name:ident, $ty:ty, $raw:expr) => { + #[bench] + fn $name(b: &mut Bencher) { + bench_decode::<$ty>(b, vals($raw)); + } + }; +} + +macro_rules! encode { + ($name:ident, $ty:ty, $raw:expr) => { + #[bench] + fn $name(b: &mut Bencher) { + bench_encode::<$ty>(b, $raw); + } + }; +} + +mod decode { + use super::*; + + decode!(content_length, ContentLength, "1234567"); + + #[bench] + fn content_length_duplicates(b: &mut Bencher) { + bench_decode::(b, vals2("1234567", "1234567")); + } + + decode!(content_length_one_digit, ContentLength, "7"); + decode!( + content_length_nineteen_digits, + ContentLength, + "9999999999999999999" + ); + decode!( + content_length_max_u64, + ContentLength, + "18446744073709551615" + ); + decode!( + content_length_overflow, + ContentLength, + "18446744073709551616" + ); + decode!( + content_length_leading_zeroes, + ContentLength, + "00000000000000000000000000000000000000001" + ); + decode!(age, Age, "3600"); + decode!(cookie_single, Cookie, "SID=31d4d96e407aad42; lang=en-US"); + + #[bench] + fn cookie_multi(b: &mut Bencher) { + bench_decode::(b, vals2("SID=31d4d96e407aad42", "lang=en-US")); + } + + #[bench] + fn cookie_many(b: &mut Bencher) { + bench_decode::(b, cookie_values_8()); + } + + decode!( + cache_control, + CacheControl, + "max-age=100, private, no-cache" + ); + decode!( + cache_control_quoted, + CacheControl, + "community=\"UCI,group\", max-age=100, private" + ); + decode!( + cache_control_many, + CacheControl, + "a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10, k=11, l=12, m=13, n=14, o=15, p=16" + ); + decode!(connection, Connection, "keep-alive"); + decode!(etag, ETag, "\"0123456789abcdef0123456789abcdef\""); + decode!( + etag_64, + ETag, + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + ); + decode!( + etag_128, + ETag, + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + ); + decode!( + etag_256, + ETag, + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + ); + decode!(date, Date, "Sun, 06 Nov 1994 08:49:37 GMT"); + decode!( + strict_transport_security, + StrictTransportSecurity, + "max-age=31536000; includeSubdomains" + ); + decode!( + strict_transport_security_quoted, + StrictTransportSecurity, + " MAX-AGE = \"31536000\" ; includeSubDomains " + ); + decode!(host, Host, "example.com:8080"); + decode!(range, Range, "bytes=0-1023,2048-4095,8192-,-4096"); + decode!(content_range, ContentRange, "bytes 12345-67890/1000000"); + decode!( + referrer_policy, + ReferrerPolicy, + "unknown, no-referrer, same-origin, origin-when-cross-origin, strict-origin-when-cross-origin" + ); +} + +mod encode { + use super::*; + + encode!(content_length, ContentLength, "1234567"); + encode!(age, Age, "3600"); + encode!(cookie, Cookie, "SID=31d4d96e407aad42; lang=en-US"); + encode!( + cache_control, + CacheControl, + "max-age=100, private, no-cache" + ); + encode!( + cache_control_all_directives, + CacheControl, + "no-cache, no-store, no-transform, only-if-cached, must-revalidate, must-understand, public, private, immutable, proxy-revalidate, max-age=100, max-stale=200, min-fresh=300, s-maxage=400" + ); + encode!(connection, Connection, "keep-alive"); + encode!(etag, ETag, "\"0123456789abcdef0123456789abcdef\""); + encode!(date, Date, "Sun, 06 Nov 1994 08:49:37 GMT"); + encode!( + strict_transport_security, + StrictTransportSecurity, + "max-age=31536000; includeSubdomains" + ); + encode!(host, Host, "example.com:8080"); + encode!(range, Range, "bytes=0-1023,2048-4095,8192-,-4096"); + encode!(content_range, ContentRange, "bytes 12345-67890/1000000"); +} + +mod query { + use super::*; + + #[bench] + fn transfer_encoding_is_chunked_single(b: &mut Bencher) { + let header = typed::("chunked"); + b.bytes = "chunked".len() as u64; + b.iter(|| black_box(&header).is_chunked()); + } + + #[bench] + fn transfer_encoding_is_chunked_multiple(b: &mut Bencher) { + let raw = "gzip, deflate, br, chunked"; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).is_chunked()); + } + + #[bench] + fn etag_is_weak_strong(b: &mut Bencher) { + let raw = "\"0123456789abcdef0123456789abcdef\""; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).is_weak()); + } + + #[bench] + fn etag_is_weak_weak(b: &mut Bencher) { + let raw = "W/\"0123456789abcdef0123456789abcdef\""; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).is_weak()); + } + + #[bench] + fn if_match_precondition_passes(b: &mut Bencher) { + let raw = "\"0123456789abcdef0123456789abcdef\""; + let etag = typed::(raw); + let condition = IfMatch::from(etag.clone()); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&condition).precondition_passes(black_box(&etag))); + } + + #[bench] + fn if_match_list_precondition_passes(b: &mut Bencher) { + let list = "\"first\", \"second\", \"0123456789abcdef0123456789abcdef\""; + let target = "\"0123456789abcdef0123456789abcdef\""; + let condition = typed::(list); + let etag = typed::(target); + b.bytes = list.len() as u64; + b.iter(|| black_box(&condition).precondition_passes(black_box(&etag))); + } + + #[bench] + fn range_satisfiable_ranges(b: &mut Bencher) { + let raw = "bytes=0-1023,2048-4095,8192-,-4096"; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| { + black_box(&header) + .satisfiable_ranges(black_box(16384)) + .collect::, Bound)>>() + }); + } + + #[bench] + fn content_disposition_is_attachment(b: &mut Bencher) { + let raw = + "attachment; filename=\"a-very-long-file-name-used-to-exercise-prefix-parsing.tar.gz\""; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).is_attachment()); + } + + #[bench] + fn construct_range_bytes(b: &mut Bencher) { + b.iter(|| black_box(Range::bytes(black_box(0)..=black_box(1023)).unwrap())); + } + + #[bench] + fn cookie_get_last(b: &mut Bencher) { + let raw = "a=1; b=2; c=3; d=4; e=5; target=abcdefgh"; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).get(black_box("target")) == Some("abcdefgh")); + } + + #[bench] + fn cookie_len(b: &mut Bencher) { + let raw = "a=1; b=2; c=3; d=4; e=5; target=abcdefgh"; + let header = typed::(raw); + b.bytes = raw.len() as u64; + b.iter(|| black_box(&header).len()); + } +} diff --git a/src/common/cache_control.rs b/src/common/cache_control.rs index 53fbb3ce..ebbd58ad 100644 --- a/src/common/cache_control.rs +++ b/src/common/cache_control.rs @@ -1,11 +1,10 @@ use std::fmt; -use std::iter::FromIterator; use std::str::FromStr; use std::time::Duration; use http::{HeaderName, HeaderValue}; -use crate::util::{self, csv, Seconds}; +use crate::util::{self, Seconds}; use crate::{Error, Header}; /// `Cache-Control` header, defined in [RFC7234](https://tools.ietf.org/html/rfc7234#section-5.2) @@ -241,7 +240,40 @@ impl Header for CacheControl { } fn decode<'i, I: Iterator>(values: &mut I) -> Result { - csv::from_comma_delimited(values).map(|FromIter(cc)| cc) + let mut cache_control = CacheControl::new(); + + for value in values { + let string = match value.to_str() { + Ok(string) => string, + Err(_) => continue, + }; + if string.as_bytes().contains(&b'"') { + let mut in_quotes = false; + for field in string.split(move |c| { + if in_quotes { + if c == '"' { + in_quotes = false; + } + false + } else if c == ',' { + true + } else { + if c == '"' { + in_quotes = true; + } + false + } + }) { + cache_control.apply_field(field)?; + } + } else { + for field in string.split(',') { + cache_control.apply_field(field)?; + } + } + } + + Ok(cache_control) } fn encode>(&self, values: &mut E) { @@ -249,70 +281,63 @@ impl Header for CacheControl { } } -// Adapter to be used in Header::decode -struct FromIter(CacheControl); - -impl FromIterator for FromIter { - fn from_iter(iter: I) -> Self - where - I: IntoIterator, - { - let mut cc = CacheControl::new(); - - // ignore all unknown directives - let iter = iter.into_iter().filter_map(|dir| match dir { - KnownDirective::Known(dir) => Some(dir), - KnownDirective::Unknown => None, - }); - - for directive in iter { - match directive { - Directive::NoCache => { - cc.flags.insert(Flags::NO_CACHE); - } - Directive::NoStore => { - cc.flags.insert(Flags::NO_STORE); - } - Directive::NoTransform => { - cc.flags.insert(Flags::NO_TRANSFORM); - } - Directive::OnlyIfCached => { - cc.flags.insert(Flags::ONLY_IF_CACHED); - } - Directive::MustRevalidate => { - cc.flags.insert(Flags::MUST_REVALIDATE); - } - Directive::MustUnderstand => { - cc.flags.insert(Flags::MUST_UNDERSTAND); - } - Directive::Public => { - cc.flags.insert(Flags::PUBLIC); - } - Directive::Private => { - cc.flags.insert(Flags::PRIVATE); - } - Directive::Immutable => { - cc.flags.insert(Flags::IMMUTABLE); - } - Directive::ProxyRevalidate => { - cc.flags.insert(Flags::PROXY_REVALIDATE); - } - Directive::MaxAge(secs) => { - cc.max_age = Some(Duration::from_secs(secs).into()); - } - Directive::MaxStale(secs) => { - cc.max_stale = Some(Duration::from_secs(secs).into()); - } - Directive::MinFresh(secs) => { - cc.min_fresh = Some(Duration::from_secs(secs).into()); - } - Directive::SMaxAge(secs) => { - cc.s_max_age = Some(Duration::from_secs(secs).into()); - } - } +impl CacheControl { + #[inline] + fn apply_field(&mut self, field: &str) -> Result<(), Error> { + let field = field.trim(); + if field.is_empty() { + return Ok(()); } + let directive = match field.parse().map_err(|_| Error::invalid())? { + KnownDirective::Known(directive) => directive, + KnownDirective::Unknown => return Ok(()), + }; - FromIter(cc) + match directive { + Directive::NoCache => { + self.flags.insert(Flags::NO_CACHE); + } + Directive::NoStore => { + self.flags.insert(Flags::NO_STORE); + } + Directive::NoTransform => { + self.flags.insert(Flags::NO_TRANSFORM); + } + Directive::OnlyIfCached => { + self.flags.insert(Flags::ONLY_IF_CACHED); + } + Directive::MustRevalidate => { + self.flags.insert(Flags::MUST_REVALIDATE); + } + Directive::MustUnderstand => { + self.flags.insert(Flags::MUST_UNDERSTAND); + } + Directive::Public => { + self.flags.insert(Flags::PUBLIC); + } + Directive::Private => { + self.flags.insert(Flags::PRIVATE); + } + Directive::Immutable => { + self.flags.insert(Flags::IMMUTABLE); + } + Directive::ProxyRevalidate => { + self.flags.insert(Flags::PROXY_REVALIDATE); + } + Directive::MaxAge(secs) => { + self.max_age = Some(Duration::from_secs(secs).into()); + } + Directive::MaxStale(secs) => { + self.max_stale = Some(Duration::from_secs(secs).into()); + } + Directive::MinFresh(secs) => { + self.min_fresh = Some(Duration::from_secs(secs).into()); + } + Directive::SMaxAge(secs) => { + self.s_max_age = Some(Duration::from_secs(secs).into()); + } + } + Ok(()) } } @@ -320,46 +345,59 @@ struct Fmt<'a>(&'a CacheControl); impl fmt::Display for Fmt<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let if_flag = |f: Flags, dir: Directive| { - if self.0.flags.contains(f) { - Some(dir) - } else { - None + #[inline] + fn emit(f: &mut fmt::Formatter, first: &mut bool, directive: Directive) -> fmt::Result { + if !*first { + f.write_str(", ")?; } - }; + *first = false; + fmt::Display::fmt(&directive, f) + } - let slice = &[ - if_flag(Flags::NO_CACHE, Directive::NoCache), - if_flag(Flags::NO_STORE, Directive::NoStore), - if_flag(Flags::NO_TRANSFORM, Directive::NoTransform), - if_flag(Flags::ONLY_IF_CACHED, Directive::OnlyIfCached), - if_flag(Flags::MUST_REVALIDATE, Directive::MustRevalidate), - if_flag(Flags::PUBLIC, Directive::Public), - if_flag(Flags::PRIVATE, Directive::Private), - if_flag(Flags::IMMUTABLE, Directive::Immutable), - if_flag(Flags::MUST_UNDERSTAND, Directive::MustUnderstand), - if_flag(Flags::PROXY_REVALIDATE, Directive::ProxyRevalidate), - self.0 - .max_age - .as_ref() - .map(|s| Directive::MaxAge(s.as_u64())), - self.0 - .max_stale - .as_ref() - .map(|s| Directive::MaxStale(s.as_u64())), - self.0 - .min_fresh - .as_ref() - .map(|s| Directive::MinFresh(s.as_u64())), - self.0 - .s_max_age - .as_ref() - .map(|s| Directive::SMaxAge(s.as_u64())), - ]; - - let iter = slice.iter().filter_map(|o| *o); - - csv::fmt_comma_delimited(f, iter) + let mut first = true; + if self.0.flags.contains(Flags::NO_CACHE) { + emit(f, &mut first, Directive::NoCache)?; + } + if self.0.flags.contains(Flags::NO_STORE) { + emit(f, &mut first, Directive::NoStore)?; + } + if self.0.flags.contains(Flags::NO_TRANSFORM) { + emit(f, &mut first, Directive::NoTransform)?; + } + if self.0.flags.contains(Flags::ONLY_IF_CACHED) { + emit(f, &mut first, Directive::OnlyIfCached)?; + } + if self.0.flags.contains(Flags::MUST_REVALIDATE) { + emit(f, &mut first, Directive::MustRevalidate)?; + } + if self.0.flags.contains(Flags::PUBLIC) { + emit(f, &mut first, Directive::Public)?; + } + if self.0.flags.contains(Flags::PRIVATE) { + emit(f, &mut first, Directive::Private)?; + } + if self.0.flags.contains(Flags::IMMUTABLE) { + emit(f, &mut first, Directive::Immutable)?; + } + if self.0.flags.contains(Flags::MUST_UNDERSTAND) { + emit(f, &mut first, Directive::MustUnderstand)?; + } + if self.0.flags.contains(Flags::PROXY_REVALIDATE) { + emit(f, &mut first, Directive::ProxyRevalidate)?; + } + if let Some(seconds) = self.0.max_age.as_ref() { + emit(f, &mut first, Directive::MaxAge(seconds.as_u64()))?; + } + if let Some(seconds) = self.0.max_stale.as_ref() { + emit(f, &mut first, Directive::MaxStale(seconds.as_u64()))?; + } + if let Some(seconds) = self.0.min_fresh.as_ref() { + emit(f, &mut first, Directive::MinFresh(seconds.as_u64()))?; + } + if let Some(seconds) = self.0.s_max_age.as_ref() { + emit(f, &mut first, Directive::SMaxAge(seconds.as_u64()))?; + } + Ok(()) } } @@ -432,22 +470,14 @@ impl FromStr for KnownDirective { "must-understand" => Directive::MustUnderstand, "proxy-revalidate" => Directive::ProxyRevalidate, "" => return Err(()), - _ => match s.find('=') { - Some(idx) if idx + 1 < s.len() => { - match (&s[..idx], (s[idx + 1..]).trim_matches('"')) { - ("max-age", secs) => secs.parse().map(Directive::MaxAge).map_err(|_| ())?, - ("max-stale", secs) => { - secs.parse().map(Directive::MaxStale).map_err(|_| ())? - } - ("min-fresh", secs) => { - secs.parse().map(Directive::MinFresh).map_err(|_| ())? - } - ("s-maxage", secs) => { - secs.parse().map(Directive::SMaxAge).map_err(|_| ())? - } - _unknown => return Ok(KnownDirective::Unknown), - } - } + _ => match s.split_once('=') { + Some((name, value)) if !value.is_empty() => match (name, value.trim_matches('"')) { + ("max-age", secs) => secs.parse().map(Directive::MaxAge).map_err(|_| ())?, + ("max-stale", secs) => secs.parse().map(Directive::MaxStale).map_err(|_| ())?, + ("min-fresh", secs) => secs.parse().map(Directive::MinFresh).map_err(|_| ())?, + ("s-maxage", secs) => secs.parse().map(Directive::SMaxAge).map_err(|_| ())?, + _unknown => return Ok(KnownDirective::Unknown), + }, Some(_) | None => return Ok(KnownDirective::Unknown), }, })) @@ -467,6 +497,14 @@ mod tests { ); } + #[test] + fn decode_skips_non_string_header_values() { + let invalid = HeaderValue::from_bytes(b"\x80").unwrap(); + let valid = HeaderValue::from_static("max-age=100"); + let cache_control = CacheControl::decode(&mut [&invalid, &valid].iter().copied()).unwrap(); + assert_eq!(cache_control.max_age(), Some(Duration::from_secs(100))); + } + #[test] fn test_parse_argument() { assert_eq!( @@ -569,4 +607,6 @@ mod tests { ); assert_eq!(headers["cache-control"], "no-cache, max-age=100"); } + + bench_header!(bench, CacheControl, "max-age=100, private, no-cache"); } diff --git a/src/common/connection.rs b/src/common/connection.rs index eed92384..4cf82586 100644 --- a/src/common/connection.rs +++ b/src/common/connection.rs @@ -132,3 +132,9 @@ mod sealed { impl Sealed for HeaderName {} } + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::Connection; + bench_header!(bench, Connection, "keep-alive"); +} diff --git a/src/common/content_disposition.rs b/src/common/content_disposition.rs index 44f8f339..5d7ea42e 100644 --- a/src/common/content_disposition.rs +++ b/src/common/content_disposition.rs @@ -83,12 +83,17 @@ impl ContentDisposition { } fn get_type(&self) -> &str { - self.0 - .to_str() - .unwrap_or("") - .split(';') - .next() - .expect("split always has at least 1 item") + let value = match self.0.to_str() { + Ok(value) => value, + Err(_) => return "", + }; + let end = value + .as_bytes() + .iter() + .position(|&byte| byte == b';') + .unwrap_or(value.len()); + + &value[..end] } } @@ -110,6 +115,28 @@ impl Header for ContentDisposition { values.extend(::std::iter::once(self.0.clone())); } } + +#[cfg(test)] +mod tests { + use super::super::test_decode; + use super::*; + + #[test] + fn disposition_type_with_parameters() { + let disposition = + test_decode::(&["attachment; filename=\"example.txt\""]).unwrap(); + assert!(disposition.is_attachment()); + assert!(!disposition.is_inline()); + assert!(!disposition.is_form_data()); + } + + #[test] + fn invalid_string_is_not_a_known_type() { + let value = HeaderValue::from_bytes(b"attachment;\x80").unwrap(); + let disposition = ContentDisposition(value); + assert!(!disposition.is_attachment()); + } +} /* use language_tags::LanguageTag; use std::fmt; diff --git a/src/common/content_length.rs b/src/common/content_length.rs index a23a00bf..470bcffa 100644 --- a/src/common/content_length.rs +++ b/src/common/content_length.rs @@ -52,11 +52,8 @@ impl Header for ContentLength { // correctly. If not, then it's an error. let mut len = None; for value in values { - let parsed = value - .to_str() - .map_err(|_| Error::invalid())? - .parse::() - .map_err(|_| Error::invalid())?; + let parsed = + crate::util::parse_u64_digits(value.as_bytes()).ok_or_else(Error::invalid)?; if let Some(prev) = len { if prev != parsed { @@ -94,3 +91,9 @@ __hyper__tm!(ContentLength, tests { test_header!(test_duplicates_vary, vec![b"5", b"6", b"5"], None); }); */ + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::ContentLength; + bench_header!(bench, ContentLength, "1234567"); +} diff --git a/src/common/content_range.rs b/src/common/content_range.rs index 08de5ccf..e3988056 100644 --- a/src/common/content_range.rs +++ b/src/common/content_range.rs @@ -107,28 +107,22 @@ impl Header for ContentRange { fn decode<'i, I: Iterator>(values: &mut I) -> Result { values .next() - .and_then(|v| v.to_str().ok()) - .and_then(|s| split_in_two(s, ' ')) - .and_then(|(unit, spec)| { - if unit != "bytes" { - // For now, this only supports bytes-content-range. nani? - return None; - } - - let (range, complete_length) = split_in_two(spec, '/')?; + .and_then(|value| value.as_bytes().strip_prefix(b"bytes ")) + .and_then(|spec| { + let (range, complete_length) = split_once_byte(spec, b'/')?; - let complete_length = if complete_length == "*" { + let complete_length = if complete_length == b"*" { None } else { - Some(complete_length.parse().ok()?) + Some(crate::util::parse_u64_digits(complete_length)?) }; - let range = if range == "*" { + let range = if range == b"*" { None } else { - let (first_byte, last_byte) = split_in_two(range, '-')?; - let first_byte = first_byte.parse().ok()?; - let last_byte = last_byte.parse().ok()?; + let (first_byte, last_byte) = split_once_byte(range, b'-')?; + let first_byte = crate::util::parse_u64_digits(first_byte)?; + let last_byte = crate::util::parse_u64_digits(last_byte)?; if last_byte < first_byte { return None; } @@ -170,11 +164,63 @@ impl Header for ContentRange { } } -fn split_in_two(s: &str, separator: char) -> Option<(&str, &str)> { - let mut iter = s.splitn(2, separator); - match (iter.next(), iter.next()) { - (Some(a), Some(b)) => Some((a, b)), - _ => None, +fn split_once_byte(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> { + let index = bytes.iter().position(|&byte| byte == separator)?; + let (left, right) = bytes.split_at(index); + Some((left, right.split_first()?.1)) +} + +#[cfg(test)] +mod tests { + use super::super::test_decode; + use super::*; + + #[test] + fn decode_bytes() { + let range = test_decode::(&["bytes 0-499/500"]).unwrap(); + assert_eq!(range.bytes_range(), Some((0, 499))); + assert_eq!(range.bytes_len(), Some(500)); + } + + #[test] + fn decode_unsatisfied_and_unknown_length() { + let unsatisfied = test_decode::(&["bytes */500"]).unwrap(); + assert_eq!(unsatisfied.bytes_range(), None); + assert_eq!(unsatisfied.bytes_len(), Some(500)); + + let unknown = test_decode::(&["bytes */*"]).unwrap(); + assert_eq!(unknown.bytes_range(), None); + assert_eq!(unknown.bytes_len(), None); + } + + #[test] + fn decode_accepts_leading_plus() { + let range = test_decode::(&["bytes +1-+2/+3"]).unwrap(); + assert_eq!(range.bytes_range(), Some((1, 2))); + assert_eq!(range.bytes_len(), Some(3)); + } + + #[test] + fn decode_rejects_invalid_values() { + for value in [ + "items 0-1/2", + "bytes 2-1/3", + "bytes 0-1/", + "bytes 0-/2", + "bytes 0-1/18446744073709551616", + ] { + assert!( + test_decode::(&[value]).is_none(), + "accepted {}", + value + ); + } + } + + #[test] + fn decode_rejects_obs_text() { + let value = HeaderValue::from_bytes(b"bytes 0-1/2\x80").unwrap(); + assert!(ContentRange::decode(&mut std::iter::once(&value)).is_err()); } } diff --git a/src/common/cookie.rs b/src/common/cookie.rs index 7551c2ad..ed4d9e9d 100644 --- a/src/common/cookie.rs +++ b/src/common/cookie.rs @@ -54,12 +54,62 @@ impl Cookie { /// Iterator the key-value pairs of this `Cookie` header. pub fn iter(&self) -> impl Iterator { - self.0.iter().filter_map(|kv| { - let mut iter = kv.splitn(2, '='); - let key = iter.next()?.trim(); - let val = iter.next()?.trim(); - Some((key, val)) - }) + CookieIter { + rest: self.0.value.to_str().ok(), + } + } +} + +struct CookieIter<'a> { + rest: Option<&'a str>, +} + +impl<'a> Iterator for CookieIter<'a> { + type Item = (&'a str, &'a str); + + fn next(&mut self) -> Option { + loop { + let rest = self.rest?; + let bytes = rest.as_bytes(); + let mut in_quotes = false; + let mut equals = None; + let mut end = bytes.len(); + + for (index, &byte) in bytes.iter().enumerate() { + if byte == b'=' && equals.is_none() { + equals = Some(index); + } + if in_quotes { + if byte == b'"' { + in_quotes = false; + } + } else if byte == b';' { + end = index; + break; + } else if byte == b'"' { + in_quotes = true; + } + } + + // SAFETY: `end` is `rest.len()` or an in-bounds ASCII boundary. + let segment = unsafe { rest.get_unchecked(..end) }; + self.rest = if end == bytes.len() { + None + } else { + // SAFETY: `end` indexes `;`, so `end + 1` is a valid boundary. + Some(unsafe { rest.get_unchecked(end + 1..) }) + }; + + let equals = match equals { + Some(index) if index < end => index, + _ => continue, + }; + // SAFETY: `equals` indexes an ASCII `=` within `segment`. + let key = unsafe { segment.get_unchecked(..equals) }; + // SAFETY: The byte after the in-bounds `=` is a valid boundary. + let value = unsafe { segment.get_unchecked(equals + 1..) }; + return Some((key.trim(), value.trim())); + } } } @@ -108,6 +158,66 @@ mod tests { assert_eq!(cookie.get("lol"), Some("cat")); } + #[test] + fn quoted_delimiters_and_extra_equals() { + let cookie = test_decode::(&["quoted=\"semi;colon\"; token=a=b; invalid"]).unwrap(); + + assert_eq!(cookie.get("quoted"), Some("\"semi;colon\"")); + assert_eq!(cookie.get("token"), Some("a=b")); + assert_eq!(cookie.len(), 2); + } + + #[test] + fn iterator_matches_safe_reference_for_short_inputs() { + fn reference(value: &str) -> Vec<(&str, &str)> { + let mut in_quotes = false; + value + .split(move |character| { + if in_quotes { + if character == '"' { + in_quotes = false; + } + false + } else if character == ';' { + true + } else { + if character == '"' { + in_quotes = true; + } + false + } + }) + .filter_map(|pair| { + let (key, value) = pair.split_once('=')?; + Some((key.trim(), value.trim())) + }) + .collect() + } + + fn check(value: &mut String, remaining: usize) { + let cookie = test_decode::(&[value]).unwrap(); + assert_eq!( + cookie.iter().collect::>(), + reference(value), + "{:?}", + value + ); + + if remaining == 0 { + return; + } + for character in ['a', '=', ';', '"', ' ', '\t'] { + value.push(character); + check(value, remaining - 1); + value.pop(); + } + } + + check(&mut String::new(), 4); + } + + bench_header!(bench, Cookie, "SID=31d4d96e407aad42; lang=en-US"); + /* #[test] fn test_set_and_get() { diff --git a/src/common/date.rs b/src/common/date.rs index 2ac15379..874b1662 100644 --- a/src/common/date.rs +++ b/src/common/date.rs @@ -44,3 +44,9 @@ impl From for SystemTime { date.0.into() } } + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::Date; + bench_header!(bench, Date, "Sun, 06 Nov 1994 08:49:37 GMT"); +} diff --git a/src/common/etag.rs b/src/common/etag.rs index 2543df85..8f656f34 100644 --- a/src/common/etag.rs +++ b/src/common/etag.rs @@ -115,3 +115,9 @@ test_etag { None::); } */ + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::ETag; + bench_header!(bench, ETag, "\"0123456789abcdef0123456789abcdef\""); +} diff --git a/src/common/host.rs b/src/common/host.rs index cb135323..f08da8d4 100644 --- a/src/common/host.rs +++ b/src/common/host.rs @@ -30,7 +30,6 @@ impl Header for Host { fn decode<'i, I: Iterator>(values: &mut I) -> Result { values .next() - .cloned() .and_then(|val| Authority::try_from(val.as_bytes()).ok()) .map(Host) .ok_or_else(Error::invalid) @@ -55,3 +54,9 @@ impl fmt::Display for Host { fmt::Display::fmt(&self.0, f) } } + +#[cfg(all(test, feature = "nightly"))] +mod benches { + use super::Host; + bench_header!(bench, Host, "example.com:8080"); +} diff --git a/src/common/if_match.rs b/src/common/if_match.rs index bc2a8479..cddd6342 100644 --- a/src/common/if_match.rs +++ b/src/common/if_match.rs @@ -100,6 +100,13 @@ mod tests { assert!(if_match.precondition_passes(&foo)); } + #[test] + fn precondition_passes_for_tag_list() { + let if_match = super::super::test_decode::(&["\"bar\", \"foo\""]).unwrap(); + assert!(if_match.precondition_passes(&ETag::from_static("\"foo\""))); + assert!(!if_match.precondition_passes(&ETag::from_static("\"baz\""))); + } + #[test] fn precondition_any() { let foo = ETag::from_static("\"foo\""); diff --git a/src/common/mod.rs b/src/common/mod.rs index 551840dc..f6543c3d 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -188,3 +188,6 @@ mod upgrade; mod user_agent; mod vary; //mod warning; + +#[cfg(all(test, feature = "nightly"))] +mod benchmarks; diff --git a/src/common/range.rs b/src/common/range.rs index aef73758..4005ff6c 100644 --- a/src/common/range.rs +++ b/src/common/range.rs @@ -1,3 +1,4 @@ +use std::convert::TryFrom; use std::ops::{Bound, RangeBounds}; use http::{HeaderName, HeaderValue}; @@ -62,7 +63,7 @@ impl Range { _ => return Err(InvalidRange { _inner: () }), }; - Ok(Range(HeaderValue::from_str(&v).unwrap())) + Ok(Range(HeaderValue::try_from(v).unwrap())) } /// Iterate the range sets as a tuple of bounds, if valid with length. @@ -73,40 +74,61 @@ impl Range { &self, len: u64, ) -> impl Iterator, Bound)> + '_ { - let s = self - .0 - .to_str() - .expect("valid string checked in Header::decode()"); - - s["bytes=".len()..].split(',').filter_map(move |spec| { - let mut iter = spec.trim().splitn(2, '-'); - let start = parse_bound(iter.next()?)?; - let end = parse_bound(iter.next()?)?; - - // Unbounded ranges in HTTP are actually a suffix - // For example, `-100` means the last 100 bytes. - if let Bound::Unbounded = start { - if let Bound::Included(end) = end { - if len < end { - // Last N bytes is larger than available! - return None; + self.0 + .as_bytes() + .strip_prefix(b"bytes=") + .into_iter() + .flat_map(|specs| specs.split(|&byte| byte == b',')) + .filter_map(move |spec| { + let spec = trim_ascii_whitespace(spec); + let (start, end) = split_once_byte(spec, b'-')?; + let start = parse_bound(start)?; + let end = parse_bound(end)?; + + // An unbounded start denotes a suffix range. + if let Bound::Unbounded = start { + if let Bound::Included(end) = end { + if len < end { + return None; + } + return Some((Bound::Included(len - end), Bound::Unbounded)); } - return Some((Bound::Included(len - end), Bound::Unbounded)); } - // else fall through - } - Some((start, end)) - }) + Some((start, end)) + }) } } -fn parse_bound(s: &str) -> Option> { - if s.is_empty() { +fn parse_bound(bytes: &[u8]) -> Option> { + if bytes.is_empty() { return Some(Bound::Unbounded); } - s.parse().ok().map(Bound::Included) + crate::util::parse_u64_digits(bytes).map(Bound::Included) +} + +fn split_once_byte(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> { + let index = bytes.iter().position(|&byte| byte == separator)?; + let (left, right) = bytes.split_at(index); + Some((left, right.split_first()?.1)) +} + +fn trim_ascii_whitespace(mut bytes: &[u8]) -> &[u8] { + // HeaderValue::to_str permits only SP and HTAB whitespace. + while let Some((&byte, rest)) = bytes.split_first() { + if byte != b' ' && byte != b'\t' { + break; + } + bytes = rest; + } + while let Some((&byte, rest)) = bytes.split_last() { + if byte != b' ' && byte != b'\t' { + break; + } + bytes = rest; + } + bytes } impl Header for Range { @@ -456,3 +478,17 @@ fn test_to_unsatisfiable_range_suffix() { let bounds = range.satisfiable_ranges(100).next(); assert_eq!(bounds, None); } + +#[test] +fn test_satisfiable_ranges_trims_and_accepts_plus() { + let range = super::test_decode::(&["bytes= 0-9 , +10-+19, -5"]).unwrap(); + let bounds = range.satisfiable_ranges(100).collect::>(); + assert_eq!( + bounds, + vec![ + (Bound::Included(0), Bound::Included(9)), + (Bound::Included(10), Bound::Included(19)), + (Bound::Included(95), Bound::Unbounded), + ] + ); +} diff --git a/src/common/referrer_policy.rs b/src/common/referrer_policy.rs index 24ca786b..03ca88e8 100644 --- a/src/common/referrer_policy.rs +++ b/src/common/referrer_policy.rs @@ -87,24 +87,32 @@ impl TryFromValues for Policy { // See https://www.w3.org/TR/referrer-policy/#determine-policy-for-token // tl;dr - Pick *last* known policy in the list let mut known = None; - for s in csv(values) { - known = Some(match s { - "no-referrer" | "never" => Policy::NoReferrer, - "no-referrer-when-downgrade" | "default" => Policy::NoReferrerWhenDowngrade, - "same-origin" => Policy::SameOrigin, - "origin" => Policy::Origin, - "origin-when-cross-origin" => Policy::OriginWhenCrossOrigin, - "strict-origin" => Policy::StrictOrigin, - "strict-origin-when-cross-origin" => Policy::StrictOriginWhenCrossOrigin, - "unsafe-url" | "always" => Policy::UnsafeUrl, - _ => continue, - }); + for value in values { + if let Ok(string) = value.to_str() { + if let Some(policy) = string.rsplit(',').map(str::trim).find_map(parse_policy) { + known = Some(policy); + } + } } known.ok_or_else(Error::invalid) } } +fn parse_policy(s: &str) -> Option { + Some(match s { + "no-referrer" | "never" => Policy::NoReferrer, + "no-referrer-when-downgrade" | "default" => Policy::NoReferrerWhenDowngrade, + "same-origin" => Policy::SameOrigin, + "origin" => Policy::Origin, + "origin-when-cross-origin" => Policy::OriginWhenCrossOrigin, + "strict-origin" => Policy::StrictOrigin, + "strict-origin-when-cross-origin" => Policy::StrictOriginWhenCrossOrigin, + "unsafe-url" | "always" => Policy::UnsafeUrl, + _ => return None, + }) +} + impl<'a> From<&'a Policy> for HeaderValue { fn from(policy: &'a Policy) -> HeaderValue { HeaderValue::from_static(match *policy { @@ -120,20 +128,6 @@ impl<'a> From<&'a Policy> for HeaderValue { } } -fn csv<'i, I>(values: I) -> impl Iterator -where - I: Iterator, -{ - values.flat_map(|value| { - value.to_str().into_iter().flat_map(|string| { - string.split(',').filter_map(|x| match x.trim() { - "" => None, - y => Some(y), - }) - }) - }) -} - #[cfg(test)] mod tests { use super::super::test_decode; diff --git a/src/common/strict_transport_security.rs b/src/common/strict_transport_security.rs index 5122e971..1697162d 100644 --- a/src/common/strict_transport_security.rs +++ b/src/common/strict_transport_security.rs @@ -86,49 +86,77 @@ impl StrictTransportSecurity { } } -enum Directive { - MaxAge(u64), - IncludeSubdomains, - Unknown, -} +fn from_bytes(bytes: &[u8]) -> Result { + let mut max_age = None; + let mut include_subdomains = false; -fn from_str(s: &str) -> Result { - s.split(';') - .map(str::trim) - .map(|sub| { - if sub.eq_ignore_ascii_case("includeSubdomains") { - Some(Directive::IncludeSubdomains) - } else { - let mut sub = sub.splitn(2, '='); - match (sub.next(), sub.next()) { - (Some(left), Some(right)) if left.trim().eq_ignore_ascii_case("max-age") => { - right - .trim() - .trim_matches('"') - .parse() - .ok() - .map(Directive::MaxAge) - } - _ => Some(Directive::Unknown), - } + for directive in bytes.split(|&byte| byte == b';') { + let directive = trim_ascii_whitespace(directive); + if directive.eq_ignore_ascii_case(b"includeSubdomains") { + if include_subdomains { + return Err(Error::invalid()); + } + include_subdomains = true; + continue; + } + + let (name, value) = match split_once_byte(directive, b'=') { + Some(parts) => parts, + None => continue, + }; + if !trim_ascii_whitespace(name).eq_ignore_ascii_case(b"max-age") { + continue; + } + if max_age.is_some() { + return Err(Error::invalid()); + } + let value = trim_byte(trim_ascii_whitespace(value), b'"'); + max_age = Some(crate::util::parse_u64_digits(value).ok_or_else(Error::invalid)?); + } + + fn split_once_byte(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> { + let index = bytes.iter().position(|&byte| byte == separator)?; + let (left, right) = bytes.split_at(index); + Some((left, right.split_first()?.1)) + } + + fn trim_ascii_whitespace(mut bytes: &[u8]) -> &[u8] { + // HeaderValue::to_str permits only SP and HTAB whitespace. + while let Some((&byte, rest)) = bytes.split_first() { + if byte != b' ' && byte != b'\t' { + break; } - }) - .try_fold((None, None), |res, dir| match (res, dir) { - ((None, sub), Some(Directive::MaxAge(age))) => Some((Some(age), sub)), - ((age, None), Some(Directive::IncludeSubdomains)) => Some((age, Some(()))), - ((Some(_), _), Some(Directive::MaxAge(_))) - | ((_, Some(_)), Some(Directive::IncludeSubdomains)) - | (_, None) => None, - (res, _) => Some(res), - }) - .and_then(|res| match res { - (Some(age), sub) => Some(StrictTransportSecurity { - max_age: Duration::from_secs(age).into(), - include_subdomains: sub.is_some(), - }), - _ => None, - }) - .ok_or_else(Error::invalid) + bytes = rest; + } + while let Some((&byte, rest)) = bytes.split_last() { + if byte != b' ' && byte != b'\t' { + break; + } + bytes = rest; + } + bytes + } + + fn trim_byte(mut bytes: &[u8], needle: u8) -> &[u8] { + while let Some((&byte, rest)) = bytes.split_first() { + if byte != needle { + break; + } + bytes = rest; + } + while let Some((&byte, rest)) = bytes.split_last() { + if byte != needle { + break; + } + bytes = rest; + } + bytes + } + + Ok(StrictTransportSecurity { + max_age: Duration::from_secs(max_age.ok_or_else(Error::invalid)?).into(), + include_subdomains, + }) } impl Header for StrictTransportSecurity { @@ -140,7 +168,7 @@ impl Header for StrictTransportSecurity { values .just_one() .and_then(|v| v.to_str().ok()) - .map(from_str) + .map(|value| from_bytes(value.as_bytes())) .unwrap_or_else(|| Err(Error::invalid())) } @@ -244,6 +272,12 @@ mod tests { None, ); } + + bench_header!( + bench, + StrictTransportSecurity, + "max-age=31536000; includeSubdomains" + ); } //bench_header!(bench, StrictTransportSecurity, { vec![b"max-age=15768000 ; includeSubDomains".to_vec()] }); diff --git a/src/common/transfer_encoding.rs b/src/common/transfer_encoding.rs index bd235b0e..0fe12d2a 100644 --- a/src/common/transfer_encoding.rs +++ b/src/common/transfer_encoding.rs @@ -54,17 +54,30 @@ impl TransferEncoding { /// Returns whether this ends with the `chunked` encoding. pub fn is_chunked(&self) -> bool { - self.0 - .value - //TODO(perf): use split and trim (not an actual method) on &[u8] - .to_str() - .map(|s| { - s.split(',') - .next_back() - .map(|encoding| encoding.trim() == "chunked") - .expect("split always has at least 1 item") - }) - .unwrap_or(false) + let value = match self.0.value.to_str() { + Ok(value) => value.as_bytes(), + Err(_) => return false, + }; + let mut encoding = value + .rsplit(|&byte| byte == b',') + .next() + .unwrap_or_default(); + + // HeaderValue::to_str permits only SP and HTAB whitespace. + while let Some((&byte, rest)) = encoding.split_first() { + if byte != b' ' && byte != b'\t' { + break; + } + encoding = rest; + } + while let Some((&byte, rest)) = encoding.split_last() { + if byte != b' ' && byte != b'\t' { + break; + } + encoding = rest; + } + + encoding == b"chunked" } } diff --git a/src/util/csv.rs b/src/util/csv.rs deleted file mode 100644 index a3d05ba4..00000000 --- a/src/util/csv.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::fmt; - -use http::HeaderValue; - -use crate::Error; - -/// Reads a comma-delimited raw header into a Vec. -pub(crate) fn from_comma_delimited<'i, I, T, E>(values: &mut I) -> Result -where - I: Iterator, - T: ::std::str::FromStr, - E: ::std::iter::FromIterator, -{ - values - .flat_map(|value| { - value.to_str().into_iter().flat_map(|string| { - let mut in_quotes = false; - string - .split(move |c| { - #[allow(clippy::collapsible_else_if)] - if in_quotes { - if c == '"' { - in_quotes = false; - } - false // dont split - } else { - if c == ',' { - true // split - } else { - if c == '"' { - in_quotes = true; - } - false // dont split - } - } - }) - .filter_map(|x| match x.trim() { - "" => None, - y => Some(y), - }) - .map(|x| x.parse().map_err(|_| Error::invalid())) - }) - }) - .collect() -} - -/// Format an array into a comma-delimited string. -pub(crate) fn fmt_comma_delimited( - f: &mut fmt::Formatter, - mut iter: impl Iterator, -) -> fmt::Result { - if let Some(part) = iter.next() { - fmt::Display::fmt(&part, f)?; - } - for part in iter { - f.write_str(", ")?; - fmt::Display::fmt(&part, f)?; - } - Ok(()) -} diff --git a/src/util/entity.rs b/src/util/entity.rs index 554bffb2..5c2b97d4 100644 --- a/src/util/entity.rs +++ b/src/util/entity.rs @@ -50,19 +50,19 @@ impl> EntityTag { /// Get the tag. pub(crate) fn tag(&self) -> &[u8] { let bytes = self.0.as_ref(); - let end = bytes.len() - 1; - if bytes[0] == b'W' { - // W/"" - &bytes[3..end] - } else { - // "" - &bytes[1..end] - } + debug_assert!(bytes.len() >= 2 && bytes.last() == Some(&b'"')); + let start = if self.is_weak() { 3 } else { 1 }; + debug_assert!(start < bytes.len()); + + &bytes[start..bytes.len() - 1] } /// Return if this is a "weak" tag. pub(crate) fn is_weak(&self) -> bool { - self.0.as_ref()[0] == b'W' + let bytes = self.0.as_ref(); + debug_assert!(!bytes.is_empty()); + + bytes[0] == b'W' } /// For strong comparison two entity-tags are equivalent if both are not weak and their @@ -96,6 +96,7 @@ impl> EntityTag { !self.weak_eq(other) } + #[inline] pub(crate) fn parse(src: T) -> Option { let slice = src.as_ref(); let length = slice.len(); @@ -163,11 +164,13 @@ impl EntityTag { } } + #[inline] pub(crate) fn from_owned(val: HeaderValue) -> Option { EntityTag::parse(val.as_bytes())?; Some(EntityTag(val)) } + #[inline] pub(crate) fn from_val(val: &HeaderValue) -> Option { EntityTag::parse(val.as_bytes()).map(|_entity| EntityTag(val.clone())) } @@ -208,18 +211,10 @@ impl<'a> From<&'a EntityTag> for HeaderValue { /// 2. in the range `%x23` to `%x7E`, or /// 3. above `%x80` fn check_slice_validity(slice: &[u8]) -> bool { - slice.iter().all(|&c| { - // HeaderValue already validates that this doesnt contain control - // characters, so we only need to look for DQUOTE (`"`). - // - // The debug_assert is just in case we use check_slice_validity in - // some new context that didnt come from a HeaderValue. - debug_assert!( - (b'\x21'..=b'\x7e').contains(&c) | (c >= b'\x80'), - "EntityTag expects HeaderValue to have check for control characters" - ); - c != b'"' - }) + debug_assert!(slice + .iter() + .all(|&c| (b'\x21'..=b'\x7e').contains(&c) | (c >= b'\x80'))); + !slice.contains(&b'"') } // ===== impl EntityTagRange ===== @@ -239,10 +234,69 @@ impl EntityTagRange { { match *self { EntityTagRange::Any => true, - EntityTagRange::Tags(ref tags) => tags - .iter() - .flat_map(EntityTag::<&str>::parse) - .any(|tag| func(&tag, entity)), + EntityTagRange::Tags(ref tags) => { + if let Ok(value) = tags.value.to_str() { + if let Some(matches) = matches_valid_list(value, entity, &func) { + return matches; + } + } + + tags.iter() + .flat_map(EntityTag::<&str>::parse) + .any(|tag| func(&tag, entity)) + } + } + } +} + +fn matches_valid_list(value: &str, entity: &EntityTag, func: &F) -> Option +where + F: Fn(&EntityTag<&str>, &EntityTag) -> bool, +{ + let bytes = value.as_bytes(); + let mut position = 0; + + loop { + while matches!(bytes.get(position), Some(b' ' | b'\t')) { + position += 1; + } + if position == bytes.len() { + return Some(false); + } + + let start = position; + if bytes.get(position..position + 3) == Some(b"W/\"") { + position += 3; + } else if bytes.get(position) == Some(&b'"') { + position += 1; + } else { + return None; + } + + let close = bytes[position..].iter().position(|&byte| byte == b'"')?; + position += close + 1; + let end = position; + + while matches!(bytes.get(position), Some(b' ' | b'\t')) { + position += 1; + } + let has_more = match bytes.get(position) { + None => false, + Some(b',') => { + position += 1; + true + } + Some(_) => return None, + }; + + let raw_tag = &value[start..end]; + debug_assert!(EntityTag::parse(raw_tag).is_some()); + let tag = EntityTag(raw_tag); + if func(&tag, entity) { + return Some(true); + } + if !has_more { + return Some(false); } } } @@ -353,4 +407,19 @@ mod tests { assert!(!etag1.strong_ne(&etag2)); assert!(!etag1.weak_ne(&etag2)); } + + #[test] + fn tag_list_does_not_match_valid_prefix_with_invalid_suffix() { + let entity = EntityTag::from_static("\"target\""); + + for value in [ + "\"target\"junk", + "W/\"target\"junk", + "\"other\", \"target\"junk", + ] { + let range = EntityTagRange::Tags(HeaderValue::from_static(value).into()); + assert!(!range.matches_strong(&entity), "{:?}", value); + assert!(!range.matches_weak(&entity), "{:?}", value); + } + } } diff --git a/src/util/fmt.rs b/src/util/fmt.rs index f8a82bbc..fd76b6d3 100644 --- a/src/util/fmt.rs +++ b/src/util/fmt.rs @@ -1,10 +1,12 @@ -use std::fmt::Display; +use std::convert::TryFrom; +use std::fmt::{self, Display}; use http::HeaderValue; pub(crate) fn fmt(fmt: T) -> HeaderValue { - let s = fmt.to_string(); - match HeaderValue::from_maybe_shared(s) { + let mut s = String::with_capacity(64); + fmt::write(&mut s, format_args!("{}", fmt)).expect("writing to a String cannot fail"); + match HeaderValue::try_from(s) { Ok(val) => val, Err(err) => panic!("illegal HeaderValue; error = {:?}, fmt = \"{}\"", err, fmt), } diff --git a/src/util/mod.rs b/src/util/mod.rs index 960d0296..ef41649b 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -15,7 +15,7 @@ pub(crate) use self::seconds::Seconds; pub(crate) use self::value_string::HeaderValueString; //mod charset; -pub(crate) mod csv; + //mod encoding; mod entity; mod flat_csv; @@ -87,3 +87,67 @@ impl TryFromValues for HeaderValue { values.next().cloned().ok_or_else(Error::invalid) } } + +/// Parse an optional `+` followed by ASCII digits. +#[inline] +pub(crate) fn parse_u64_digits(bytes: &[u8]) -> Option { + let digits = match bytes.split_first() { + Some((b'+', rest)) => rest, + _ => bytes, + }; + if digits.is_empty() { + return None; + } + + // Values shorter than 20 digits cannot overflow u64. + if digits.len() < 20 { + let mut acc: u64 = 0; + for &b in digits { + let digit = b.wrapping_sub(b'0'); + if digit > 9 { + return None; + } + acc = acc * 10 + digit as u64; + } + return Some(acc); + } + + let mut acc: u64 = 0; + for &b in digits { + let digit = b.wrapping_sub(b'0'); + if digit > 9 { + return None; + } + acc = acc.checked_mul(10)?.checked_add(digit as u64)?; + } + Some(acc) +} + +#[cfg(test)] +mod tests { + use super::parse_u64_digits; + + #[test] + fn parse_u64_digits_matches_from_str_edges() { + for input in [ + "", + "+", + "0", + "+1", + "9999999999999999999", + "18446744073709551615", + "18446744073709551616", + "00000000000000000000000000000000000000001", + "-1", + "1_000", + "12x", + ] { + assert_eq!( + parse_u64_digits(input.as_bytes()), + input.parse::().ok(), + "mismatch for {:?}", + input + ); + } + } +} diff --git a/src/util/seconds.rs b/src/util/seconds.rs index 779ce651..3e01a6e7 100644 --- a/src/util/seconds.rs +++ b/src/util/seconds.rs @@ -11,9 +11,9 @@ pub(crate) struct Seconds(Duration); impl Seconds { pub(crate) fn from_val(val: &HeaderValue) -> Option { - let secs = val.to_str().ok()?.parse().ok()?; - - Some(Self::from_secs(secs)) + Some(Self::from_secs(crate::util::parse_u64_digits( + val.as_bytes(), + )?)) } pub(crate) fn from_secs(secs: u64) -> Self {