Rust in Everything
基于 bytes 1.12.1

remaining() 是 25,chunk() 只有 8 个字节

主线位置:共享主线(Bytes)的下游。几个 Bytes 被 Chain 或一个队列串成一个 Buf 交给解析器,每一段仍是共享主线上的一个切片,彼此在内存里不相邻;chunk() 一次只能给出其中一段。

flowchart TB
  a["Bytes a"] --> c["Chain 或 BufList"]
  b["Bytes b"] --> c
  c --> ch["chunk() 只给 a"]
  ch --> bug1["找分隔符:None"]
  ch --> bug2["切定长字段:panic"]
  c --> g["get_* / copy_to_slice"]
  g --> ok1["按段读,结果正确"]
  c --> cb["copy_to_bytes"]
  cb --> ok2["跨段才拷贝"]

Chain 的四个方法、chunk 为什么只能给一段、chunks_vectored 在 Chain 上的漏洞,在 适配器 和 Buf 里讲过,那里也跑过同一个坏解析器。这一页补上:真实程序里从哪儿拿到这种 Buf,怎么写才对,怎么测出来。

本页实验在 M2 Max、rustc 1.99.0、--release 下运行,依赖:

bytes = "=1.12.1"
http-body = "1"
http-body-util = "=0.1.5"
futures-util = "0.3"
tokio = { version = "=1.54.1", features = ["rt"] }

复现

两个坏写法:在 chunk() 里找 \r\n,从 chunk() 里直接切 4 个字节当长度前缀。分别喂给 Chain,和 http-body-util 的 BodyExt::collect 收集一个分两帧到达的 body 之后 aggregate() 出来的 Buf:

use std::convert::Infallible;
use std::panic;

use bytes::{Buf, Bytes};
use futures_util::stream;
use http_body::Frame;
use http_body_util::{BodyExt, StreamBody};

// 错误写法:以为 chunk() 就是全部数据
fn find_crlf_buggy(buf: &impl Buf) -> Option<usize> {
  buf.chunk().windows(2).position(|w| w == b"\r\n")
}

// 错误写法:直接从 chunk() 里切 4 个字节当长度前缀
fn frame_len_buggy(buf: &impl Buf) -> u32 {
  u32::from_be_bytes(buf.chunk()[..4].try_into().unwrap())
}

// 改法:找分隔符之前先变成一个连续的 Bytes,再按行切
fn lines(buf: &mut impl Buf) -> (Vec<Bytes>, bool) {
  let first = buf.chunk().as_ptr();
  let mut all = buf.copy_to_bytes(buf.remaining());
  let copied = all.as_ptr() != first;
  let mut out = Vec::new();
  while let Some(pos) = all.windows(2).position(|w| w == b"\r\n") {
    out.push(all.split_to(pos)); // Bytes::split_to 不拷贝
    all.advance(2);
  }
  (out, copied)
}

