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_*:两个宏,一条快路径」)。
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只在跨段时才拷贝。 - 测试时把消息在每个位置切成两段再解析。