use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::sync::OnceLock;
use std::time::Duration;
const MAX_HEADER_BYTES: usize = 16 * 1024;
const MAX_BODY_BYTES: usize = 64 * 1024;
const MAX_IMAGE_BYTES: usize = 12 * 1024 * 1024;
const IMAGE_PATH: &str = "/api/act/image/";
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
pub struct Request {
pub method: String,
pub path: String,
pub query: HashMap<String, String>,
pub body: Vec<u8>,
json_content_type: bool,
pub received: std::time::Instant,
cookie_header: String,
bearer: String,
accepts_gzip: bool,
accepts_br: bool,
if_none_match: String,
raw_query: String,
headers: Vec<(String, String)>,
}
impl Request {
pub fn parse(stream: &TcpStream) -> Result<Request, (u16, &'static str)> {
let _ = stream.set_read_timeout(Some(READ_TIMEOUT));
let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT));
let mut reader = BufReader::new(stream.take((MAX_HEADER_BYTES + MAX_IMAGE_BYTES) as u64));
let mut line = String::new();
if reader.read_line(&mut line).is_err() || line.is_empty() {
return Err((400, "malformed request line"));
}
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or_default().to_string();
let target = parts.next().unwrap_or_default();
if method != "GET" && method != "HEAD" && method != "POST" {
return Err((405, "this server answers GET, HEAD and POST"));
}
if target.is_empty() {
return Err((400, "malformed request line"));
}
let (raw_path, raw_query) = match target.split_once('?') {
Some((p, q)) => (p, q),
None => (target, ""),
};
let path = percent_decode(raw_path);
if !path.starts_with('/') || path.contains("..") || path.contains('\0') {
return Err((400, "unacceptable path"));
}
let query = raw_query
.split('&')
.filter(|p| !p.is_empty())
.map(|pair| match pair.split_once('=') {
Some((k, v)) => (percent_decode(k), percent_decode(v)),
None => (percent_decode(pair), String::new()),
})
.collect();
let mut header_bytes = line.len();
let mut length: Option<usize> = None;
let mut json_content_type = false;
let mut cookie_header = String::new();
let mut bearer = String::new();
let mut accepts_gzip = false;
let mut accepts_br = false;
let mut if_none_match = String::new();
let mut headers: Vec<(String, String)> = Vec::new();
loop {
let mut header = String::new();
match reader.read_line(&mut header) {
Ok(0) => break,
Ok(_) if header.trim().is_empty() => break,
Ok(n) => {
header_bytes += n;
if header_bytes > MAX_HEADER_BYTES {
return Err((431, "request headers too large"));
}
let Some((name, value)) = header.split_once(':') else {
continue;
};
headers.push((name.trim().to_string(), value.trim().to_string()));
let value = value.trim();
if name.eq_ignore_ascii_case("content-length") {
length = value.parse::<usize>().ok();
} else if name.eq_ignore_ascii_case("content-type") {
json_content_type = value
.split(';')
.next()
.unwrap_or_default()
.trim()
.eq_ignore_ascii_case("application/json");
} else if name.eq_ignore_ascii_case("cookie") {
cookie_header = value.to_string();
} else if name.eq_ignore_ascii_case("accept-encoding") {
let offers = |wanted: &str| {
value.split(',').any(|coding| {
let mut parts = coding.split(';');
let name = parts.next().unwrap_or_default().trim();
let refused = parts.any(|p| {
let q = p.trim().replace(' ', "");
q.strip_prefix("q=")
.and_then(|q| q.parse::<f32>().ok())
.is_some_and(|q| q <= 0.0)
});
name.eq_ignore_ascii_case(wanted) && !refused
})
};
accepts_gzip = offers("gzip");
accepts_br = offers("br");
} else if name.eq_ignore_ascii_case("if-none-match") {
if_none_match = value.to_string();
} else if name.eq_ignore_ascii_case("authorization") {
if let Some((scheme, token)) = value.split_once(' ')
&& scheme.eq_ignore_ascii_case("bearer")
{
bearer = token.trim().to_string();
}
}
}
Err(_) => return Err((431, "request headers too large")),
}
}
let mut body = Vec::new();
if method == "POST" {
let want = length.ok_or((411, "a POST needs a Content-Length"))?;
let cap = match path.starts_with(IMAGE_PATH) {
true => MAX_IMAGE_BYTES,
false => MAX_BODY_BYTES,
};
if want > cap {
return Err((413, "request body too large"));
}
body = vec![0u8; want];
if reader.read_exact(&mut body).is_err() {
return Err((400, "request body ended early"));
}
}
Ok(Request {
method,
path,
query,
body,
json_content_type,
received: std::time::Instant::now(),
cookie_header,
bearer,
accepts_gzip,
accepts_br,
if_none_match,
raw_query: raw_query.to_string(),
headers,
})
}
pub fn wants_json(&self) -> bool {
self.method == "POST" && self.json_content_type
}
pub fn json(&self) -> Result<serde_json::Value, (u16, &'static str)> {
let value: serde_json::Value =
serde_json::from_slice(&self.body).map_err(|_| (400, "body is not JSON"))?;
match value.is_object() {
true => Ok(value),
false => Err((400, "body is not a JSON object")),
}
}
pub fn token(&self) -> &str {
self.query.get("t").map_or("", String::as_str)
}
pub fn raw_query(&self) -> &str {
&self.raw_query
}
pub fn headers(&self) -> &[(String, String)] {
&self.headers
}
pub fn is_websocket(&self) -> bool {
self.headers
.iter()
.any(|(k, v)| k.eq_ignore_ascii_case("upgrade") && v.eq_ignore_ascii_case("websocket"))
}
pub fn accepts_gzip(&self) -> bool {
self.accepts_gzip
}
fn encoding(&self) -> Option<Encoding> {
match (self.accepts_br, self.accepts_gzip) {
(true, _) => Some(Encoding::Br),
(false, true) => Some(Encoding::Gzip),
(false, false) => None,
}
}
fn holds(&self, etag: &str) -> bool {
let bare = |t: &str| t.trim().trim_start_matches("W/").to_string();
let etag = bare(etag);
self.if_none_match
.split(',')
.any(|t| t.trim() == "*" || bare(t) == etag)
}
pub fn bearer(&self) -> &str {
&self.bearer
}
pub fn cookie(&self, name: &str) -> Option<&str> {
self.cookie_header
.split(';')
.filter_map(|pair| pair.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v)
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
match hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
Some(byte) => {
out.push(byte);
i += 3;
}
None => {
out.push(b'%');
i += 1;
}
}
}
b'+' => {
out.push(b' ');
i += 1;
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
409 => "Conflict",
411 => "Length Required",
413 => "Payload Too Large",
431 => "Request Header Fields Too Large",
503 => "Service Unavailable",
_ => "Error",
}
}
fn common_headers(out: &mut String) {
out.push_str(
"X-Content-Type-Options: nosniff\r\n\
Referrer-Policy: no-referrer\r\n\
Content-Security-Policy: default-src 'none'; \
style-src 'unsafe-inline'; \
script-src 'unsafe-inline'; \
img-src 'self' data:; \
font-src data:; \
manifest-src 'self'; \
connect-src 'self'; \
frame-src 'self'; \
base-uri 'none'; \
form-action 'none'; \
frame-ancestors 'none'\r\n",
);
}
pub const TERM_POLICY: &str = "default-src 'none'; base-uri 'none'; object-src 'none'; \
frame-ancestors 'self'; form-action 'none'; script-src 'self' 'wasm-unsafe-eval'; \
style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'none'; \
connect-src 'self' ws: wss:; worker-src 'self'; manifest-src 'self'; \
media-src 'none'; frame-src 'none'";
pub const NO_STORE: &str = "no-store";
pub const IMMUTABLE: &str = "private, max-age=31536000, immutable";
pub const REVALIDATE: &str = "private, no-cache";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Encoding {
Br,
Gzip,
}
impl Encoding {
fn header(self) -> &'static str {
match self {
Encoding::Br => "br",
Encoding::Gzip => "gzip",
}
}
fn pack(self, body: &[u8], thorough: bool) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(body.len() / 3);
match self {
Encoding::Br => {
let params = brotli::enc::BrotliEncoderParams {
quality: if thorough { 9 } else { 5 },
lgwin: 22,
..Default::default()
};
brotli::BrotliCompress(&mut &body[..], &mut out, ¶ms).ok()?;
}
Encoding::Gzip => {
let level = match thorough {
true => flate2::Compression::best(),
false => flate2::Compression::default(),
};
let mut gz = flate2::write::GzEncoder::new(out, level);
gz.write_all(body).ok()?;
out = gz.finish().ok()?;
}
}
(out.len() < body.len()).then_some(out)
}
}
fn compressible(content_type: &str) -> bool {
let kind = content_type.split(';').next().unwrap_or_default().trim();
kind.starts_with("text/")
|| matches!(
kind,
"application/json"
| "application/javascript"
| "application/wasm"
| "application/manifest+json"
| "image/svg+xml"
)
}
const COMPRESS_FROM: usize = 1024;
pub struct Packed {
body: Vec<u8>,
etag: String,
br: OnceLock<Option<Vec<u8>>>,
gzip: OnceLock<Option<Vec<u8>>>,
}
impl Packed {
pub fn new(body: impl Into<Vec<u8>>) -> Packed {
let body = body.into();
Packed {
etag: etag_of(&body),
body,
br: OnceLock::new(),
gzip: OnceLock::new(),
}
}
fn encoded(&self, encoding: Encoding) -> Option<&[u8]> {
let slot = match encoding {
Encoding::Br => &self.br,
Encoding::Gzip => &self.gzip,
};
slot.get_or_init(|| encoding.pack(&self.body, true))
.as_deref()
}
}
fn etag_of(body: &[u8]) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut h);
format!("W/\"{:x}-{:016x}\"", body.len(), h.finish())
}
struct Head<'a> {
content_type: &'a str,
cache: &'a str,
etag: Option<&'a str>,
encoding: Option<Encoding>,
vary: bool,
extra: &'a str,
policy: Option<&'a str>,
}
pub fn respond(
stream: &mut TcpStream,
request: Option<&Request>,
status: u16,
content_type: &str,
body: &[u8],
) {
respond_extra(stream, request, status, content_type, body, "");
}
pub fn respond_extra(
stream: &mut TcpStream,
request: Option<&Request>,
status: u16,
content_type: &str,
body: &[u8],
extra: &str,
) {
respond_fresh(stream, request, status, content_type, body, extra, NO_STORE);
}
fn respond_fresh(
stream: &mut TcpStream,
request: Option<&Request>,
status: u16,
content_type: &str,
body: &[u8],
extra: &str,
cache: &str,
) {
let worth = body.len() >= COMPRESS_FROM && compressible(content_type);
let packed = worth
.then(|| request.and_then(Request::encoding))
.flatten()
.and_then(|e| Some((e, e.pack(body, false)?)));
let head = Head {
content_type,
cache,
etag: None,
encoding: packed.as_ref().map(|(e, _)| *e),
vary: worth,
extra,
policy: None,
};
let sent = packed.as_ref().map_or(body, |(_, b)| b.as_slice());
write_response(stream, request, status, &head, sent, body.len());
}
pub fn respond_revalidated(
stream: &mut TcpStream,
request: &Request,
content_type: &str,
body: &[u8],
) {
let etag = etag_of(body);
if request.holds(&etag) {
return not_modified(stream, request, &etag, REVALIDATE, "");
}
let worth = body.len() >= COMPRESS_FROM && compressible(content_type);
let packed = worth
.then(|| request.encoding())
.flatten()
.and_then(|e| Some((e, e.pack(body, false)?)));
let head = Head {
content_type,
cache: REVALIDATE,
etag: Some(&etag),
encoding: packed.as_ref().map(|(e, _)| *e),
vary: worth,
extra: "",
policy: None,
};
let sent = packed.as_ref().map_or(body, |(_, b)| b.as_slice());
write_response(stream, Some(request), 200, &head, sent, body.len());
}
pub fn respond_packed(
stream: &mut TcpStream,
request: &Request,
content_type: &str,
packed: &Packed,
cache: &str,
extra: &str,
policy: Option<&str>,
) {
if request.holds(&packed.etag) {
return not_modified(stream, request, &packed.etag, cache, extra);
}
let worth = compressible(content_type) && packed.body.len() >= COMPRESS_FROM;
let chosen = worth
.then(|| request.encoding())
.flatten()
.and_then(|e| Some((e, packed.encoded(e)?)));
let head = Head {
content_type,
cache,
etag: Some(&packed.etag),
encoding: chosen.map(|(e, _)| e),
vary: worth,
extra,
policy,
};
let sent = chosen.map_or(packed.body.as_slice(), |(_, b)| b);
write_response(stream, Some(request), 200, &head, sent, packed.body.len());
}
fn not_modified(stream: &mut TcpStream, request: &Request, etag: &str, cache: &str, extra: &str) {
let mut head = format!(
"HTTP/1.1 304 Not Modified\r\n\
Connection: close\r\n\
Cache-Control: {cache}\r\n\
ETag: {etag}\r\n\
Vary: Accept-Encoding\r\n"
);
head.push_str(extra);
head.push_str("\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.flush();
crate::elog::event(
"http",
"response",
serde_json::json!({
"method": request.method,
"path": request.path,
"status": 304,
"bytes": 0,
"ms": request.received.elapsed().as_millis() as u64,
}),
);
}
fn write_response(
stream: &mut TcpStream,
request: Option<&Request>,
status: u16,
head_of: &Head,
body: &[u8],
raw: usize,
) {
let mut head = format!(
"HTTP/1.1 {status} {}\r\n\
Content-Type: {}\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
Cache-Control: {}\r\n",
reason(status),
head_of.content_type,
body.len(),
head_of.cache,
);
if let Some(etag) = head_of.etag {
head.push_str(&format!("ETag: {etag}\r\n"));
}
if let Some(encoding) = head_of.encoding {
head.push_str(&format!("Content-Encoding: {}\r\n", encoding.header()));
}
if head_of.vary {
head.push_str("Vary: Accept-Encoding\r\n");
}
match head_of.policy {
None => common_headers(&mut head),
Some(policy) => {
head.push_str("X-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\n");
head.push_str("Content-Security-Policy: ");
head.push_str(policy);
head.push_str("\r\n");
}
}
head.push_str(head_of.extra);
head.push_str("\r\n");
let head_only = request.is_some_and(|r| r.method == "HEAD");
let mut buf = head.into_bytes();
if !head_only {
buf.extend_from_slice(body);
}
let _ = stream.write_all(&buf);
let _ = stream.flush();
crate::elog::event(
"http",
"response",
match request {
Some(r) => serde_json::json!({
"method": r.method,
"path": r.path,
"status": status,
"bytes": body.len(),
"raw": raw,
"ms": r.received.elapsed().as_millis() as u64,
}),
None => serde_json::json!({ "status": status, "bytes": body.len() }),
},
);
}
pub fn respond_error(stream: &mut TcpStream, request: Option<&Request>, status: u16, msg: &str) {
respond(
stream,
request,
status,
"text/plain; charset=utf-8",
format!("{status} {}: {msg}\n", reason(status)).as_bytes(),
);
}
pub struct EventStream<'a> {
sink: Sink<'a>,
}
enum Sink<'a> {
Plain(&'a mut TcpStream),
Gzip(flate2::write::GzEncoder<&'a mut TcpStream>),
}
impl Sink<'_> {
fn event(&mut self, bytes: &[u8]) -> std::io::Result<()> {
match self {
Sink::Plain(stream) => {
stream.write_all(bytes)?;
stream.flush()
}
Sink::Gzip(gz) => {
gz.write_all(bytes)?;
gz.flush()
}
}
}
}
impl<'a> EventStream<'a> {
pub fn open(stream: &'a mut TcpStream, request: &Request) -> std::io::Result<EventStream<'a>> {
let gzip = request.accepts_gzip();
let mut head = String::from(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/event-stream; charset=utf-8\r\n\
Cache-Control: no-store\r\n\
Connection: close\r\n\
Vary: Accept-Encoding\r\n",
);
if gzip {
head.push_str("Content-Encoding: gzip\r\n");
}
common_headers(&mut head);
head.push_str("\r\n");
stream.write_all(head.as_bytes())?;
stream.flush()?;
let sink = match gzip {
true => Sink::Gzip(flate2::write::GzEncoder::new(
stream,
flate2::Compression::default(),
)),
false => Sink::Plain(stream),
};
Ok(EventStream { sink })
}
pub fn send(&mut self, event: &str, data: &str) -> std::io::Result<()> {
let mut frame = format!("event: {event}\n");
for line in data.split('\n') {
frame.push_str("data: ");
frame.push_str(line);
frame.push('\n');
}
frame.push('\n');
let sent = self.sink.event(frame.as_bytes());
crate::elog::event(
"sse",
"send",
serde_json::json!({ "event": event, "bytes": frame.len(), "ok": sent.is_ok() }),
);
sent
}
pub fn ping(&mut self) -> std::io::Result<()> {
self.sink.event(b"event: ping\ndata: 1\n\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_decoding_handles_escapes_and_plus() {
assert_eq!(percent_decode("/a%2Fb"), "/a/b");
assert_eq!(percent_decode("hello+world"), "hello world");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
assert_eq!(percent_decode("caf%C3%A9"), "café");
}
#[test]
fn a_truncated_escape_stays_literal() {
assert_eq!(percent_decode("/x%2"), "/x%2");
}
#[test]
fn every_status_the_router_sends_has_a_reason() {
for status in [200, 400, 403, 404, 405, 409, 411, 413, 431, 503] {
assert_ne!(reason(status), "Error", "status {status} has no reason");
}
}
#[test]
fn the_policy_forbids_loading_anything_off_the_network() {
let mut headers = String::new();
common_headers(&mut headers);
assert!(headers.contains("default-src 'none'"));
assert!(headers.contains("frame-ancestors 'none'"));
assert!(headers.contains("nosniff"));
assert!(headers.contains("img-src 'self'"));
assert!(headers.contains("manifest-src 'self'"));
}
#[test]
fn a_cookie_is_read_by_name() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let mut client = std::net::TcpStream::connect(addr).unwrap();
let (server, _) = listener.accept().unwrap();
use std::io::Write;
client
.write_all(
b"GET / HTTP/1.1\r\n\
Cookie: cctop_access_9999=other; cctop_access_7777=abc; theme=dark\r\n\
\r\n",
)
.unwrap();
let req = Request::parse(&server).unwrap();
assert_eq!(req.cookie("cctop_access_7777"), Some("abc"));
assert_eq!(req.cookie("cctop_access_9999"), Some("other"));
assert_eq!(req.cookie("cctop_access"), None);
assert_eq!(req.cookie("theme"), Some("dark"));
assert_eq!(req.cookie("absent"), None);
}
#[test]
fn gzip_is_sent_only_to_a_client_that_offers_it() {
let parse = |header: &str| {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
use std::io::Write;
client
.write_all(format!("GET / HTTP/1.1\r\n{header}\r\n\r\n").as_bytes())
.unwrap();
Request::parse(&server).unwrap().accepts_gzip()
};
assert!(parse("Accept-Encoding: gzip, deflate, br"));
assert!(parse("accept-encoding: br;q=1.0, GZIP;q=0.5"));
assert!(!parse("Accept-Encoding: gzip;q=0, br"));
assert!(!parse("Accept-Encoding: br"));
assert!(!parse("X-Other: 1"));
}
#[test]
fn a_bearer_token_is_read_from_the_authorization_header() {
let parse = |header: &str| {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
use std::io::Write;
client
.write_all(format!("GET /metrics HTTP/1.1\r\n{header}\r\n\r\n").as_bytes())
.unwrap();
Request::parse(&server).unwrap().bearer().to_string()
};
assert_eq!(parse("Authorization: Bearer abc123"), "abc123");
assert_eq!(parse("authorization: bearer abc123 "), "abc123");
assert_eq!(parse("Authorization: Basic YWJjOmRlZg=="), "");
assert_eq!(parse("X-Other: 1"), "");
}
#[test]
fn an_unchanged_poll_is_answered_304() {
let ask = |if_none_match: &str, body: &[u8]| -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (mut server, _) = listener.accept().unwrap();
let extra = match if_none_match {
"" => String::new(),
tag => format!("If-None-Match: {tag}\r\n"),
};
client
.write_all(format!("GET /api/tabs HTTP/1.1\r\n{extra}\r\n").as_bytes())
.unwrap();
let request = Request::parse(&server).unwrap();
respond_revalidated(&mut server, &request, "application/json", body);
drop(server);
let mut raw = String::new();
client.read_to_string(&mut raw).unwrap();
raw
};
let first = ask("", br#"{"tabs":[]}"#);
assert!(first.starts_with("HTTP/1.1 200 "), "{first}");
assert!(
first.contains("Cache-Control: private, no-cache\r\n"),
"{first}"
);
let tag = first
.lines()
.find_map(|l| l.strip_prefix("ETag: "))
.expect("an ETag")
.to_string();
let again = ask(&tag, br#"{"tabs":[]}"#);
assert!(again.starts_with("HTTP/1.1 304 "), "{again}");
assert!(again.ends_with("\r\n\r\n"), "a 304 has no body: {again}");
let strong = tag.trim_start_matches("W/");
assert!(ask(strong, br#"{"tabs":[]}"#).starts_with("HTTP/1.1 304 "));
let moved = ask(&tag, br#"{"tabs":[{"name":"x"}]}"#);
assert!(moved.starts_with("HTTP/1.1 200 "), "{moved}");
assert!(moved.ends_with(r#"{"tabs":[{"name":"x"}]}"#), "{moved}");
}
#[test]
fn brotli_is_preferred_and_a_zero_quality_is_a_refusal() {
let parse = |header: &str| {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server, _) = listener.accept().unwrap();
client
.write_all(format!("GET / HTTP/1.1\r\n{header}\r\n\r\n").as_bytes())
.unwrap();
Request::parse(&server).unwrap().encoding()
};
assert_eq!(
parse("Accept-Encoding: gzip, deflate, br, zstd"),
Some(Encoding::Br)
);
assert_eq!(parse("Accept-Encoding: gzip, br;q=0"), Some(Encoding::Gzip));
assert_eq!(parse("Accept-Encoding: gzip;q=0.0, br;q=0.000"), None);
assert_eq!(parse("Accept-Encoding: identity"), None);
}
}