Ориентация главы
Глава 90 остановилась на пустой строке в конце блока заголовков; тело начинается там — и «как тело кончается» — самый ошибкоопасный вопрос HTTP/1. Эта глава иллюстрирует три ограничения: фиксированная длина (Content-Length — прочитать ровно N и стоп), chunked (Transfer-Encoding: chunked — построчные длины кусков, завершение нулевым куском), закрытие соединения (без объявлений; закрытие — конец, семантика HTTP/1.0). Ядро дизайна — Plan прежде всего: xrtHttp1RequestBodyPlan/ResponseBodyPlan сперва выводят из заголовка «как читать» (с семантикой метода — HEAD/204 без тела), затем по Plan инициализируется потоковый читатель xrtHttp1BodyInit/Read/Done; сторона записи зеркально даёт ChunkWrite/ChunkEndWrite. Trailer (блок полей в хвосте chunked) и согласование TE (заявка возможностей клиента) — тоже здесь. Это полная специализация главы 72 для HTTP: машина состояний мельче, но скелет «инкрементально поглощать слипшиеся и недоехавшие пакеты» неизменен.
91.1引入
Прокси пересылает ответ на 2 ГБ: вверх по течению chunked, вниз клиент понимает только HTTP/1.0? Потоково читать и пересылать кусок за куском или сперва накопить 2 ГБ? Как серверу объявить ограничение для динамически генерируемого контента (общая длина неизвестна до отправки)? Как клиенту получить trailer в хвосте chunked (целостностные дайджесты через прокси)? — Ответы держатся на слое кадрирования: он решает, возможно ли «пересылать по мере прибытия» (память не зависит от размера тела), как выразить «динамическую длину» (chunked) и как получить «отложенные метаданные» (trailer).
Цена неверного кадрирования не абстрактна: двусмысленность ограничения — это контрабанда запросов (request smuggling) — два последовательных запроса в одном соединении по-разному понятны на границе; атакующий прячет второй запрос в «тело» первого и протаскивает через прокси. Что при одновременных CL и TE (обязан отказ), что при нескольких CL (обязан отказ) — контракты главы записывают эти правила в камень.
91.2概念
Три ограничения и вывод Plan
xrtHttp1ResponseBodyPlan(&Head, 方法, &Plan) (&Head, метод, &Plan) и RequestBodyPlan(&Head, &Plan) производят полное решение «как читать»: способ ограничения, общую длину (при фиксированной), допустимость trailer.Метод участвует в семантике: ответ HEAD и ответы 204/304 объявили CL — тела всё равно нет; Plan обнуляет по роли.Отказ от двусмысленности: CL вместе с TE, несколько несогласованных CL — Plan возвращает отказ (первый шлюз защиты от контрабанды стоит в слое вывода).
Потоковое чтение: машина состояний Body
xrtHttp1BodyRead(&Body, 输入视图, 是否终结, &消费, &数据视图, NULL) (&Body, входное представление, финальный ли, &потреблено, &представление данных, NULL) — ядро цикла: подаёте текущий буфер; возвращается одно из трёх — «данные» (заимствованное представление Data — прямое чтение без копий), «завершено», «ошибка», — а Consumed сообщает, сколько потреблено в этом раунде (оставшиеся байты принадлежат следующему сообщу/вне тела).Потолочная оборона (xhttp1bodylimits): MaxBody (общий объём тела), длина строки куска, число trailer — злобно гигантский кусок останавливается до входа. Trailer собирается через привязанный массив (передайте массив trailer в BodyInit), доступен после Done — та же форма, что у массива полей главы 90.
Сторона записи: генерация кусков и терминатора
xrtHttp1ChunkWrite(数据, 输出, 容量, &长度) (данные, вывод, ёмкость, &длина) генерирует полный кусок «16-ричная длина\r\n + данные\r\n»; xrtHttp1ChunkEndWrite(trailer数组, 数量, ...) (массив trailer, количество, ...) генерирует терминатор «0\r\n» с опциональным блоком trailer. Двумя инструментами собирается любой поток: динамический контент генерируется и отправляется по ходу (в паре с Vec-отправкой главы 68, амортизирующей системные вызовы); большие файлы циклом с фиксированным куском. Фиксированное тело проще — CL в заголовке, тело отправляется напрямую.Правило выбора: общая длина известна → CL (экономия строк по 6 байтов на кусок и стоимости разбора); неизвестна или потоковая генерация → chunked.
Trailer: метаданные после тела
chunked разрешает блок полей после терминатора — Digest (целостность содержимого), трассировочные ID через прокси — стандартное место отложенных метаданных. Две дисциплины: сторона чтения — превышение ёмкости массива trailer ошибка (та же логика, что у потолка полей); согласование на отправителя — только клиентский TE: trailers заявляет знание trailer (следующий раздел); сервер пользуется ими после подтверждения; не поддерживающему прокси легально их снять.
Согласование TE: заявка возможностей транспортного кодирования
xrtHttpTeParse(字段数组, 数量, &Info) (массив полей, количество, &Info) суммирует (повторяемые) поля TE: список принимаемых транспортных кодирований, флаг XHTTP_TE_ACCEPTS_TRAILERS (клиент заявляет знание trailer) и q-значения по кодированиям. xrtHttpTeQuality(字段, 数量, 编码名) (поля, количество, имя кодирования) возвращает целое в тысячных (gzip;q=0.5 → 500) — устраняя ошибки точности и платформенные расхождения сравнения плавающих. Сервер выбирает из этого приемлемое для обеих сторон кодирование; q=0 — явное «не надо». Модель согласования «целые q-значения + флаги» едина с другими семействами полей.
91.3示例
Первая полная программа: Plan, потоковое чтение + поблочная запись
Программа ниже из examples/http/http1_body/main.c — полный контур стороны чтения и стороны записи:
#include <stdio.h> #include <xrt.h> /* 解析响应分帧并以零拷贝视图逐段读取 chunked 正文。 */ static bool readResponseBody(void) { static const uint8 Message[] = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" "7\r\nchunked\r\n5\r\n body\r\n0\r\nDigest: ok\r\n\r\n"; xhttpfield Fields[8]; xhttpfield Trailers[4]; xhttp1bodylimits Limits; xhttp1bodyplan Plan; xhttp1head Head; xhttp1body Body; size_t iOffset; xrtHttp1HeadInit(&Head, Fields, 8); if ( xrtHttp1ResponseParse( (xbytesview){ Message, sizeof(Message) - 1u }, &Head, NULL, NULL ) != XHTTP1_READY ) { return false; } if ( !xrtHttp1ResponseBodyPlan( &Head, XRT_STR_LITERAL("GET"), &Plan ) ) { return false; } xrtHttp1BodyLimitsInit(&Limits); Limits.MaxBody = 1024; if ( !xrtHttp1BodyInit( &Body, &Plan, Trailers, 4, &Limits ) ) { return false; } iOffset = Head.Bytes; while ( !xrtHttp1BodyDone(&Body) ) { xbytesview Data; size_t iConsumed; xhttp1bodystatus Status = xrtHttp1BodyRead( &Body, (xbytesview){ Message + iOffset, sizeof(Message) - 1u - iOffset }, false, &iConsumed, &Data, NULL ); if ( Status == XHTTP1_BODY_DATA ) { fwrite(Data.Data, 1, Data.Size, stdout); } else if ( Status != XHTTP1_BODY_DONE ) { return false; } iOffset += iConsumed; } putchar('\n'); return true; } /* 直接生成完整 chunk,也可只生成 size 行后配合向量发送正文。 */ static bool writeResponseBody(void) { static const xhttpfield Trailers[] = { { XRT_STR_INIT("Digest"), XRT_STR_INIT("sha-256=:demo:") } }; uint8 Output[128]; size_t iChunk; size_t iEnd; if ( !xrtHttp1ChunkWrite( (xbytesview){ (cbytes)"hello", 5 }, Output, sizeof(Output), &iChunk ) || !xrtHttp1ChunkEndWrite( Trailers, 1, Output + iChunk, sizeof(Output) - iChunk, &iEnd ) ) { return false; } fwrite(Output, 1, iChunk + iEnd, stdout); return true; } */ /* 展示 HTTP/1 Body Plan、流式读取和分块写出。 */ int main(void) { if ( !readResponseBody() || !writeResponseBody() ) { return 1; } return 0; }
Что только что произошло. Сторона чтения (первая из четырёх строк вывода): (1) заголовок ответа объявляет Transfer-Encoding: chunked; после ResponseParse ResponseBodyPlan (с методом GET) выводит план chunked; BodyLimitsInit ставит MaxBody=1024, затем BodyInit привязывает массив trailer. (2) Вход — «7\r\nchunked\r\n5\r\n body\r\n0\r\nDigest: ok\r\n\r\n» — цикл BodyRead выдаёт отрезки chunked и body (печать склейкой chunked body); по прибытии куска 0 trailer Digest: ok падает в привязанный массив, состояние DONE. Ноль копий: представление Data указывает прямо в исходный буфер.Сторона записи (последние три строки): (3) ChunkWrite("hello") даёт 5\r\nhello\r\n (печать 5 и hello двумя строками), ChunkEndWrite(带 Digest trailer) (с trailer Digest) даёт 0\r\nDigest: sha-256=:demo:\r\n\r\n (первая строка 0) — последовательность байтов, порождаемая стороной записи, в точности совпадает с форматом, потребляемым стороной чтения: это HTTP-версия тезиса главы 72 «читающая и пишущая стороны делят одну машину состояний кадрирования».
Вторая полная программа: чтение согласования TE
Вторая программа из examples/http/te/main.c — сводный запрос возможностей клиента:
#include <xrt.h> #include <stdio.h> */ /* 汇总重复 TE 字段并读取客户端的传输能力。 */ int main(void) { static const xhttpfield Fields[] = { { XRT_STR_INIT("TE"), XRT_STR_INIT("trailers") }, { XRT_STR_INIT("TE"), XRT_STR_INIT("gzip;q=0.5") } }; xhttpteinfo Info; if ( !xrtHttpTeParse(Fields, 2u, &Info) ) { return 1; } printf( "codings=%zu trailers=%s gzip=%u\n", Info.TransferCodingCount, (Info.Flags & XHTTP_TE_ACCEPTS_TRAILERS) != 0 ? "yes" : "no", (unsigned int)xrtHttpTeQuality( Fields, 2u, XRT_STR_LITERAL("gzip") ) ); return 0; }
Что только что произошло. (1) Два поля TE (HTTP разрешает повторение) суммируются TeParse в одну capability: транспортное кодирование одно (gzip — trailers это флаг возможности, не кодирование), приём trailer подтверждён. (2) TeQuality("gzip") возвращает 500 — целое-в-тысячных форма q=0.5; ловушки точности плавающих переговоров (семейство 0.1+0.2≠0.3) уничтожены на уровне представления. (3) Цепочка решения сервера: trailers=yes → можно слать Digest в хвосте chunked; gzip=500 → gzip пригоден, но с низким приоритетом. Сопроводительные examples/http1/variants и examples/http/trailer покрывают варианты ограничения и детали trailer — они пригодятся в упражнениях главы.
91.4契约
- Plan прежде всего: перед чтением тела обязателен Plan (вывод из заголовка); метод участвует в семантике (на стороне ответа HEAD/204/304 без тела); двусмысленное ограничение (CL+TE вместе, несогласованные CL) отвергается в слое Plan — первый шлюз против контрабанды.
- Три состояния Body: DATA (заимствованное представление Data + Consumed) / DONE (ограничение выполнено) / ERROR; цикл подачи до DONE; на ошибке — стоп.
- Потолочная оборона: три линии — MaxBody/длина строки куска/число trailer; отказ сверх предела до потребления памяти — потолки суть решение развёртывания.
- Сбор trailer: в привязанный массив; превышение ёмкости — ошибка; доступ после Done; сторона чтения не проверяет «заявил ли клиент trailers» (это переговоры отправителя).
- Симметрия записи: ChunkWrite (кусок) + ChunkEndWrite (терминатор + опциональный trailer); общий формат с машиной чтения; атомарность ёмкости как в главе 88.
- Выбор ограничения: длина известна → CL; потоковая генерация → chunked; серверу 1.1 голое закрытие запрещено.
- Согласование TE: повторяемые поля суммируются; q целым-в-тысячных (без плавающих); флаг
ACCEPTS_TRAILERS; неупомянутые кодирования q=0. - Ноль копий: представление Data заимствует вход; тело минует транзитный буфер — «пересылка по мере прибытия» держит память независимой от размера тела.
- Слой Message:
MessageParseкомбинирует заголовок+тело за один проход (удобный путь малых сообщений); потоковые сценарии — семейство Body.
Типичные ошибки
Симптом: самодельный прокси обрабатывает запрос с Content-Length и Transfer-Encoding одновременно по CL, бэкенд — по TE; следующий запрос соединения «спрятан» в тело: контрабанда запросов.
Причина: два ограничения сразу — двусмысленный вход; расхождение пониманий фронта и тыла — сама конструкция атаки. Единственное безопасное действие — отказ.
if ( has_content_length(Head) ) { plan_as_fixed(...); /* я читаю по CL */ } else { plan_as_chunked(...); /* если нижний поток понимает наоборот: граница сместилась -> окно контрабанды */ }
/* отказ встроен в слой Plan: пусть говорит результат вывода */ if ( !xrtHttp1RequestBodyPlan(&Head, &Plan) ) { return reject("ambiguous framing"); /* двусмысленное ограничение: единственно безопасный ответ */ }
Симптом: потоковый прокси теряет байты или пересылает дважды — старт каждой подачи неверен.
Причина: вход BodyRead — «с какого места начать, решаете вы»: сколько потреблено (iConsumed) обязано накапливаться в смещении следующего раунда; непотреблённый хвост буфера может принадлежать следующему сообщению (pipelining).
size_t iOff = Head.Bytes; while ( !Done ) { BodyRead(&Body, view(In), false, &iUsed, &Data, NULL); forward(Data); /* iUsed потерян: следующий раунд снова с прежнего смещения */ }
size_t iOff = Head.Bytes; while ( !xrtHttp1BodyDone(&Body) ) { xhttp1bodystatus St = xrtHttp1BodyRead(&Body, (xbytesview){ In.Data + iOff, In.Size - iOff }, bFin, &iUsed, &Data, NULL); iOff += iUsed; /* потребление подключено к смещению */ if ( St == XHTTP1_BODY_DATA ) { forward(Data); } else if ( St != XHTTP1_BODY_DONE ) { return false; } } /* данные в In за iOff принадлежат следующему сообщению */
Симптом: чтобы написать Content-Length, весь динамический ответ копится в памяти — экспортный интерфейс на 2 ГБ валивает сервер в OOM.
Причина: CL требует знания общей длины до отправки. При потоковой генерации контента единственно верное решение — chunked: каждый кусок отправляется по мере генерации, память не зависит от объёма.
buffer_whole_response(&Body); /* накопили все 2 ГБ */ set_content_length(Body.Size); send_all(Body);
send_header_chunked(); while ( next_chunk(&Data) ) { xrtHttp1ChunkWrite(Data, Out, sizeof(Out), &iSize); send(Out, iSize); /* генерируем и отправляем по ходу */ } xrtHttp1ChunkEndWrite(Trailers, 1, Out, sizeof(Out), &iSize); send(Out, iSize); /* кусок 0 + trailer в финале */
Практические задания
Постройте три байтовых потока ответов (CL=11, chunked с двумя кусками, без объявлений); Plan + Body каждое до DONE, печатая последовательности действий (раунды потребления/отрезки данных). Критерии приёмки: три вывода сходятся с ручной выкладкой; у группы без объявлений «ограничение закрытием» даёт DONE при bFin=true (закрытие противоположной стороны).
Постройте четыре злых образца: CL+TE вместе, несогласованные двойные CL, сверх MaxBody, сверхдлинная строка куска — по одному проверьте отказ на уровне Plan/Body. Занесите уровень отказа каждого образца в таблицу. Критерии приёмки: все четыре отвергнуты; вы можете указать, в каком слое конвейера разбора стоит каждый шлюз.
Ответ вверх по течению chunked, кусками: Body читает потоково → каждый отрезок данных «переводится в верхний регистр» → ChunkWrite перекусывает (фиксированный кусок 16 байтов) → пересылка вниз; trailer пересылается дословно (пересчёт Digest опционален). Критерии приёмки: память не зависит от общего объёма вверх по течению (сверка со статистикой главы 6); границы кусков вверх/вниз различаются (7+5 на входе, 16×N на выходе), а семантика сохранна; при обрыве верхнего потока нижний получает чистую ошибку, а не полкадра.
Итоги
| Тема | Кратко |
|---|---|
| Три ограничения | фиксированная длина CL / chunked (завершение куском 0) / закрытием (семантика 1.0) |
| Plan прежде всего | вывод из заголовка + метода; HEAD/204 без тела; двусмысленное ограничение (CL+TE, несогласованный CL) отвергается |
| Три состояния Body | DATA (представление + Consumed) / DONE / ERROR; смещение потребления обязано накапливаться |
| Три линии потолков | MaxBody / длина строки куска / число trailer; отказ до потребления; решение развёртывания |
| Trailer | блок полей после терминатора; сбор в привязанный массив; отправителю нужен клиентский TE trailers |
| Пишущая пара | ChunkWrite (строка длины + данные) / ChunkEndWrite (кусок 0 + trailer); общий формат с машиной чтения |
| Выбор ограничения | длина известна → CL; поток → chunked; серверу 1.1 голое закрытие запрещено |
| Согласование TE | повторяемые поля суммируются; q целым-в-тысячных; флаг ACCEPTS_TRAILERS; неупомянутое q=0 |
| Ноль копий | Data заимствует вход; пересылка по мере прибытия держит память вне зависимости от размера тела |
| Слой Message | заголовок + тело за один проход (малые сообщения); семейство Body — потоковый примитив |
📖 Справочники API в этой главе:http1 (39 записей)http_te (16 записей)http (72 записей)
Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки