Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

HTTP и веб-контент

senior~140 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

HTTP и веб-контент

В прошлом уроке эхо-сервер принимал строку и отправлял её обратно. Протокол у него был один: что пришло, то и ушло. Веб устроен точно так же, только строки договорные. Клиент пишет в сокет несколько строк по заранее известным правилам, сервер читает их, решает, какой файл отдать или какую программу запустить, и пишет в тот же сокет ответ, тоже по правилам. Вот и весь HTTP: соглашение о том, какие байты идут по соединению и в каком порядке. Сегодня мы прочитаем эти байты глазами: наберём запрос руками в nc, посмотрим, что отвечает настоящий сервер и наш будущий TINY, разберём, как URL превращается в имя файла или в запуск программы, и как программа, ничего не знающая про сеть, отвечает клиенту через dup2 из урока про переадресацию. В конце шаг проекта: разбор запроса на Zig, тот самый parse_uri из книги, и одна дыра в нём, которую книга оставила открытой.

Цели урока

  • Разобрать URL на части и сказать, какая часть нужна клиенту для соединения, какая уходит серверу в запросе, а какая не покидает браузер вовсе.
  • Прочитать транзакцию HTTP построчно: строка запроса, заголовки, пустая строка, тело; строка статуса, заголовки, пустая строка, тело.
  • Набрать запрос руками через nc и понять вывод curl -v.
  • Знать коды 200, 301, 400, 403, 404, 501, 505 и чем HTTP/1.0 отличается от HTTP/1.1 для сервера на двести строк.
  • Объяснить, как сервер выбирает между статическим и динамическим контентом и как тип содержимого получается из расширения.
  • Объяснить CGI целиком: аргументы в QUERY_STRING, метод и длина тела в окружении, тело в стандартном вводе, вывод через dup2(connfd, 1) прямо в сокет, и почему программа сама пишет Content-length.
  • Увидеть, чем опасен буфер вывода у сервера, который делает fork, и почему CGI-программе буфер не мешает.
  • Написать разбор строки запроса, заголовков и URI на Zig без аллокатора и закрыть обход корня через ...

Идея: веб это соглашение о байтах в сокете

Всё, что умеет браузер, начинается с того же, что делал клиент эха: getaddrinfo по имени хоста из урока про адреса и имена, connect на порт, запись нескольких строк, чтение ответа. Сервер начинает с того же, что эхо-сервер: accept, чтение строк, запись ответа. Разница только в том, что обе стороны заранее договорились, что значат строки.

Договор называется HTTP. Смысл методов, кодов и заголовков описан в RFC 9110, раскладка байт по проводу для версии 1.1 в RFC 9112. С точки зрения бэкенда мы уже разбирали его в уроке про основы HTTP: методы и их обещания, коды ответов, заголовки, куки. Здесь другой ракурс. Нас интересует не что HTTP значит для приложения, а что с ним делает программа, у которой есть только дескриптор и read. Отсюда вопросы, которых бэкендер обычно не задаёт: где кончается запрос, если read вернул кусок? Сколько байт тела ждать? Как сервер узнаёт размер ответа программы, которую он сам запустил?

Отвечать будем на примере TINY, веб-сервера из главы 11 книги. Его полный разбор в следующем уроке, а сегодня он работает для нас чёрным ящиком: мы шлём ему байты и смотрим, какие байты он возвращает. Сервер уже лежит в эталоне и запускается так:

$ zig build
$ zig-out/bin/tiny tiny 8065 zig-out
tiny: listening on port 8065, root zig-out

Первый аргумент это порт, второй корень: каталог, из которого сервер отдаёт файлы. zig build кладёт туда страницу home.html, картинку dot.png, текст hello.txt и программу cgi-bin/adder, про которую ниже.

Контент: байты плюс тип

Сервер отдаёт клиенту контент: последовательность байт и её тип. Байты без типа бесполезны: браузер получил 74 байта, это текст, картинка или архив? Тип записывается как MIME-тип вида тип/подтип и уходит в заголовке Content-Type. Вот типы, которые знает наш TINY:

MIME-типЧто этоРасширения в TINY
text/htmlстраница HTML.html, .htm
text/plainпростой текствсё незнакомое
image/gifкартинка GIF.gif
image/pngкартинка PNG.png
image/jpegкартинка JPEG.jpg, .jpeg
video/mp4видео.mp4
text/css, text/javascript, application/jsonстили, скрипты, данные.css, .js, .json

Обрати внимание на правую колонку. Файловая система ничего не знает о типах, у файла есть только имя и байты. Тип придумывает сервер, и самый простой способ это расширение имени. Так делают TINY, nginx (файл mime.types) и почти все статические серверы. Браузер со своей стороны верит заголовку, а не расширению в URL: отдай PNG с Content-Type: text/plain, и он покажет мусор из байт. Мы проверим это в упражнениях.

По тому, откуда берутся байты, контент бывает двух видов.

  • Статический. Байты лежат в файле на диске, сервер читает файл и отправляет его целиком. Страницы, картинки, скрипты, видео. Ответ на один и тот же запрос одинаков, пока файл не поменяли.
  • Динамический. Байты порождает программа, которую сервер запускает на каждый запрос, а её вывод отдаёт клиенту. Ответ зависит от аргументов, времени, базы данных.

Каждый кусок контента, статический или динамический, связан с каким-то файлом на сервере: в первом случае это сам файл, во втором программа. Значит, по запросу сервер должен найти файл. Об этом следующий раздел.

URL: где лежит и кто отдаст

Адрес контента называется URL, его общий синтаксис описан в RFC 3986. Разберём один адрес, http://localhost:8000/docs/hello%20world.txt?q=zig%26c#top:

ЧастьЗначение
схемаhttp
хостlocalhost
порт8000
путь/docs/hello%20world.txt
запросq=zig%26c
фрагментtop

Каждая часть нужна своему участнику.

  • Схема, хост и порт нужны клиенту, чтобы открыть соединение. Схема задаёт протокол и порт по умолчанию (80 для http, 443 для https), хост уходит в getaddrinfo, порт в connect. Серверу после соединения они уже не нужны, он и так знает, где стоит.
  • Путь и запрос клиент отправляет серверу в строке запроса. Как их понимать, решает только сервер. RFC ничего не говорит о файлах и каталогах: путь это просто строка, а соответствие между ней и диском придумывает программа.
  • Фрагмент после # не покидает браузер. Это место на странице, куда прокрутить, и сервер его никогда не видит.

Для TINY книга договаривается так: корень сервера это текущий каталог, ./; путь, в котором встречается cgi-bin, указывает на программу, всё остальное на статический файл; путь, кончающийся на /, получает имя по умолчанию home.html. После ? идут аргументы программы, разделённые &.

Символы, которым в URL нельзя стоять буквально (пробел, ?, &, #, кириллица), кодируются через процент: %20 это пробел, %26 амперсанд. В примере выше q=zig%26c значит одну пару «ключ, значение» со значением zig&c, а не две пары.

В стандартной библиотеке Zig разбор URL уже есть: std.Uri.parse. Он режет строку на части и ничего не раскодирует сам. Каждая часть приходит как Component, объединение двух вариантов: .raw (текст как есть, без процентов) и .percent_encoded (как пришло, с процентами); раскодировать просят явно, методом toRaw с буфером.

const std = @import("std");

fn show(name: []const u8, component: ?std.Uri.Component) void {
    var buf: [256]u8 = undefined;
    const c = component orelse return std.debug.print("{s:<8} (нет)\n", .{name});
    const text = switch (c) {
        .raw, .percent_encoded => |s| s,
    };
    const raw = c.toRaw(&buf) catch "?";
    std.debug.print("{s:<8} {s:<24} раскодировано: {s}\n", .{ name, text, raw });
}

pub fn main() !void {
    const uri = try std.Uri.parse("http://localhost:8000/docs/hello%20world.txt?q=zig%26c#top");
    std.debug.print("{s:<8} {s}\n", .{ "scheme", uri.scheme });
    show("host", uri.host);
    std.debug.print("{s:<8} {?d}\n", .{ "port", uri.port });
    show("path", uri.path);
    show("query", uri.query);
    show("fragment", uri.fragment);
}
$ zig run urlparts.zig
scheme   http
host     localhost                раскодировано: localhost
port     8000
path     /docs/hello%20world.txt  раскодировано: /docs/hello world.txt
query    q=zig%26c                раскодировано: q=zig&c
fragment top                      раскодировано: top

Запомни, что разбор и раскодирование это два разных шага. К этому вернёмся, когда будем закрывать дыру в parse_uri: порядок проверок там решает всё.

Транзакция HTTP руками

HTTP текстовый, поэтому запрос можно набрать руками. Книга делает это через telnet, нам удобнее nc, он есть и на macOS, и в любом Linux. Спросим главную страницу у www.aol.com, как в книге:

$ printf 'GET / HTTP/1.1\r\nHost: www.aol.com\r\n\r\n' | nc www.aol.com 80
HTTP/1.1 301 Moved Permanently
Server: CloudFront
Date: Sat, 26 Sep 2026 11:32:49 GMT
Content-Type: text/html
Content-Length: 167
Connection: keep-alive
Location: https://www.aol.com/
X-Cache: Redirect from cloudfront
Via: 1.1 1c7275102c069b3b4bff7bcc191ded2e.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: FRA56-P6
Alt-Svc: h3=":443"; ma=86400
X-Amz-Cf-Id: Vm_zy2PJxMXAKV1k59pxzwmIWzEVf5cfbOn0S0YRtjSKh1yTIPZtPg==
X-XSS-Protection: 1; mode=block
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>

Мы отправили 37 байт и получили ответ. Разберём обе стороны.

Запрос

Запрос это строка запроса, за ней ноль или больше строк заголовков, за ними пустая строка, за ней необязательное тело. Каждая строка кончается парой байт \r\n, возвратом каретки и переводом строки (CRLF). Так завещали телетайпы, и так требует RFC 9112. Терпимый сервер, как наш, принимает и одинокий \n: стандарт это разрешает.

Строка запроса состоит из трёх слов через пробел: метод URI версия.

  • Метод говорит, что сделать с ресурсом. GET просит контент, HEAD просит то же, но без тела (только заголовки), POST отправляет данные в теле. Есть ещё PUT, DELETE, OPTIONS и другие, их смысл разобран в уроке про основы HTTP. TINY знает первые три, на остальные отвечает 501.
  • URI здесь это путь с запросом, / или /cgi-bin/adder?15&213, без схемы и хоста: соединение уже открыто, хост известен. Такая форма зовётся origin-form. Исключение это запрос к прокси: ему клиент пишет полный URL, GET http://www.aol.com/ HTTP/1.1, потому что прокси сам должен решить, куда соединяться. До прокси мы доберёмся в следующем блоке.
  • Версия это HTTP/1.0 или HTTP/1.1. Версии 2 и 3 уже не текстовые, в них те же методы и заголовки упакованы в двоичные кадры, см. урок про эволюцию HTTP.

Заголовки это пары Имя: значение, по одной на строку. Имена нечувствительны к регистру: Host, host и HOST это один заголовок. В нашем запросе он один, Host, и в HTTP/1.1 он обязателен: на одном адресе и порту живут тысячи сайтов, и только по этому заголовку сервер узнаёт, к какому из них пришли. Сервер HTTP/1.1, получивший запрос без Host, обязан ответить 400.

Пустая строка означает конец заголовков. Это единственный способ узнать, где они кончились: read из урока про короткие счёты может вернуть пол-заголовка или три заголовка сразу, поэтому сервер читает строками через буфер, как в уроке про буферизованный ввод, пока не встретит пустую.

Тело идёт после пустой строки. У GET его обычно нет, у POST есть, и его длину сообщает заголовок Content-Length. Без него сервер не знал бы, сколько байт ждать: соединение ведь не закрыто, клиент ждёт ответа.

Ответ

Ответ устроен зеркально: строка статуса, заголовки, пустая строка, тело.

Строка статуса это версия код фраза: HTTP/1.1 301 Moved Permanently. Код трёхзначный, первая цифра задаёт класс, фраза для людей, программы её не читают.

КодФразаКогда
200OKвсё хорошо, контент в теле
301Moved Permanentlyресурс переехал, новый адрес в заголовке Location
400Bad Requestсервер не понял запрос: кривая строка, кривые заголовки
403Forbiddenпонял, но отдавать не будет: нет прав на файл, путь за пределами корня
404Not Foundтакого файла нет
501Not Implementedметод серверу неизвестен
505HTTP Version Not Supportedсервер не говорит на этой версии

Этого хватает для TINY. Полная картина классов 1xx до 5xx разобрана в уроке про основы HTTP; наш сервер добавит к таблице только 413 (тело слишком большое), 414 (URI не влез в буфер) и 431 (заголовки слишком большие), и все три это защита от клиента, который шлёт гигабайты.

Заголовки ответа. Для нас важны три. Content-Type это MIME-тип тела. Content-Length это его длина в байтах: без неё клиент не узнает, где кончился ответ, если соединение остаётся открытым. Connection: keep-alive обещает, что соединение не закроется после ответа и по нему можно слать следующий запрос. Наш nc следующих не шлёт: его ввод кончился, и соединение закрывается.

Сам ответ 301 говорит «иди на https://www.aol.com/»: сайт давно работает только через TLS, а http на порту 80 оставлен ради таких переадресаций. Браузер, получив 301, сам откроет новое соединение по адресу из Location.

Тот же разговор с TINY

Теперь то же самое с нашим сервером, только через curl -v: он печатает отправленные строки со значком >, полученные со значком <, а свои комментарии со значком *.

$ curl -v http://localhost:8065/home.html
* Host localhost:8065 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8065...
* connect to ::1 port 8065 from ::1 port 57645 failed: Connection refused
*   Trying 127.0.0.1:8065...
* Connected to localhost (127.0.0.1) port 8065
> GET /home.html HTTP/1.1
> Host: localhost:8065
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
* HTTP 1.0, assume close after body
< HTTP/1.0 200 OK
< Server: Tiny Web Server
< Connection: close
< Content-length: 114
< Content-type: text/html
<
{ [114 bytes data]
* Closing connection
<html>
<head><title>test</title></head>
<body>
<img align="middle" src="dot.png">
Dave O'Hallaron
</body>
</html>

Первые строки ты уже видел в уроке про сокеты изнутри: getaddrinfo вернул для localhost два адреса, IPv6 и IPv4, и curl перебирает их, как openClientfd. На ::1 никто не слушает (наш сервер открыл только IPv4), второй адрес подошёл. Дальше запрос: строка, три заголовка, пустая строка. curl спросил по HTTP/1.1, сервер ответил по HTTP/1.0, и curl честно отметил: assume close after body, «после тела соединение закроется».

Так и задумано. TINY говорит на HTTP/1.0: одно соединение на один запрос, ответил и закрыл. Это самый простой вариант для итеративного сервера. Он обслуживает клиентов по одному, и клиент, который держал бы соединение открытым ради следующего запроса, заблокировал бы всех остальных. Заголовок Connection: close честно об этом предупреждает. Постоянные соединения, keep-alive по умолчанию и передача тела кусками (Transfer-Encoding: chunked) появились в HTTP/1.1, и зачем они нужны, рассказано в уроке про транспорт под HTTP. Серверу, который их поддерживает, пришлось бы разбирать границы запросов внутри одного соединения, а нам хватает Content-length и close.

Ошибки выглядят так же, только с другим кодом и страницей от сервера в теле. Нет файла:

$ curl -i http://localhost:8065/nope.html
HTTP/1.0 404 Not Found
Server: Tiny Web Server
Connection: close
Content-length: 150
Content-type: text/html

<html><title>Tiny Error</title><body bgcolor="ffffff">
404: Not Found
<p>Tiny couldn't find this file
<hr><em>The Tiny Web server</em>
</body></html>

Незнакомый метод (-X подменяет метод в строке запроса):

$ curl -i -X DELETE http://localhost:8065/home.html
HTTP/1.0 501 Not Implemented
Server: Tiny Web Server
Connection: close
Content-length: 163
Content-type: text/html

<html><title>Tiny Error</title><body bgcolor="ffffff">
501: Not Implemented
<p>Tiny does not implement this method
<hr><em>The Tiny Web server</em>
</body></html>

Мусор вместо строки запроса, руками через nc:

$ printf 'GARBAGE\r\n\r\n' | nc localhost 8065
HTTP/1.0 400 Bad Request
Server: Tiny Web Server
Connection: close
Content-length: 146
Content-type: text/html

<html><title>Tiny Error</title><body bgcolor="ffffff">
400: Bad Request
<p>malformed request line
<hr><em>The Tiny Web server</em>
</body></html>

И HEAD: заголовки те же, что у GET, Content-length честный, а тела нет. Так клиент узнаёт размер и тип, не скачивая файл.

$ curl -I http://localhost:8065/dot.png
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 74
Content-type: image/png

Пока всё это делает сервер, а мы смотрим со стороны. Сервер пишет в свой stderr по строке на транзакцию: адрес клиента, строка запроса, код и число байт тела.

tiny: listening on port 8065, root zig-out
127.0.0.1:57650 "GET /home.html HTTP/1.1" 200 114
127.0.0.1:57652 "GET /cgi-bin/adder?15&213 HTTP/1.0" 200 0
127.0.0.1:57654 "GET /nope.html HTTP/1.1" 404 0
127.0.0.1:57656 "DELETE /home.html HTTP/1.1" 501 0
127.0.0.1:57658 "GET /../etc/passwd HTTP/1.1" 403 0
127.0.0.1:57660 "HEAD /dot.png HTTP/1.1" 200 74

Две строки лога мы ещё не объяснили: запрос к /cgi-bin/adder, у которого тело ноль байт, хотя ответ явно был, и 403 на /../etc/passwd. Обе разберём ниже.

Виджет: транзакция глазами TINY

Собери запрос сам. Слева выбираешь метод, URI (пресеты под полем: страница, корень, картинка, программа с аргументами, несуществующий файл) и какие заголовки отправить. Справа виджет показывает запрос байт в байт, с \r\n на концах строк и подписью к каждой строке. Ниже ответ TINY, тоже построчно и с подписями, а под спойлером «что сделал doit» шаги сервера: как он разобрал URI, что вернул stat, куда пошёл дальше.

Попробуй так:

  • Выбери HEAD при выключенной галочке расширений. Это TINY из книги, он знает только GET и ответит 501. Включи галочку: тот же запрос даст заголовки без тела.
  • Выбери POST к /cgi-bin/adder и поменяй тело. В запросе появится Content-Length, а аргументы adder возьмёт из тела вместо ?.
  • Сними все заголовки. TINY ответит как ни в чём не бывало: книжный сервер заголовки читает и выбрасывает. Настоящий сервер HTTP/1.1 без Host ответил бы 400.
  • Введи /secret.html: файл есть, но прав на чтение нет, это 403. А /cgi-bin/nope даст 404 ещё до запуска программы.

Виджет моделирует TINY из книги, поэтому проверки на .. в нём нет, как и в книге. Наша версия на Zig такой запрос отвергнет, и ниже ты увидишь, почему это важно.

Динамический контент: CGI

Со статикой понятно: нашёл файл, отправил. Программа же требует ответов на три вопроса. Как передать ей аргументы из запроса? Как передать остальное, что она может захотеть узнать (метод, длину тела, адрес клиента)? Куда девать её вывод? Стандартный ответ тридцатилетней давности называется CGI, и он целиком собран из того, что ты уже знаешь по блоку про процессы.

Аргументы. В GET они стоят в URI после ?: /cgi-bin/adder?15&213. Сервер отрезает хвост и кладёт его в переменную окружения QUERY_STRING=15&213. Не в argv, а именно в окружение: так договорились, и программе не надо разбирать командную строку.

Остальные сведения тоже едут в окружении. Самые ходовые:

ПеременнаяЧто в ней
QUERY_STRINGаргументы после ?
REQUEST_METHODGET, HEAD или POST
CONTENT_LENGTHдлина тела запроса в байтах
CONTENT_TYPEMIME-тип тела, например application/x-www-form-urlencoded
SERVER_PORT, SERVER_NAMEгде слушает сервер
REMOTE_ADDRадрес клиента

Наш TINY передаёт первые три. RFC 3875 перечисляет ещё десяток, и настоящие серверы передают их все.

Тело POST программа читает из стандартного ввода, ровно CONTENT_LENGTH байт. Сервер подключает к её дескриптору 0 пайп и пишет туда тело. Как это устроено у TINY, в следующем уроке.

Вывод уходит прямо в сокет. Сервер делает fork, в ребёнке dup2(connfd, 1) и execve программы. Всё, что программа печатает в стандартный вывод, оказывается в соединении с клиентом, и сервер эти байты даже не видит. Это тот же трюк, что в конце урока про переадресацию: tr отвечала по сокету, не зная про сокеты. Вся механика fork и execve разобрана в уроке про процессы, а почему execve сохраняет таблицу дескрипторов и заменяет всё остальное, в уроке про отображение памяти.

Кто пишет какой заголовок

Раз вывод программы уходит в сокет мимо сервера, ответ собирается из двух половин. Сервер пишет строку статуса и Server, программа пишет остальные заголовки, пустую строку и тело. Вот что печатает adder, если запустить его руками, без сервера:

$ QUERY_STRING='15&213' zig-out/cgi-bin/adder
Connection: close
Content-length: 107
Content-type: text/html

Welcome to add.com: THE Internet addition portal.
<p>The answer is: 15 + 213 = 228
<p>Thanks for visiting!

А вот что получает клиент от TINY:

$ printf 'GET /cgi-bin/adder?15&213 HTTP/1.0\r\n\r\n' | nc localhost 8065
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 107
Content-type: text/html

Welcome to add.com: THE Internet addition portal.
<p>The answer is: 15 + 213 = 228
<p>Thanks for visiting!

Первые две строки от сервера, остальное от adder. Теперь понятна строка лога 200 0: сервер записал ноль байт тела, тело писал не он.

Отсюда же ответ на вопрос, почему Content-length пишет программа. Размер ответа знает только она. Сервер мог бы узнать его, лишь прочитав весь вывод программы через пайп, а он туда не смотрит. У TINY есть запасной выход, Connection: close: клиент HTTP/1.0 может читать тело до закрытия соединения. Но честная длина позволяет клиенту отличить полный ответ от оборванного, поэтому adder её считает: сначала собирает тело в буфер, потом печатает длину, потом тело.

Настоящие серверы вроде Apache делают иначе: читают вывод программы через пайп, разбирают её заголовки (включая особый Status:), сами пишут строку статуса и досчитывают длину. RFC 3875 называет такой вывод разбираемым сервером. Вариант TINY, где программа пишет почти всё сама, ближе к выводу без разбора (в RFC это NPH, non-parsed header), только половину заголовка всё же пишет сервер. Он проще и быстрее, но программа должна знать протокол.

Буфер сервера и fork

Поставь себя на место сервера. Он пишет в сокет свою половину, HTTP/1.0 200 OK и Server, делает fork, ребёнок запускает adder. Что, если сервер пишет через буферизованный Writer и не сбросил буфер перед fork? В уроке про переадресацию несброшенный буфер при fork удваивался. Здесь сценарий другой, потому что после fork сразу идёт execve. Проверим. Сокетом клиента будет socketpair, а буфер сделаем явным, Writer над массивом, сброс это write в сокет.

const std = @import("std");
const c = std.c;

const adder = "./zig-out/cgi-bin/adder";

/// Один запуск CGI: половина заголовка от «сервера», остальное от adder.
/// `flush_before_fork = false` повторяет ошибку: заголовок ждёт в буфере.
fn runCgi(flush_before_fork: bool) void {
    // sv[1] играет роль connfd, из sv[0] читает «клиент».
    var sv: [2]c.fd_t = undefined;
    if (c.socketpair(c.AF.UNIX, c.SOCK.STREAM, 0, &sv) != 0) return;

    // Буфер в памяти процесса, как у любого Writer: сброс это write в сокет.
    var buf: [256]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    out.writeAll("HTTP/1.0 200 OK\r\nServer: Tiny Web Server\r\n") catch return;
    if (flush_before_fork) {
        _ = c.write(sv[1], out.buffered().ptr, out.buffered().len);
        out.end = 0;
    }

    // Окружение ребёнка готовим до fork.
    const argv = [_:null]?[*:0]const u8{adder};
    const envp = [_:null]?[*:0]const u8{ "QUERY_STRING=15&213", "REQUEST_METHOD=GET" };

    const pid = c.fork();
    if (pid == 0) {
        _ = c.dup2(sv[1], 1); // stdout ребёнка теперь сокет
        _ = c.close(sv[0]);
        _ = c.close(sv[1]);
        _ = c.execve(adder, &argv, &envp);
        c._exit(127);
    }
    _ = c.waitpid(pid, null, 0);

    // Опоздавший сброс: заголовок уходит в сокет после ответа adder.
    _ = c.write(sv[1], out.buffered().ptr, out.buffered().len);
    _ = c.close(sv[1]);

    var reply: [1024]u8 = undefined;
    var len: usize = 0;
    while (len < reply.len) {
        const n = c.read(sv[0], reply[len..].ptr, reply.len - len);
        if (n <= 0) break;
        len += @intCast(n);
    }
    _ = c.close(sv[0]);
    std.debug.print("{s}", .{reply[0..len]});
}

pub fn main() void {
    std.debug.print("--- flush до fork\n", .{});
    runCgi(true);
    std.debug.print("--- flush после waitpid\n", .{});
    runCgi(false);
}

Запускать надо из каталога эталона, где лежит собранный adder:

$ zig run cgirun.zig
--- flush до fork
HTTP/1.0 200 OK
Server: Tiny Web Server
Connection: close
Content-length: 107
Content-type: text/html

Welcome to add.com: THE Internet addition portal.
<p>The answer is: 15 + 213 = 228
<p>Thanks for visiting!
--- flush после waitpid
Connection: close
Content-length: 107
Content-type: text/html

Welcome to add.com: THE Internet addition portal.
<p>The answer is: 15 + 213 = 228
<p>Thanks for visiting!
HTTP/1.0 200 OK
Server: Tiny Web Server

Удвоения нет: копия буфера в ребёнке погибла вместе со всем его адресным пространством в execve, adder про неё не знает. Зато порядок сломан. Половина сервера легла в сокет позже, чем вся половина программы, и клиент получил ответ, который начинается с Connection: close. Браузер назовёт это ошибкой протокола. Правило из урока 62 работает и здесь, только ставки выше: flush перед fork обязателен.

Ещё одна деталь листинга: argv и envp собраны до fork. У нас они статические, а в настоящем сервере строка QUERY_STRING=... собирается из запроса, и собирать её после fork нельзя по той же причине, что звать printf из обработчика сигнала: в многопоточном процессе ребёнок наследует только один поток, и аллокатор может оказаться с чужой блокировкой, взятой в момент fork. Список безопасных функций тот же, что в уроке про сигналы. Книга делает setenv в ребёнке, и однопоточному TINY это сходит с рук, а мы сразу привыкаем делать правильно.

И последнее про окружение. Книга передаёт ребёнку environ сервера целиком плюс QUERY_STRING. Мы передаём только то, что нужно CGI: три переменные и ничего больше. Программа, которую запускают по запросу незнакомца, не должна видеть ни PATH сервера, ни его секретов из переменных окружения.

А буфер самой программы?

Книга в упражнении 11.5 задаёт вопрос с подвохом. В уроке про буферизованный ввод и вывод мы договорились, что с сокетами буферизованный ввод и вывод надо применять осторожно. А adder пишет в сокет через обычный буферизованный Writer, и всё работает. Почему? Подумай сам, ответ в упражнениях. Здесь только о том, что может пойти не так. adder обязан сбросить буфер перед выходом: flush в конце main не для красоты. Программа на C, которая пишет через printf и выходит через exit, сбрасывает буфер автоматически, а упавшая или вышедшая через _exit теряет всё, что лежало в буфере, и клиент получает от сервера половину заголовка и ничего больше. В Zig автоматического сброса нет вовсе, забытый flush означает пустой ответ всегда, а не только при падении.

Шаг проекта: разбор запроса

Теперь напишем то, что делает сервер до всякого fork и mmap: превратим байты из сокета в структуру. Это чистая часть TINY: ни одного системного вызова, читатель приходит как *std.Io.Reader. В сервере под ним будет fdio.Reader из урока про сокеты над дескриптором соединения, в тестах Reader.fixed над строкой. Это та же развязка, что в уроке про буферизованный ввод: у std.Io.Reader свой буфер, и ему всё равно, откуда в буфер приходят байты.

Файл src/http/request.zig целиком:

//! Чистая часть HTTP: строка запроса, заголовки, `parseUri`, тип содержимого
//! по расширению и заголовок ответа. Ни одного системного вызова: читатель
//! приходит как `*std.Io.Reader`, в тестах это `Reader.fixed` над строкой.

const std = @import("std");
const Io = std.Io;

pub const Error = error{
    /// Строка запроса не из трёх слов или без версии `HTTP/`.
    BadRequestLine,
    /// Заголовок без двоеточия.
    BadHeader,
    /// Строка длиннее буфера читателя или заголовки не влезли в `Headers`.
    HeaderTooLong,
    /// `Content-Length` не число.
    BadContentLength,
    /// Поток кончился до пустой строки.
    UnexpectedEof,
    /// Чтение из сокета не удалось.
    ReadFailed,
    /// `..` в пути или путь не влез в буфер.
    Forbidden,
    UriTooLong,
};

pub const Method = enum {
    GET,
    HEAD,
    POST,
    /// Любой другой метод: сервер ответит 501.
    other,

    pub fn parse(text: []const u8) Method {
        return std.meta.stringToEnum(Method, text) orelse .other;
    }
};

pub const RequestLine = struct {
    method: Method,
    /// Метод как его прислали, для ответа 501 и лога.
    method_text: []const u8,
    uri: []const u8,
    version: []const u8,
};

/// `GET /home.html HTTP/1.1\r\n` в три части. `\r\n` в конце необязателен.
pub fn parseRequestLine(line: []const u8) Error!RequestLine {
    const trimmed = std.mem.trimEnd(u8, line, "\r\n");
    var words = std.mem.tokenizeScalar(u8, trimmed, ' ');
    const method_text = words.next() orelse return error.BadRequestLine;
    const uri = words.next() orelse return error.BadRequestLine;
    const version = words.next() orelse return error.BadRequestLine;
    if (words.next() != null) return error.BadRequestLine;
    if (!std.mem.startsWith(u8, version, "HTTP/")) return error.BadRequestLine;
    if (uri.len == 0 or uri[0] != '/') return error.BadRequestLine;
    return .{ .method = .parse(method_text), .method_text = method_text, .uri = uri, .version = version };
}

pub const Header = struct {
    name: []const u8,
    value: []const u8,
};

/// Заголовки запроса. Строки копируются в `storage`: срез из читателя
/// живёт только до следующего обращения к нему, а заголовки нужны и после
/// чтения тела. Доступ по имени без учёта регистра, как велит RFC 9110.
pub const Headers = struct {
    storage: [max_bytes]u8 = undefined,
    len: usize = 0,
    count: usize = 0,
    /// `Content-Length`, если был.
    content_length: ?usize = null,
    /// `Host`, если был.
    host: ?[]const u8 = null,

    pub const max_bytes = 8192;
    pub const max_count = 64;

    pub fn get(h: *const Headers, name: []const u8) ?[]const u8 {
        var it = h.iterator();
        while (it.next()) |header| {
            if (std.ascii.eqlIgnoreCase(header.name, name)) return header.value;
        }
        return null;
    }

    pub fn iterator(h: *const Headers) Iterator {
        return .{ .rest = h.storage[0..h.len] };
    }

    pub const Iterator = struct {
        rest: []const u8,

        pub fn next(it: *Iterator) ?Header {
            if (it.rest.len == 0) return null;
            const end = std.mem.indexOfScalar(u8, it.rest, '\n') orelse it.rest.len;
            const line = it.rest[0..end];
            it.rest = if (end < it.rest.len) it.rest[end + 1 ..] else it.rest[it.rest.len..];
            return splitHeader(line) catch unreachable;
        }
    };

    fn append(h: *Headers, line: []const u8) Error!void {
        if (h.count == max_count or h.len + line.len + 1 > max_bytes) return error.HeaderTooLong;
        @memcpy(h.storage[h.len..][0..line.len], line);
        h.storage[h.len + line.len] = '\n';
        h.len += line.len + 1;
        h.count += 1;
    }
};

/// `Name: value` в пару срезов. Пробелы вокруг значения срезаются.
pub fn splitHeader(line: []const u8) Error!Header {
    const colon = std.mem.indexOfScalar(u8, line, ':') orelse return error.BadHeader;
    const name = std.mem.trim(u8, line[0..colon], " \t");
    if (name.len == 0) return error.BadHeader;
    return .{ .name = name, .value = std.mem.trim(u8, line[colon + 1 ..], " \t") };
}

/// `read_requesthdrs` из книги: строки до пустой. Книга их только печатает,
/// мы ещё запоминаем и разбираем `Content-Length` и `Host`: без первого
/// нельзя принять POST, второй обязателен в HTTP/1.1.
pub fn readRequestHeaders(reader: *Io.Reader, headers: *Headers) Error!void {
    headers.* = .{};
    while (true) {
        const raw = reader.takeDelimiterInclusive('\n') catch |err| switch (err) {
            error.EndOfStream => return error.UnexpectedEof,
            error.StreamTooLong => return error.HeaderTooLong,
            error.ReadFailed => return error.ReadFailed,
        };
        const line = std.mem.trimEnd(u8, raw, "\r\n");
        if (line.len == 0) return;
        const header = try splitHeader(line);
        try headers.append(line);
        if (std.ascii.eqlIgnoreCase(header.name, "Content-Length")) {
            headers.content_length = std.fmt.parseInt(usize, header.value, 10) catch return error.BadContentLength;
        } else if (std.ascii.eqlIgnoreCase(header.name, "Host")) {
            headers.host = headers.get("Host");
        }
    }
}

pub const ParsedUri = struct {
    is_static: bool,
    /// Путь к файлу относительно корня, начинается с `./`.
    filename: []const u8,
    /// Аргументы CGI после `?`, пустая строка для статики.
    cgiargs: []const u8,
};

/// `parse_uri` из книги. Статика: всё, где нет `cgi-bin`; `/` и любой
/// путь со слешем на конце получают `home.html`. Динамика: `cgi-bin`
/// в пути, аргументы после `?`. Корень `./`, как в книге, а вот `..`
/// в пути книга не проверяет: с ним `GET /../etc/passwd` уходил бы за
/// корень. У нас это `error.Forbidden`.
pub fn parseUri(uri: []const u8, filename_buf: []u8, cgiargs_buf: []u8) Error!ParsedUri {
    if (uri.len == 0 or uri[0] != '/') return error.Forbidden;
    if (std.mem.indexOf(u8, uri, "cgi-bin") == null) {
        const path = try safePath(uri);
        const suffix: []const u8 = if (path[path.len - 1] == '/') "home.html" else "";
        const filename = std.fmt.bufPrint(filename_buf, ".{s}{s}", .{ path, suffix }) catch return error.UriTooLong;
        return .{ .is_static = true, .filename = filename, .cgiargs = cgiargs_buf[0..0] };
    }
    const question = std.mem.indexOfScalar(u8, uri, '?');
    const path = try safePath(if (question) |q| uri[0..q] else uri);
    const args = if (question) |q| uri[q + 1 ..] else "";
    if (args.len > cgiargs_buf.len) return error.UriTooLong;
    @memcpy(cgiargs_buf[0..args.len], args);
    const filename = std.fmt.bufPrint(filename_buf, ".{s}", .{path}) catch return error.UriTooLong;
    return .{ .is_static = false, .filename = filename, .cgiargs = cgiargs_buf[0..args.len] };
}

/// Путь без сегмента `..`: иначе `./../secret` вышел бы за корень.
fn safePath(path: []const u8) Error![]const u8 {
    var segments = std.mem.splitScalar(u8, path, '/');
    while (segments.next()) |segment| {
        if (std.mem.eql(u8, segment, "..")) return error.Forbidden;
    }
    return path;
}

/// `get_filetype` из книги плюс `png` и `mp4`. Незнакомое расширение
/// это `text/plain`, как в книге.
pub fn contentType(filename: []const u8) []const u8 {
    const ext = std.fs.path.extension(filename);
    const table = .{
        .{ ".html", "text/html" },
        .{ ".htm", "text/html" },
        .{ ".gif", "image/gif" },
        .{ ".png", "image/png" },
        .{ ".jpg", "image/jpeg" },
        .{ ".jpeg", "image/jpeg" },
        .{ ".mp4", "video/mp4" },
        .{ ".css", "text/css" },
        .{ ".js", "text/javascript" },
        .{ ".json", "application/json" },
    };
    inline for (table) |row| {
        if (std.ascii.eqlIgnoreCase(ext, row[0])) return row[1];
    }
    return "text/plain";
}

pub fn statusText(status: u16) []const u8 {
    return switch (status) {
        200 => "OK",
        400 => "Bad Request",
        403 => "Forbidden",
        404 => "Not Found",
        413 => "Content Too Large",
        414 => "URI Too Long",
        431 => "Request Header Fields Too Large",
        500 => "Internal Server Error",
        501 => "Not Implemented",
        else => "Unknown",
    };
}

/// Заголовок ответа TINY. `Connection: close` честно предупреждает: сервер
/// говорит на HTTP/1.0 и закрывает соединение после ответа.
pub fn writeResponseHead(w: *Io.Writer, status: u16, content_type: []const u8, content_length: usize) Io.Writer.Error!void {
    try w.print("HTTP/1.0 {d} {s}\r\n", .{ status, statusText(status) });
    try w.writeAll("Server: Tiny Web Server\r\n");
    try w.writeAll("Connection: close\r\n");
    try w.print("Content-length: {d}\r\n", .{content_length});
    try w.print("Content-type: {s}\r\n\r\n", .{content_type});
}

Пройдёмся по частям.

parseRequestLine режет строку на три слова через tokenizeScalar, поэтому лишние пробелы между словами не страшны, а четвёртое слово или отсутствие третьего это BadRequestLine. Метод превращается в перечисление через std.meta.stringToEnum, незнакомый становится .other, но исходный текст сохраняется в method_text для лога и страницы 501. URI обязан начинаться со /: форму для прокси, http://..., наш сервер не принимает. Проверку версии мы сделали мягкой: подойдёт любая HTTP/что-то, а отвечаем мы всегда HTTP/1.0. Строгий сервер ответил бы на HTTP/3.0 кодом 505.

Headers хранит строки заголовков в собственном массиве на 8 КиБ. Зачем копировать, если takeDelimiterInclusive уже вернул срез? Потому что этот срез указывает внутрь буфера читателя и живёт только до следующего обращения к читателю. Следующий take может сдвинуть данные в буфере или перезаписать их новыми байтами из сокета. А заголовки нужны и после того, как прочитано тело: Content-Length понадобится, чтобы прочитать тело, а Host обработчику. Копирование в storage решает это без аллокатора. Лимит в 64 заголовка и 8 КиБ это защита: клиент, который шлёт заголовки бесконечно, получит HeaderTooLong вместо того, чтобы съесть память сервера.

readRequestHeaders это read_requesthdrs из книги. Книжная версия только читает строки до пустой и печатает их, наша ещё разбирает каждую. takeDelimiterInclusive('\n') возвращает строку вместе с переводом строки, если та целиком влезла в буфер читателя, и ошибку StreamTooLong, если не влезла. Получается, что размер буфера читателя и есть самая длинная строка, которую сервер согласен принять. У TINY это 8 КиБ, как у nginx по умолчанию. Короткие счёты, о которых мы столько говорили в уроке 60, здесь не видны вовсе: их прячет читатель, который пополняет буфер, пока не найдёт \n.

parseUri это parse_uri из книги с одной добавкой. Логика книжная: нет cgi-bin в URI, значит статика, имя файла это . плюс путь, со слешем на конце добавляется home.html; есть cgi-bin, значит программа, имя до ?, аргументы после. Буферы даёт вызывающий, результат это срезы этих буферов.

Добавка это safePath. Посмотри, что сделала бы книжная версия с запросом GET /../etc/passwd: имя файла ./../etc/passwd, то есть на уровень выше корня сервера и дальше в /etc. Сервер, запущенный из /srv/www, отдал бы /srv/etc/passwd, а с парой лишних .. и настоящий /etc/passwd. Это классическая уязвимость обхода каталога, одна из самых старых дыр веб-серверов. Наша функция режет путь по / и отвергает любой сегмент, равный ровно ... Именно сегмент, а не подстроку: файл a..b или каталог ... законны.

$ curl -i --path-as-is 'http://localhost:8065/../etc/passwd'
HTTP/1.0 403 Forbidden
Server: Tiny Web Server
Connection: close
Content-length: 143
Content-type: text/html

<html><title>Tiny Error</title><body bgcolor="ffffff">
403: Forbidden
<p>path escapes the root
<hr><em>The Tiny Web server</em>
</body></html>

Флаг --path-as-is нужен, потому что curl, как и браузер, сам схлопывает .. в пути до отправки. Но злоумышленник пользуется не браузером, а nc, и получает байты в сокет ровно такими, какими их набрал. Проверять должен сервер.

Проверка работает только потому, что мы не раскодируем проценты. Запрос /%2e%2e/etc/passwd для нашего сервера это каталог с буквальным именем %2e%2e, его нет, ответ 404. Сервер, который раскодирует путь после проверки на .., снова открыт. Правильный порядок один: разбор, раскодирование, проверка. В домашнем задании ты попробуешь его нарушить и посмотришь, что будет.

contentType это get_filetype из книги: таблица расширений, незнакомое становится text/plain. Таблица объявлена кортежем и обходится через inline for, то есть разворачивается на этапе компиляции в цепочку сравнений. Сравнение без учёта регистра, поэтому photo.JPG тоже image/jpeg.

writeResponseHead печатает заголовок ответа TINY. Порядок и написание заголовков книжные, Content-length с маленькой буквы тоже: регистр имени неважен, и мы не стали спорить с книгой.

CGI-программа adder

Второй файл шага, cgi/adder.zig:

//! CGI-программа adder из главы 11: складывает два числа из `QUERY_STRING`
//! вида `15&213`. Печатает вторую половину заголовка ответа (первую уже
//! отправил TINY) и тело. При `REQUEST_METHOD=HEAD` тела нет.
//! Собирается в `zig-out/cgi-bin/adder`, сервер запускает его через `execve`.

const std = @import("std");
const c = std.c;

pub fn main(init: std.process.Init) !void {
    const query: []const u8 = if (c.getenv("QUERY_STRING")) |q| std.mem.span(q) else "";
    const method: []const u8 = if (c.getenv("REQUEST_METHOD")) |m| std.mem.span(m) else "GET";

    var body_buf: [1024]u8 = undefined;
    const body = render(query, &body_buf);

    var out_buf: [2048]u8 = undefined;
    var stdout = std.Io.File.stdout().writerStreaming(init.io, &out_buf);
    const out = &stdout.interface;
    try out.writeAll("Connection: close\r\n");
    try out.print("Content-length: {d}\r\n", .{body.len});
    try out.writeAll("Content-type: text/html\r\n\r\n");
    if (!std.mem.eql(u8, method, "HEAD")) try out.writeAll(body);
    try out.flush();
}

/// Тело ответа. Кривые аргументы дают нули, как `atoi` в книге, только
/// без неопределённого поведения.
pub fn render(query: []const u8, buf: []u8) []const u8 {
    const numbers = parseArgs(query);
    return std.fmt.bufPrint(buf,
        \\Welcome to add.com: THE Internet addition portal.
        \\<p>The answer is: {d} + {d} = {d}
        \\<p>Thanks for visiting!
        \\
    , .{ numbers[0], numbers[1], numbers[0] + numbers[1] }) catch buf[0..0];
}

/// `n1&n2` в пару чисел. Формы `first=15&second=213` тоже принимаются:
/// берём хвост после `=`.
pub fn parseArgs(query: []const u8) [2]i64 {
    var numbers: [2]i64 = .{ 0, 0 };
    var parts = std.mem.splitScalar(u8, query, '&');
    for (&numbers) |*n| {
        const part = parts.next() orelse break;
        const digits = if (std.mem.indexOfScalar(u8, part, '=')) |eq| part[eq + 1 ..] else part;
        n.* = std.fmt.parseInt(i64, digits, 10) catch 0;
    }
    return numbers;
}

Здесь всё, что мы обсудили про CGI. Аргументы из QUERY_STRING, метод из REQUEST_METHOD (переменные читаются через getenv из libc: процесс запущен через execve с нашим envp, и libc разложила его в environ). Тело ответа собирается в буфер раньше заголовков, потому что Content-length надо напечатать до тела. На HEAD заголовки те же, тела нет. В конце flush. Кривые аргументы дают нули, как atoi в книге, только без неопределённого поведения: parseInt возвращает ошибку, а не мусор. Форма first=15&second=213 тоже работает: так аргументы пришлёт браузер из HTML-формы.

build.zig собирает adder отдельным исполняемым файлом и кладёт его в zig-out/cgi-bin/adder, а содержимое www/ в корень zig-out. Поэтому tiny tiny 8065 zig-out отдаёт и страницы, и программу из одного каталога. Фрагмент build.zig, который за это отвечает:

    // CGI-программа adder: ставится в zig-out/cgi-bin, туда же, куда
    // сервер смотрит по URI /cgi-bin/adder.
    const adder = b.addExecutable(.{
        .name = "adder",
        .root_module = b.createModule(.{
            .root_source_file = b.path("cgi/adder.zig"),
            .target = target,
            .optimize = optimize,
            .link_libc = true,
        }),
    });
    const install_adder = b.addInstallArtifact(adder, .{ .dest_dir = .{ .override = .{ .custom = "cgi-bin" } } });
    b.getInstallStep().dependOn(&install_adder.step);

    // Статика кладётся в корень zig-out: `tiny 8000 zig-out` отдаёт
    // /home.html и /cgi-bin/adder из одного каталога.
    b.installDirectory(.{ .source_dir = b.path("www"), .install_dir = .prefix, .install_subdir = "" });

В src/root.zig модуль шага подключается одной строкой, pub const request = @import("http/request.zig");, а путь к собранному adder попадает в тесты через build_options.adder_exe, объявленный там же в build.zig.

Тесты шага

tests/step_65.zig проверяет разбор строки запроса и заголовков на кривых входах, parseUri на таблице случаев из книги и на попытках выйти за корень, MIME, заголовок ответа байт в байт и adder как настоящий процесс с QUERY_STRING в окружении. Один тест гонит заголовок через настоящий пайп в читатель с буфером на 32 байта, чтобы увидеть HeaderTooLong от строки длиннее буфера.

//! Шаг 65: разбор строки запроса и заголовков, parse_uri по таблице
//! случаев из книги, тип содержимого, заголовок ответа, adder как процесс.

const std = @import("std");
const tiny = @import("tiny");
const request = tiny.request;
const support = @import("support.zig");

const testing = std.testing;

test "parseRequestLine: метод, URI, версия" {
    const line = try request.parseRequestLine("GET /home.html HTTP/1.1\r\n");
    try testing.expectEqual(.GET, line.method);
    try testing.expectEqualStrings("/home.html", line.uri);
    try testing.expectEqualStrings("HTTP/1.1", line.version);

    const post = try request.parseRequestLine("POST /cgi-bin/adder HTTP/1.0");
    try testing.expectEqual(.POST, post.method);
    try testing.expectEqualStrings("POST", post.method_text);

    const put = try request.parseRequestLine("PUT /x HTTP/1.1\r\n");
    try testing.expectEqual(.other, put.method);
    try testing.expectEqualStrings("PUT", put.method_text);
}

test "parseRequestLine: кривые строки" {
    for ([_][]const u8{ "", "\r\n", "GET", "GET /", "GET / HTTP/1.1 extra", "GET home.html HTTP/1.1", "GET / FTP/1" }) |text| {
        try testing.expectError(error.BadRequestLine, request.parseRequestLine(text));
    }
}

test "readRequestHeaders: до пустой строки, Content-Length и Host разобраны" {
    var reader: std.Io.Reader = .fixed("Host: localhost:8000\r\nUser-Agent: curl/8.7.1\r\ncontent-length: 7\r\nAccept: */*\r\n\r\nn1=1&n2\n");
    var headers: request.Headers = .{};
    try request.readRequestHeaders(&reader, &headers);
    try testing.expectEqual(4, headers.count);
    try testing.expectEqual(7, headers.content_length.?);
    try testing.expectEqualStrings("localhost:8000", headers.host.?);
    try testing.expectEqualStrings("curl/8.7.1", headers.get("user-agent").?);
    try testing.expectEqual(null, headers.get("Cookie"));
    // Тело осталось в читателе нетронутым.
    try testing.expectEqualStrings("n1=1&n2\n", try reader.takeDelimiterInclusive('\n'));

    var it = headers.iterator();
    try testing.expectEqualStrings("Host", it.next().?.name);
    try testing.expectEqualStrings("User-Agent", it.next().?.name);
}

test "readRequestHeaders: без заголовков, только пустая строка" {
    var reader: std.Io.Reader = .fixed("\r\n");
    var headers: request.Headers = .{};
    try request.readRequestHeaders(&reader, &headers);
    try testing.expectEqual(0, headers.count);
    try testing.expectEqual(null, headers.content_length);
}

test "readRequestHeaders: ошибки" {
    var no_colon: std.Io.Reader = .fixed("Host localhost\r\n\r\n");
    var headers: request.Headers = .{};
    try testing.expectError(error.BadHeader, request.readRequestHeaders(&no_colon, &headers));

    var bad_length: std.Io.Reader = .fixed("Content-Length: seven\r\n\r\n");
    try testing.expectError(error.BadContentLength, request.readRequestHeaders(&bad_length, &headers));

    var eof: std.Io.Reader = .fixed("Host: x\r\n");
    try testing.expectError(error.UnexpectedEof, request.readRequestHeaders(&eof, &headers));
}

test "readRequestHeaders: строка длиннее буфера читателя это HeaderTooLong" {
    // Заголовок в 100 байт через пайп в читатель с буфером на 32: размер
    // буфера и есть самая длинная строка, которую сервер согласен принять.
    var fds: [2]std.c.fd_t = undefined;
    try testing.expect(std.c.pipe(&fds) == 0);
    defer _ = std.c.close(fds[0]);
    const long = "X-Long: " ++ "a" ** 100 ++ "\r\n\r\n";
    try tiny.fdio.writen(fds[1], long);
    _ = std.c.close(fds[1]);

    var small_buf: [32]u8 = undefined;
    var reader: tiny.fdio.Reader = .init(fds[0], &small_buf);
    var headers: request.Headers = .{};
    try testing.expectError(error.HeaderTooLong, request.readRequestHeaders(&reader.interface, &headers));
}

test "readRequestHeaders: больше max_count заголовков это HeaderTooLong" {
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(testing.allocator);
    for (0..request.Headers.max_count + 1) |_| try text.appendSlice(testing.allocator, "A: b\r\n");
    try text.appendSlice(testing.allocator, "\r\n");
    var reader: std.Io.Reader = .fixed(text.items);
    var headers: request.Headers = .{};
    try testing.expectError(error.HeaderTooLong, request.readRequestHeaders(&reader, &headers));
}

fn expectUri(uri: []const u8, is_static: bool, filename: []const u8, cgiargs: []const u8) !void {
    var filename_buf: [256]u8 = undefined;
    var cgiargs_buf: [256]u8 = undefined;
    const parsed = try request.parseUri(uri, &filename_buf, &cgiargs_buf);
    try testing.expectEqual(is_static, parsed.is_static);
    try testing.expectEqualStrings(filename, parsed.filename);
    try testing.expectEqualStrings(cgiargs, parsed.cgiargs);
}

test "parseUri: таблица случаев из книги" {
    try expectUri("/", true, "./home.html", "");
    try expectUri("/home.html", true, "./home.html", "");
    try expectUri("/godzilla.gif", true, "./godzilla.gif", "");
    try expectUri("/docs/", true, "./docs/home.html", "");
    try expectUri("/cgi-bin/adder?15&213", false, "./cgi-bin/adder", "15&213");
    try expectUri("/cgi-bin/adder", false, "./cgi-bin/adder", "");
    try expectUri("/cgi-bin/adder?", false, "./cgi-bin/adder", "");
}

test "parseUri: выход за корень запрещён, а точка внутри имени нет" {
    var filename_buf: [256]u8 = undefined;
    var cgiargs_buf: [256]u8 = undefined;
    try testing.expectError(error.Forbidden, request.parseUri("/../etc/passwd", &filename_buf, &cgiargs_buf));
    try testing.expectError(error.Forbidden, request.parseUri("/docs/../../secret", &filename_buf, &cgiargs_buf));
    try testing.expectError(error.Forbidden, request.parseUri("/cgi-bin/../adder?1&2", &filename_buf, &cgiargs_buf));
    try testing.expectError(error.Forbidden, request.parseUri("home.html", &filename_buf, &cgiargs_buf));
    try expectUri("/a..b/notes.txt", true, "./a..b/notes.txt", "");
    try expectUri("/.hidden", true, "./.hidden", "");

    var tiny_buf: [4]u8 = undefined;
    try testing.expectError(error.UriTooLong, request.parseUri("/home.html", &tiny_buf, &cgiargs_buf));
}

test "contentType по расширению" {
    try testing.expectEqualStrings("text/html", request.contentType("./home.html"));
    try testing.expectEqualStrings("image/gif", request.contentType("./godzilla.gif"));
    try testing.expectEqualStrings("image/png", request.contentType("./dot.png"));
    try testing.expectEqualStrings("image/jpeg", request.contentType("./photo.JPG"));
    try testing.expectEqualStrings("video/mp4", request.contentType("./clip.mp4"));
    try testing.expectEqualStrings("text/plain", request.contentType("./notes.txt"));
    try testing.expectEqualStrings("text/plain", request.contentType("./README"));
}

test "writeResponseHead: заголовок ответа TINY" {
    var buf: [256]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try request.writeResponseHead(&out, 200, "text/html", 120);
    try testing.expectEqualStrings("HTTP/1.0 200 OK\r\nServer: Tiny Web Server\r\nConnection: close\r\nContent-length: 120\r\nContent-type: text/html\r\n\r\n", out.buffered());
}

test "adder как процесс: QUERY_STRING в ответ" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var env: std.process.Environ.Map = .init(arena);
    try env.put("QUERY_STRING", "15&213");
    const result = try std.process.run(arena, testing.io, .{ .argv = &.{support.adder_exe}, .environ_map = &env });
    try testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
    const expected_body = "Welcome to add.com: THE Internet addition portal.\n<p>The answer is: 15 + 213 = 228\n<p>Thanks for visiting!\n";
    const expected = "Connection: close\r\nContent-length: " ++ std.fmt.comptimePrint("{d}", .{expected_body.len}) ++ "\r\nContent-type: text/html\r\n\r\n" ++ expected_body;
    try testing.expectEqualStrings(expected, result.stdout);
}

test "adder: HEAD печатает заголовки без тела, кривые числа дают нули" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    var env: std.process.Environ.Map = .init(arena);
    try env.put("QUERY_STRING", "x&7");
    try env.put("REQUEST_METHOD", "HEAD");
    const result = try std.process.run(arena, testing.io, .{ .argv = &.{support.adder_exe}, .environ_map = &env });
    try testing.expect(std.mem.endsWith(u8, result.stdout, "Content-type: text/html\r\n\r\n"));
    try testing.expect(std.mem.indexOf(u8, result.stdout, "Content-length: ") != null);
}
$ zig build test -Dstep=65 --summary all
Build Summary: 5/5 steps succeeded; 13/13 tests passed
test success
+- run test 13 pass (13 total) 713ms MaxRSS:3M
   +- compile test Debug native success 1s MaxRSS:284M
      +- options success
         +- compile exe adder Debug native success 1s MaxRSS:257M

Обрати внимание на последнюю строку: сборка тестов зависит от сборки adder, потому что тест запускает его как процесс через std.process.run с картой окружения. Это ровно то, что сделает сервер, только без сокета.

На macOS

Всё в уроке работает на macOS напрямую, выводы сняты на macOS 26 (Apple Silicon): сервер, adder, cgirun.zig с socketpair, fork и execve, тесты шага. Код идёт через libc, а на macOS libc линкуется всегда; на Linux эталон линкует её сам (link_libc = true в build.zig), а для отдельного листинга добавь -lc: zig run cgirun.zig -lc.

Разница в инструментах. telnet, которым книга набирает запросы, из macOS давно убран (с версии 10.13), поэтому в уроке nc. nc на macOS после конца ввода дожидается ответа и выходит, когда сервер закроет соединение: запрос к aol.com из урока завершается сам за секунду. nc из пакета netcat-openbsd в Debian после конца ввода по умолчанию ждёт, пока соединение не закроется, и с сервером, который держит keep-alive, может висеть до Ctrl+C; флаг -q 1 велит ему выйти через секунду после конца ввода. TINY закрывает соединение сам, с ним разницы нет. curl стоит в обеих системах. Порт 8065 выбран выше 1024: на Linux порты ниже требуют прав.

Практика

