use std::io::Read;
use encoding_rs::Encoding;
use crate::shared::text_decode::{self, EncodingSource};
pub const MAX_INFLATED_MB: usize = 32;
const MAX_INFLATED_BYTES: usize = MAX_INFLATED_MB * 1024 * 1024;
#[derive(Debug)]
pub struct BodyText {
pub text: String,
pub content_type: String,
pub encoding: &'static Encoding,
pub source: EncodingSource,
}
#[derive(Debug, thiserror::Error)]
pub enum BodyError {
#[error(transparent)]
Read(#[from] reqwest::Error),
#[error("the body is compressed as `{0}`, which is not supported")]
Unsupported(String),
#[error("the `{coding}` body is damaged: {source}")]
Corrupt {
coding: String,
source: std::io::Error,
},
#[error("the `{coding}` body inflates past {limit} bytes")]
TooLarge { coding: String, limit: usize },
#[error("the response is larger than {limit} bytes")]
BodyTooLarge { limit: usize },
}
impl BodyError {
pub fn coding(&self) -> Option<&str> {
match self {
Self::Read(_) => None,
Self::Unsupported(coding)
| Self::Corrupt { coding, .. }
| Self::TooLarge { coding, .. } => Some(coding),
Self::BodyTooLarge { .. } => None,
}
}
pub fn too_large(&self) -> bool {
matches!(self, Self::BodyTooLarge { .. })
}
}
pub async fn read(resp: reqwest::Response) -> Result<BodyText, BodyError> {
let content_type = header_value(&resp, reqwest::header::CONTENT_TYPE);
let coding = header_value(&resp, reqwest::header::CONTENT_ENCODING);
let tld = resp.url().domain().and_then(tld_label);
let raw = read_capped(resp, MAX_INFLATED_BYTES).await?;
let body = undo_content_coding(&coding, raw, MAX_INFLATED_BYTES)?;
let (text, encoding, source) = text_decode::decode(&body, &content_type, tld.as_deref());
Ok(BodyText {
text,
content_type,
encoding,
source,
})
}
async fn read_capped(resp: reqwest::Response, limit: usize) -> Result<Vec<u8>, BodyError> {
use futures_util::StreamExt;
if resp.content_length().is_some_and(|len| len > limit as u64) {
return Err(BodyError::BodyTooLarge { limit });
}
let mut body: Vec<u8> = Vec::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if body.len() + chunk.len() > limit {
return Err(BodyError::BodyTooLarge { limit });
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn header_value(resp: &reqwest::Response, name: reqwest::header::HeaderName) -> String {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string()
}
fn undo_content_coding(
header: &str,
mut body: Vec<u8>,
limit: usize,
) -> Result<Vec<u8>, BodyError> {
for coding in header.rsplit(',').map(|c| c.trim().to_ascii_lowercase()) {
body = match coding.as_str() {
"" | "identity" => continue,
"gzip" | "x-gzip" => {
inflate(flate2::read::MultiGzDecoder::new(&body[..]), &coding, limit)?
}
"deflate" => match inflate(flate2::read::ZlibDecoder::new(&body[..]), &coding, limit) {
Err(BodyError::Corrupt { .. }) => {
inflate(flate2::read::DeflateDecoder::new(&body[..]), &coding, limit)?
}
other => other?,
},
_ => return Err(BodyError::Unsupported(coding)),
};
}
Ok(body)
}
fn inflate(decoder: impl Read, coding: &str, limit: usize) -> Result<Vec<u8>, BodyError> {
let mut out = Vec::new();
decoder
.take(limit as u64 + 1)
.read_to_end(&mut out)
.map_err(|source| BodyError::Corrupt {
coding: coding.to_string(),
source,
})?;
if out.len() > limit {
return Err(BodyError::TooLarge {
coding: coding.to_string(),
limit,
});
}
Ok(out)
}
fn tld_label(host: &str) -> Option<String> {
let label = host
.trim_end_matches('.')
.rsplit('.')
.next()?
.to_ascii_lowercase();
let usable = !label.is_empty()
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
usable.then_some(label)
}
#[cfg(test)]
pub(crate) mod testkit {
use std::io::Write;
pub(crate) fn gzip(bytes: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
enc.write_all(bytes).unwrap();
enc.finish().unwrap()
}
pub(crate) fn legacy_page_response(title: &str, prose: &str) -> Vec<u8> {
let html = format!(
"<html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=windows-1251\">\
</head><body><h1>{title}</h1><p>{prose}</p></body></html>"
);
let body = gzip(&encoding_rs::WINDOWS_1251.encode(&html).0);
let mut out = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: gzip\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
out.extend_from_slice(&body);
out
}
}
#[cfg(test)]
mod tests {
use super::testkit::gzip;
use super::*;
use crate::shared::text_decode::{
Utf8Evidence, detect, document_charset, header_charset, reads_alike,
};
use encoding_rs::UTF_8;
use std::io::Write;
fn zlib(bytes: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
enc.write_all(bytes).unwrap();
enc.finish().unwrap()
}
fn raw_deflate(bytes: &[u8]) -> Vec<u8> {
let mut enc = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
enc.write_all(bytes).unwrap();
enc.finish().unwrap()
}
#[test]
fn content_codings_are_undone_last_applied_first() {
let page = b"<p>the page</p>".repeat(50);
let codings = [
("gzip", gzip(&page)),
("x-gzip", gzip(&page)),
("deflate", zlib(&page)),
("deflate", raw_deflate(&page)),
("deflate, gzip", gzip(&zlib(&page))),
("identity", page.clone()),
("", page.clone()),
];
for (header, body) in codings {
let undone = undo_content_coding(header, body, MAX_INFLATED_BYTES).unwrap();
assert_eq!(undone, page, "{header}");
}
}
#[test]
fn a_coding_that_cannot_be_undone_is_an_error_naming_it() {
let page = b"<p>the page</p>".repeat(50);
let unsupported = undo_content_coding("br", page.clone(), MAX_INFLATED_BYTES).unwrap_err();
assert!(
matches!(&unsupported, BodyError::Unsupported(c) if c == "br"),
"{unsupported}"
);
let corrupt = undo_content_coding("gzip", page, MAX_INFLATED_BYTES).unwrap_err();
assert!(matches!(corrupt, BodyError::Corrupt { .. }), "{corrupt}");
let bomb = undo_content_coding("gzip", gzip(&[0u8; 4096]), 1024).unwrap_err();
assert!(
matches!(bomb, BodyError::TooLarge { limit: 1024, .. }),
"{bomb}"
);
assert_eq!(bomb.coding(), Some("gzip"));
}
#[test]
fn the_tld_hint_only_ever_takes_a_form_the_detector_accepts() {
let cp1251 = encoding_rs::WINDOWS_1251
.encode("Старый журнал о компьютерах хранил статьи в той кодировке.")
.0;
let hosts = [
("sector.biz.ua", Some("ua")),
("Example.RU.", Some("ru")),
("xn--p1ai", Some("xn--p1ai")),
("localhost", Some("localhost")),
("under_score", None),
("", None),
];
for (host, expected) in hosts {
let label = tld_label(host);
assert_eq!(label.as_deref(), expected, "{host}");
detect(&cp1251, label.as_deref());
}
}
#[tokio::test]
#[ignore = "requires network access"]
async fn live_charset_corpus() {
const AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/124.0 Safari/537.36";
const CORPUS: &[&str] = &[
"https://sector.biz.ua/mycomp/mid203/aid5.html",
"https://www.opennet.ru/",
"http://lib.ru/",
"http://www.kulichki.com/",
"http://abehiroshi.la.coocan.jp/",
"http://www.newsmth.net/",
"https://www.fourmilab.ch/",
"http://citforum.ru/",
"http://www.people.com.cn/",
"https://www.163.com/",
"https://www.ixbt.com/news/",
"https://habr.com/ru/articles/",
"https://ru.wikipedia.org/wiki/Windows-1251",
"https://www.lemonde.fr/",
];
let client = crate::shared::net::GuardedClient::new(
crate::shared::net::AddressPolicy::PublicOnly,
std::time::Duration::from_secs(20),
);
let mut disputed = Vec::new();
for url in CORPUS {
let sent = client
.get(url)
.unwrap()
.header(reqwest::header::USER_AGENT, AGENT)
.send();
let resp = match sent.await {
Ok(resp) => resp,
Err(err) => {
eprintln!("{url} | request failed: {err}");
continue;
}
};
let status = resp.status();
let content_type = header_value(&resp, reqwest::header::CONTENT_TYPE);
let coding = header_value(&resp, reqwest::header::CONTENT_ENCODING);
let tld = resp.url().domain().and_then(tld_label);
let body = undo_content_coding(
&coding,
resp.bytes().await.unwrap().into(),
MAX_INFLATED_BYTES,
)
.unwrap();
let evidence = Utf8Evidence::of(&body);
let declared_in_document = document_charset(&body).map(Encoding::name);
let (text, encoding, step) = text_decode::decode(&body, &content_type, tld.as_deref());
let replaced = text.matches('\u{FFFD}').count();
eprintln!(
"{url} | {status} | coding {coding:?} | header {:?} | document {declared_in_document:?} \
| utf-8 {}/{} | {} by {step:?} | U+FFFD {replaced} | detector {} (tld {tld:?}), {} (none)",
header_charset(&content_type).map(Encoding::name),
evidence.decoded,
evidence.broken,
encoding.name(),
detect(&body, tld.as_deref()).name(),
detect(&body, None).name(),
);
let agrees = if encoding == UTF_8 {
replaced == 0
} else {
reads_alike(encoding, detect(&body, tld.as_deref()), &body)
};
if status.is_success() && !agrees {
disputed.push(*url);
}
}
assert!(
disputed.is_empty(),
"the decision and the evidence disagree on: {disputed:?}"
);
}
}