Rust in Everything
基于 reqwest 0.13.5

GZIP 不解压,identity 挡不住解压

主线位置:响应主线,body 最内层之上的解压层。打开 gzip 等 feature 后,栈的最外层是 tower-http 的 Decompression:请求方向补 Accept-Encoding,响应方向看 Content-Encoding 决定要不要解压。它在栈里的位置见 执行与 Pending,匹配规则和九种情形的实测见 响应体。

flowchart TB
  resp["响应头"] --> ce{"Content-Encoding 字节"}
  ce -->|"gzip 且配置开"| dec["解压 删两个头"]
  ce -->|"GZIP 或 x-gzip"| raw["原样交出"]
  raw --> bad["json 报错"]
  req["请求头 identity"] -.->|"不参与判断"| ce
  dec --> gz[".gz 被解开"]

实验 crate:exp-reqwest/pitb-lab(src/bin/gzcase.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,模板 feature(含 gzip),release 构建,客户端 .no_proxy()。服务器是同一个 crate 里的 hyper 服务器:不管请求是什么,body 都是 {"id":7,"name":"seven"} 的 gzip 压缩结果(43 字节),?enc= 决定 Content-Encoding 怎么写,?ct= 决定 Content-Type,并把收到的 Accept-Encoding 放进响应头 x-seen-ae。

现象

两个方向各一个坑。先是同一份 gzip 数据,Content-Encoding 换三种写法,用默认客户端 json():

Content-Encoding: gzip   json() -> {"id":7,"name":"seven"}
Content-Encoding: GZIP   json() -> error decoding response body for url (http://127.0.0.1:52110/x?enc=GZIP) <- expected value at line 1 column 1
Content-Encoding: x-gzip json() -> error decoding response body for url (http://127.0.0.1:52110/x?enc=x-gzip) <- expected value at line 1 column 1

GZIP 和 x-gzip 都没被解压,serde_json 拿到的是以 1f 8b 开头的压缩字节。RFC 9110 规定内容编码的名字不区分大小写,并建议把 x-gzip 当作 gzip,按标准这两个响应都应该被解压。

再是反方向:存储服务把一个 .tar.gz 文件标成 Content-Encoding: gzip(Content-Type: application/gzip),客户端想要原始字节,于是自己写了 Accept-Encoding: identity:

  let r = c
    .get(url("ct=application/gzip"))
    .header("accept-encoding", "identity")
    .send()
    .await
    .unwrap();
download .gz, sent accept-encoding: identity, content-encoding now None
  saved 23 bytes, starts [7b, 22]

请求确实发出了 identity(服务器回显的 x-seen-ae),但服务器照样回了 gzip,客户端照样解压了:存下来的是 23 字节、以 {"(7b 22)开头的明文,不是 43 字节的 gzip 文件,校验和对不上。Content-Encoding 头也被删了,从响应上看不出发生过解压。

原因

响应方向的判断在 tower-http 0.6.11 的 ResponseFuture:

        let res =
            if let header::Entry::Occupied(entry) = parts.headers.entry(header::CONTENT_ENCODING) {
                let body = match entry.get().as_bytes() {
                    #[cfg(feature = "decompression-gzip")]
                    b"gzip" if self.accept.gzip() => DecompressionBody::new(BodyInner::gzip(
                        WrapBody::new(body, CompressionLevel::default()),
                    )),
                    // ...
                    _ => {
                        return Poll::Ready(Ok(Response::from_parts(
                            parts,
                            DecompressionBody::new(BodyInner::identity(body)),
                        )))
                    }
                };

                entry.remove();
                parts.headers.remove(header::CONTENT_LENGTH);
  • 对头的原始字节做精确匹配,只认小写的 gzip。GZIP、x-gzip、gzip, br 都落到 _,原样交出,不报错。
  • 条件里的 self.accept 是客户端配置(ClientBuilder::gzip 等),不是这个请求实际发出去的 Accept-Encoding。请求方向 Decompression::call 只在请求没有 Accept-Encoding 时补一个,你写了 identity 它就不动;但响应回来时它不看你写了什么。
  • 解压后删掉 Content-Encoding 和 Content-Length,调用方无从得知。

reqwest 没有按请求关解压的开关:RequestBuilder 上没有,只有 ClientBuilder::gzip / no_gzip 这一级。

tag 之后 master 已经升到 tower-http 0.7(#3062,未发布),本地 tower-http-0.7.1 的 future.rs 仍是同样的 b"gzip" 字节匹配。

📝NOTE
冷知识:大小写敏感不是换成 tower-http 才有的

#2840(「Use decompression from tower-http」,b7c3712,首次发布在 v0.12.25)删掉了 reqwest 自己的 src/async_impl/decoder.rs,746 行。被删的 detect_encoding 是这样判断的:

        let mut is_content_encoded = {
            headers
                .get_all(CONTENT_ENCODING)
                .iter()
                .any(|enc| enc == encoding_str)
                || headers
                    .get_all(TRANSFER_ENCODING)
                    .iter()
                    .any(|enc| enc == encoding_str)
        };

HeaderValue 和 &str 比较也是逐字节的,GZIP 一样不认。旧代码还会看 Transfer-Encoding,并且对 Content-Length: 0 的响应打一条 warn 后不解压;换成 tower-http 后这两处都没有了。提交说明只有「Closes #2575」一行。

修法

要原始字节的请求,用一个关掉解压的 Client:

  let raw = reqwest::Client::builder().no_proxy().gzip(false).build().unwrap();
  let b = raw.get(url("ct=application/gzip")).send().await.unwrap().bytes().await.unwrap();
gzip(false) download: 43 bytes, starts [1f, 8b]

gzip(false) 时请求不再自动带 Accept-Encoding,响应也不再解压。它是 Client 级别的,所以下载文件和调 API 最好用两个 Client。

对不守规矩的服务器,自己解码。 同一个 gzip(false) 的客户端,按 RFC 的规则(不区分大小写、x-gzip 等于 gzip)判断,再用 flate2 解:

  let r = raw.get(url("enc=GZIP")).send().await.unwrap();
  let enc = r.headers().get("content-encoding").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
  let body = r.bytes().await.unwrap();
  let json: serde_json::Value = if enc.eq_ignore_ascii_case("gzip") || enc.eq_ignore_ascii_case("x-gzip") {
    let mut s = Vec::new();
    flate2::read::GzDecoder::new(&body[..]).read_to_end(&mut s).unwrap();
    serde_json::from_slice(&s).unwrap()
  } else {
    serde_json::from_slice(&body).unwrap()
  };
gzip(false) + manual decode of GZIP: {"id":7,"name":"seven"}

只给个别主机这样做:其他主机还是交给默认的解压层更省事。

怎么检查

  • 响应上还留着 Content-Encoding 头,说明解压层没处理它:开了 gzip 的客户端只有在它不是精确的 gzip(或 deflate、br、zstd 中开了 feature 的那个)时才会留着。
  • bytes() 的前两个字节是 1f 8b,就是没解压的 gzip。
  • 反过来,下载的文件比服务器声称的小、Content-Length 头不见了、content_length() 是 None,就是被解压过了。

小结

  • 开了 gzip 的客户端只认精确的小写 gzip;GZIP、x-gzip 原样交出,json() 报 expected value at line 1 column 1。
  • 解不解压只看客户端配置,不看请求实际发的 Accept-Encoding;写 identity 只影响服务器,挡不住客户端解压,.gz 文件会被存成解压后的内容。
  • 没有按请求关解压的开关:要原始字节就另建 gzip(false) 的 Client,对不守规矩的服务器自己按不区分大小写的规则解码。

💬 关联讨论

发起讨论
加载中...