Задача про parseUri из шага проекта, только самодостаточная: в ней нет читателя и сервера, одна функция и её договор. Правила те же, что в уроке: статика против cgi-bin, home.html для слеша на конце, аргументы после ? копируются в буфер вызывающего, .. в пути запрещён, нехватка буфера это UriTooLong. Тесты проверяют таблицу из книги, cgi-bin в середине пути, точки внутри имён и в аргументах, буферы ровно по размеру и на байт меньше.

Упражнения

Итоги

  • HTTP это соглашение о байтах в соединении: строка запроса метод URI версия, заголовки Имя: значение, пустая строка, тело; в ответ строка статуса версия код фраза, заголовки, пустая строка, тело. Строки кончаются на \r\n, имена заголовков нечувствительны к регистру.
  • Конец заголовков узнаётся только по пустой строке, конец тела только по Content-Length или по закрытию соединения. Поэтому сервер читает строками через буфер, а размер буфера ограничивает длину строки.
  • Контент это байты плюс MIME-тип. Файловая система типов не знает, сервер выводит тип из расширения, а браузер верит заголовку Content-Type.
  • Из URL клиент берёт схему, хост и порт для соединения, серверу отправляет путь и запрос, фрагмент оставляет себе. Как путь соотносится с файлами, решает сервер. У TINY: cgi-bin значит программа, слеш на конце значит home.html, корень ./.
  • Коды: 200 успех, 301 переезд с Location, 400 кривой запрос, 403 нельзя, 404 нет, 501 неизвестный метод, 505 неизвестная версия.
  • TINY говорит на HTTP/1.0 с Connection: close: одно соединение на один запрос. Итеративному серверу иначе нельзя. Постоянные соединения и chunked это HTTP/1.1.
  • CGI: аргументы в QUERY_STRING, метод и длина тела в REQUEST_METHOD и CONTENT_LENGTH, тело POST в стандартном вводе, вывод через dup2(connfd, 1) прямо в сокет. Сервер пишет строку статуса, программа всё остальное, включая Content-length, потому что размер знает только она.
  • Перед fork сервер обязан сбросить свой буфер, иначе его половина ответа придёт после половины программы. Окружение ребёнка собирается до fork, и в нём только то, что нужно CGI.
  • Книжный parse_uri пропускает /../etc/passwd за корень. Проверяй сегменты .. в пути, и проверяй после раскодирования процентов, если раскодируешь.

Дальше

Сегодня мы смотрели на TINY снаружи и собрали его чистую часть: разбор строки запроса, заголовков и URI, типы содержимого, заголовок ответа и CGI-программу. В следующем уроке соберём сервер целиком. doit свяжет разбор с сокетом, serveStatic отдаст файл через mmap из урока 56 одним write, serveDynamic сделает всё, что мы сегодня пробовали в cgirun.zig, а clienterror соберёт те страницы ошибок, что ты видел в выводах curl. Потом научим сервер переживать клиента, который бросил соединение посреди ответа, принимать POST с телом и отдавать видео. А проект zbox получит свой HTTP-фасад: тот же сервер примет исходник по POST /run и ответит JSON-ом песочницы.

домашка

Домашка