// 一个分几帧到达的 body,经 BodyExt::collect 收集,再 aggregate
fn aggregated(parts: &[&'static [u8]]) -> impl Buf + use<> {
  let frames: Vec<_> = parts.iter().map(|p| Ok::<_, Infallible>(Frame::data(Bytes::from_static(p)))).collect();
  let body = StreamBody::new(stream::iter(frames));
  let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
  rt.block_on(body.collect()).unwrap().aggregate()
}

fn main() {
  panic::set_hook(Box::new(|info| println!("panicked: {}", info.payload_as_str().unwrap_or("?"))));
  let (a, b): (&'static [u8], &'static [u8]) = (b"GET / HT", b"TP/1.1\r\nHost: a\r\n");

  // 1. Chain:一行请求在两次 read 之间被切开
  let mut buf = Bytes::from_static(a).chain(Bytes::from_static(b));
  println!("chain:     remaining={} chunk={:?}", buf.remaining(), Bytes::copy_from_slice(buf.chunk()));
  println!("chain:     find_crlf_buggy={:?}", find_crlf_buggy(&buf));
  println!("chain:     lines={:?}", lines(&mut buf));

  // 2. http-body-util 的 Collected::aggregate():两帧原样排队
  let mut agg = aggregated(&[a, b]);
  println!("aggregate: remaining={} chunk={:?}", agg.remaining(), Bytes::copy_from_slice(agg.chunk()));
  println!("aggregate: find_crlf_buggy={:?}", find_crlf_buggy(&agg));
  println!("aggregate: lines={:?}", lines(&mut agg));

  // 3. 只有一帧时:copy_to_bytes 不拷贝
  let mut one = aggregated(&[b"GET / HTTP/1.1\r\nHost: a\r\n"]);
  println!("one frame: lines={:?}", lines(&mut one));

  // 4. 长度前缀被切在 2 + 2
  let mut frame = aggregated(&[&[0, 0], &[0, 5, b'h', b'e', b'l', b'l', b'o']]);
  let r = panic::catch_unwind(|| frame_len_buggy(&frame));
  println!("prefix:    frame_len_buggy ok={}", r.is_ok());
  println!("prefix:    try_get_u32={:?}", frame.try_get_u32());
  let mut body = [0u8; 5];
  frame.copy_to_slice(&mut body);
  println!("prefix:    copy_to_slice={:?}", std::str::from_utf8(&body).unwrap());
}

aggregated 的返回类型写成 impl Buf + use<>,是因为 edition 2024 下 impl Trait 默认捕获参数 parts 的生命周期,调用处的临时数组活不到使用的时候。跑 3 次,输出相同:

chain:     remaining=25 chunk=b"GET / HT"
chain:     find_crlf_buggy=None
chain:     lines=([b"GET / HTTP/1.1", b"Host: a"], true)
aggregate: remaining=25 chunk=b"GET / HT"
aggregate: find_crlf_buggy=None
aggregate: lines=([b"GET / HTTP/1.1", b"Host: a"], true)
one frame: lines=([b"GET / HTTP/1.1", b"Host: a"], false)
panicked: range end index 4 out of range for slice of length 2
prefix:    frame_len_buggy ok=false
prefix:    try_get_u32=Ok(5)
prefix:    copy_to_slice="hello"
  • remaining() 是 25,完整的请求行就在里面,chunk() 却只有第一段的 8 个字节,find_crlf_buggy 返回 None。放在「找不到行尾就等更多数据」的循环里,这个连接会一直等下去:数据早就到了。
  • frame_len_buggy 从 2 字节的第一段里切 4 个字节,panic 的是标准库的切片索引:range end index 4 out of range for slice of length 2。panic 位置在你自己的代码里,不在 bytes 里。
  • Chain 和 aggregate() 的表现完全一样:它们都是「几段排在一起」。
  • 同样的数据,try_get_u32 读出 5,copy_to_slice 读出 hello,lines 切出两行。最后一列 true / false 是 copy_to_bytes 有没有拷贝,下面「修法」再说。

为什么

Buf::chunk 的文档第一句就说了:

    /// Returns a slice starting at the current position and of length between 0
    /// and `Buf::remaining()`. Note that this *can* return a shorter slice (this
    /// allows non-continuous internal representation).

Bytes、BytesMut、&[u8] 的数据是连续的,chunk() 恰好等于全部剩余数据,解析器在它们上面测试永远通过。不连续的实现只能给一段。Chain::chunk:

    fn chunk(&self) -> &[u8] {
        if self.a.has_remaining() {
            self.a.chunk()
        } else {
            self.b.chunk()
        }
    }

collect().aggregate() 返回的是 http-body-util 0.1.5 内部的 BufList(src/util.rs),一个 VecDeque 装着每一帧,chunk 只看队首:

    #[inline]
    fn chunk(&self) -> &[u8] {
        self.bufs.front().map(Buf::chunk).unwrap_or_default()
    }

get_* 和 copy_to_slice 之所以没问题,是因为它们本来就按段处理:当前 chunk 够就直接读,不够就一段一段拷到栈上的数组里再拼(Buf 的「get_*:两个宏,一条快路径」)。

📝NOTE
冷知识:「chunk 为空当且仅当读完了」是 1.7.0 才写进文档的

1.7.0 之前,Buf::chunk 的实现者须知只有一句「Once the end of the buffer is reached, i.e., Buf::remaining returns 0, calls to chunk() should return an empty slice.」它只要求读完时返回空,没说没读完时不能返回空。issue #716 指出「a non-continuous implementation of Buf::chunk can return an empty slice even if Buf::remaining > 0」,举的例子正是一个 VecDeque<B> 包几个 Buf、chunk 返回队首的类型,和上面的 BufList 同一个形状:队首那一段读完了还没弹出,chunk 就是空的,可后面还有数据。

PR #717(「docs: clarify the behavior of Buf::chunk」,首次发布于 1.7.0)把它改成现在的「chunk() should return an empty slice if and only if remaining() returns 0」。这条保证是 while buf.has_remaining() { let c = buf.chunk(); ...; buf.advance(c.len()) } 这种逐段循环能结束的前提,Chain::chunk 在 a 读完时转去给 b.chunk(),不返回 a 的空切片,所以两段都守住这条时,Chain 也守得住。改动只在文档,CHANGELOG 1.7.0 记为「Clarify the behavior of Buf::chunk (#717)」。

哪里会拿到不连续的 Buf

  • collect().aggregate():hyper 的 Incoming 每一帧是一个 Bytes,单独一帧是连续的;但一个 body 通常分好几帧到,hyper 卷里 256 KiB 的 body 分成了 6 块(Incoming 与 body 通道)。aggregate() 把这些帧原样排队,不拼接(http-body-util)。在本地测小 body 时只有一帧,解析器看起来没问题,换成大 body 或者慢的客户端才出错。
  • 自己 chain 起来的:比如把上次没解析完的尾巴和新读到的一块接起来。
  • VecDeque<u8>:环形缓冲绕回开头时,chunk 只给前一段。
  • 反过来,axum 的 Bytes 提取器、collect().to_bytes() 给的是连续的 Bytes:多于一帧时它们拷贝一次(请求体提取器)。hyper 自己写 chunked body 时也用 Chain(body 的解码与编码),那是交给 writev 的,不会到你手里。

修法

  • 定长字段:用 get_*、try_get_*(数据可能不够时用 try_get_*,见 数据不够时的 panic)、copy_to_slice。不要自己从 chunk() 里切。
  • 找分隔符:先 copy_to_bytes(remaining()) 变成一个连续的 Bytes,再在上面找、用 split_to 切。Chain 和 BufList 都覆盖了 copy_to_bytes:要的字节全在一段里时交给那一段,段是 Bytes 时就是 split_to,不拷贝;只有跨段时才分配一块新的拷进去(适配器 的「copy_to_bytes:不跨边界时不拷贝」)。上面输出的最后一列就是这个:两段时 true(拷了一次),一帧时 false。没有覆盖它的 Buf 用的是 默认实现,总是拷贝。
  • 流式读:像 hyper 那样,把读到的字节放进同一块 BytesMut,解析器只看这块连续的缓冲(读写缓冲)。
  • 想不拷贝、逐段扫描时,用 chunk() + advance(chunk.len()) 的循环,并自己处理分隔符跨段(\r 在上一段末尾、\n 在下一段开头)。不要指望 chunks_vectored 一次给出所有段:它只保证至少一段,Chain 上还会漏掉中间的数据(Buf 的 Trickle)。

怎么确认自己没踩

最直接的测试:把一条消息在每一个位置切成两段,用 Chain 接起来交给解析器,结果必须和不切时一样。

use bytes::{Buf, Bytes};

// 错误写法:只在 chunk() 里找第一行
fn first_line_buggy(buf: &impl Buf) -> Option<Bytes> {
  let c = buf.chunk();
  c.windows(2).position(|w| w == b"\r\n").map(|p| Bytes::copy_from_slice(&c[..p]))
}

// 改法:先变成连续的 Bytes 再找
fn first_line(buf: &mut impl Buf) -> Option<Bytes> {
  let mut all = buf.copy_to_bytes(buf.remaining());
  let p = all.windows(2).position(|w| w == b"\r\n")?;
  Some(all.split_to(p))
}

fn main() {
  let msg = Bytes::from_static(b"GET / HTTP/1.1\r\nHost: a\r\n");
  let want = Some(Bytes::from_static(b"GET / HTTP/1.1"));
  let (mut bad, mut good) = (Vec::new(), 0);
  // 在每一个位置把消息切成两段,用 Chain 接起来再解析
  for at in 0..=msg.len() {
    let mut buf = msg.slice(..at).chain(msg.slice(at..));
    if first_line_buggy(&buf) != want {
      bad.push(at);
    }
    if first_line(&mut buf) == want {
      good += 1;
    }
  }
  println!("split points: {}", msg.len() + 1);
  println!("buggy wrong at {} points: {:?}", bad.len(), bad);
  println!("fixed right at {} points", good);
}

跑 3 次,输出相同:

split points: 26
buggy wrong at 15 points: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
fixed right at 26 points

坏写法在 1 到 15 这 15 个切点上出错:切点落在请求行或 \r\n 之间(15 是 \r 和 \n 之间)。切点 0 时第一段是空的,Chain::chunk 直接给第二段,碰巧对了;16 以后整行都在第一段里,也对。只用整条消息测试,或者只在边界上测一两个位置,都发现不了。

此外:

  • 在解析代码里搜 .chunk():除了「逐段处理、advance(chunk.len())」的循环,每一处都要问一句「如果这里只有一部分数据呢」。
  • 解析函数的参数是 impl Buf 时,测试里至少用一次 Chain 或 VecDeque<u8>,不要只用 Bytes 和 &[u8]。

小结

  • chunk() 是「当前位置开始的一段」,长度在 0 到 remaining() 之间;Chain、collect().aggregate()、VecDeque<u8> 都只给第一段。
  • 把 chunk() 当全部数据,找分隔符会得到 None(读循环卡住),切定长字段会在你自己的代码里 panic。
  • hyper 的 Incoming 每帧是连续的 Bytes,但 body 分多帧到;aggregate() 不拼接,to_bytes() 和 axum 的 Bytes 提取器会拼。
  • 定长字段用 get_* / try_get_* / copy_to_slice;找分隔符先 copy_to_bytes(remaining()),Chain 和 BufList 只在跨段时才拷贝。
  • 测试时把消息在每个位置切成两段再解析。

💬 关联讨论

发起讨论
加载中...