From 6210b46c1f2d33c4b4082618f6aa2293398d68b3 Mon Sep 17 00:00:00 2001 From: Oleg Sukhoroslov Date: Thu, 24 Sep 2026 21:30:37 +0300 Subject: [PATCH] Add HW 3 --- homework/03-http-server/readme.md | 236 ++++++++++ homework/03-http-server/solution/Dockerfile | 10 + .../03-http-server/solution/http_messages.py | 124 +++++ .../03-http-server/solution/requirements.txt | 1 + homework/03-http-server/solution/server.py | 86 ++++ homework/03-http-server/tests/.dockerignore | 7 + homework/03-http-server/tests/.gitignore | 1 + homework/03-http-server/tests/Dockerfile | 18 + homework/03-http-server/tests/compare.go | 124 +++++ .../tests/compare_reader_test.go | 60 +++ homework/03-http-server/tests/contract.go | 301 +++++++++++++ .../03-http-server/tests/contract_test.go | 144 ++++++ homework/03-http-server/tests/entrypoint.sh | 128 ++++++ homework/03-http-server/tests/env.go | 233 ++++++++++ homework/03-http-server/tests/env_gen.go | 73 +++ .../03-http-server/tests/env_selected_test.go | 44 ++ homework/03-http-server/tests/framing.go | 49 ++ homework/03-http-server/tests/gen_utils.go | 49 ++ homework/03-http-server/tests/go.mod | 16 + homework/03-http-server/tests/go.sum | 28 ++ homework/03-http-server/tests/hw_test.go | 309 +++++++++++++ .../tests/large_put_policy_test.go | 119 +++++ homework/03-http-server/tests/launch.tmpl | 2 + .../03-http-server/tests/launch_docker.tmpl | 2 + homework/03-http-server/tests/logs.go | 94 ++++ homework/03-http-server/tests/query.go | 424 ++++++++++++++++++ homework/03-http-server/tests/query_gen.go | 146 ++++++ .../03-http-server/tests/readiness_test.go | 56 +++ .../tests/response_validation_test.go | 269 +++++++++++ homework/03-http-server/tests/runner.go | 303 +++++++++++++ homework/03-http-server/tests/runner_test.go | 38 ++ homework/03-http-server/tests/test_starter.py | 92 ++++ homework/03-http-server/tests/test_utils.go | 103 +++++ homework/03-http-server/tests/tester.go | 308 +++++++++++++ .../tests/verify-zram-scratch.sh | 15 + 35 files changed, 4012 insertions(+) create mode 100644 homework/03-http-server/readme.md create mode 100644 homework/03-http-server/solution/Dockerfile create mode 100644 homework/03-http-server/solution/http_messages.py create mode 100644 homework/03-http-server/solution/requirements.txt create mode 100644 homework/03-http-server/solution/server.py create mode 100644 homework/03-http-server/tests/.dockerignore create mode 100644 homework/03-http-server/tests/.gitignore create mode 100644 homework/03-http-server/tests/Dockerfile create mode 100644 homework/03-http-server/tests/compare.go create mode 100644 homework/03-http-server/tests/compare_reader_test.go create mode 100644 homework/03-http-server/tests/contract.go create mode 100644 homework/03-http-server/tests/contract_test.go create mode 100644 homework/03-http-server/tests/entrypoint.sh create mode 100644 homework/03-http-server/tests/env.go create mode 100644 homework/03-http-server/tests/env_gen.go create mode 100644 homework/03-http-server/tests/env_selected_test.go create mode 100644 homework/03-http-server/tests/framing.go create mode 100644 homework/03-http-server/tests/gen_utils.go create mode 100644 homework/03-http-server/tests/go.mod create mode 100644 homework/03-http-server/tests/go.sum create mode 100644 homework/03-http-server/tests/hw_test.go create mode 100644 homework/03-http-server/tests/large_put_policy_test.go create mode 100644 homework/03-http-server/tests/launch.tmpl create mode 100644 homework/03-http-server/tests/launch_docker.tmpl create mode 100644 homework/03-http-server/tests/logs.go create mode 100644 homework/03-http-server/tests/query.go create mode 100644 homework/03-http-server/tests/query_gen.go create mode 100644 homework/03-http-server/tests/readiness_test.go create mode 100644 homework/03-http-server/tests/response_validation_test.go create mode 100644 homework/03-http-server/tests/runner.go create mode 100644 homework/03-http-server/tests/runner_test.go create mode 100644 homework/03-http-server/tests/test_starter.py create mode 100644 homework/03-http-server/tests/test_utils.go create mode 100644 homework/03-http-server/tests/tester.go create mode 100644 homework/03-http-server/tests/verify-zram-scratch.sh diff --git a/homework/03-http-server/readme.md b/homework/03-http-server/readme.md new file mode 100644 index 0000000..31d5cdf --- /dev/null +++ b/homework/03-http-server/readme.md @@ -0,0 +1,236 @@ +# HTTP-сервер + +В этом задании вам предстоит реализовать простой HTTP-сервер для хранения файлов. + +На практике вы, скорее всего, будете использовать готовые библиотеки, в которых работа с HTTP уже реализована. Но полезно хотя бы раз сделать это «руками», чтобы лучше понять протокол: как клиент передаёт запрос, как сервер его читает и формирует ответ. + +Формат сообщений и работу соединений описывает [RFC 9112](https://www.rfc-editor.org/rfc/rfc9112.html), семантику методов и заголовков — [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html). В задании используется ограниченное подмножество HTTP/1.1, описанное ниже. Прочитайте условие целиком перед началом работы. + +## Запуск сервера + +При запуске сервер получает адрес и порт для приёма соединений, рабочую директорию и ожидаемое доменное имя. Эти настройки можно задать параметрами командной строки или переменными окружения: + +| Параметр | Переменная окружения | По умолчанию | Назначение | +|---|---|---|---| +| `--host` | `SERVER_HOST` | `0.0.0.0` | Адрес прослушивания TCP-соединений | +| `--port` | `SERVER_PORT` | `8080` | Порт сервера | +| `--working-directory` | `SERVER_WORKING_DIRECTORY` | Нет | Абсолютный путь к существующей рабочей директории | +| `--server-domain` | `SERVER_DOMAIN` | `localhost` | Ожидаемое доменное имя в заголовке `Host` | + +Параметр командной строки имеет приоритет над переменной окружения, а переменная окружения — над значением по умолчанию. Если рабочая директория не указана ни одним способом или задана пустой строкой, сервер завершается с кодом 1. В Python-заготовке эта настройка уже реализована. + +Параметры `host` и `port` определяют, где сервер принимает TCP-соединения. Параметр `server-domain` используется для проверки заголовка `Host` в запросах; он не меняет адрес прослушивания и не требует настройки DNS. + +### Проверка готовности + +Тестер ждёт готовности сервера не более **10 секунд**, отправляя запросы `GET / HTTP/1.1` с заголовком `Host`, равным `server-domain`. Готовность подтверждает любой корректный HTTP-ответ, включая ответ об ошибке. + +**Сервер должен отвечать на GET `/` во всех группах тестов.** В G1 и G2 можно вернуть код 200 с коротким текстом в теле ответа. Начиная с G3, этот запрос должен возвращать список файлов и директорий в рабочей директории. + +Если сервер не начнёт отвечать вовремя, тестер завершит запуск с ошибкой `server did not start in 10 seconds`. В этом случае проверьте вывод сервера в журнале тестирования: проблема может быть как в запуске, так и в обработке GET `/`. + +## Работа с файлами + +Сервер поддерживает четыре метода: GET читает файл или содержимое директории, POST создаёт файл или директорию, PUT заменяет содержимое существующего файла, а DELETE удаляет файл или директорию. Реализовывать HEAD и OPTIONS не требуется. + +Метод задаёт действие, а путь в запросе — файл или директорию, к которым это действие применяется. Все четыре метода используют одну схему путей: путь отсчитывается от рабочей директории сервера, заданной при запуске. Например, при `--working-directory /srv/files` запросы к `/docs/file.txt` работают с `/srv/files/docs/file.txt`. Начальный `/` в запросе обозначает рабочую директорию сервера, а не корень файловой системы компьютера. Отдельные адреса вроде `/get` или `/upload` создавать не нужно. + +Ниже записи вида `GET /docs/file.txt` обозначают метод и путь запроса. В передаваемом по TCP HTTP-сообщении к ним добавляются версия протокола, заголовки и, при необходимости, тело. + +### GET: чтение файла или директории + +Если путь указывает на файл, сервер возвращает его содержимое в теле ответа. Например, `GET /docs/file.txt` возвращает байты файла `/srv/files/docs/file.txt`. + +Если путь указывает на директорию, сервер возвращает список её непосредственных дочерних файлов и директорий — листинг. Так, `GET /docs` возвращает имена объектов внутри `/srv/files/docs`, а `GET /` — внутри самой рабочей директории. Вложенные директории обходить не нужно. + +В списке должны присутствовать все имена, включая скрытые файлы, но без `.` и `..`. Порядок и оформление произвольные: можно вернуть текст или HTML. Размеры, даты и права доступа не требуются; дополнительные сведения допустимы. + +### POST: создание файла или директории + +Путь в POST-запросе задаёт имя создаваемого объекта целиком. Например, `POST /docs/new.txt` создаёт файл `/srv/files/docs/new.txt` и записывает в него тело запроса. Директория `/srv/files/docs` к этому моменту должна существовать. Если тело пустое, создаётся пустой файл. + +Чтобы создать директорию, клиент передаёт заголовок `Create-Directory: True`. Например, `POST /docs/archive` с этим заголовком создаёт пустую директорию `/srv/files/docs/archive`. Сервер создаёт только указанный объект: промежуточные директории автоматически не создаются. Если по этому пути уже есть файл или директория, POST завершается ошибкой и не заменяет их. + +### PUT: замена содержимого файла + +PUT записывает тело запроса в существующий файл по указанному пути. Например, `PUT /docs/file.txt` полностью заменяет содержимое `/srv/files/docs/file.txt`. Если новые данные короче прежних, остаток прежнего содержимого должен исчезнуть; пустое тело делает файл пустым. + +В этом задании PUT применяется только к существующим файлам. Создавать новый файл или заменять директорию этим методом нельзя. + +### DELETE: удаление файла или директории + +Запрос `DELETE /docs/file.txt` удаляет файл `/srv/files/docs/file.txt`. Для удаления директории клиент должен явно передать `Remove-Directory: True`. Например, `DELETE /docs/archive` с этим заголовком удаляет `/srv/files/docs/archive` вместе со всеми вложенными файлами и директориями. Без разрешающего заголовка сервер должен отказать в удалении директории, даже если она пустая. + +### Коды ответов и общие правила + +В таблице собраны коды ответов для всех перечисленных операций. Там, где указаны два кода, можно выбрать любой из них. + +| Запрос и состояние пути | Действие и ответ | +|---|---| +| GET существующего файла | Вернуть содержимое файла с кодом 200 | +| GET существующей директории | Вернуть список её непосредственных дочерних файлов и директорий с кодом 200 | +| GET отсутствующего пути | Вернуть 404 | +| POST нового объекта, родительская директория существует | Создать объект и вернуть 200 или 201 | +| POST существующего файла или директории | Вернуть 409 | +| POST без родительской директории | Вернуть 404, не создавать промежуточные директории | +| PUT существующего файла | Полностью заменить содержимое телом запроса и вернуть 200 или 204 | +| PUT директории | Вернуть 409 | +| PUT отсутствующего пути | Вернуть 404, не создавать файл | +| DELETE существующего файла | Удалить файл и вернуть 200 | +| DELETE `/` | Вернуть 403: удаление рабочей директории запрещено независимо от заголовка `Remove-Directory` | +| DELETE директории с `Remove-Directory: True` | Удалить директорию со всем содержимым и вернуть 200 | +| DELETE директории без `Remove-Directory: True` | Вернуть 406 | +| DELETE отсутствующего пути | Вернуть 404 | + +Заголовки `Create-Directory` и `Remove-Directory` включают соответствующее действие только со значением `True`. Значение `False` равнозначно отсутствию заголовка; других значений в запросах не будет. + +Для упрощения работы с путями в тестах используются только ASCII-имена из букв, цифр, дефиса, подчёркивания и точки. Компоненты `.` и `..`, параметры после `?`, percent-encoding и символические ссылки исключены. Если один из промежуточных компонентов пути является файлом, такой путь обрабатывается как отсутствующий. + +При ошибке файловая система должна остаться без изменений. Ответ об ошибке содержит непустой понятный текст; точная формулировка не задана. Успешные ответы на POST, PUT и DELETE могут иметь пустое тело. Ответ 204 всегда передаётся без тела. + +## HTTP и заголовки + +Каждое соединение содержит один запрос. Сервер отправляет ответ с `Connection: close` и закрывает соединение. Одновременная обработка нескольких соединений не требуется. + +Запросы синтаксически корректны и используют HTTP/1.1. В них нет повторяющихся заголовков, `Transfer-Encoding` и `Expect`. Длина тела задаётся корректным `Content-Length`; если заголовка нет, тела нет. Имена заголовков сравниваются без учёта регистра. Неизвестные заголовки можно игнорировать. + +TCP передаёт поток байтов: результат одного чтения из сокета может содержать часть заголовка либо конец заголовков вместе с началом тела. Сервер должен работать при любом таком разбиении и не ждать закрытия соединения клиентом, чтобы определить конец запроса. Файлы могут содержать произвольные байты. + +| Заголовок | Требование | +|---|---| +| `Host` в запросе | В функциональных тестах содержит доменное имя без порта. Сравнивается с `server-domain` без учёта регистра; при несовпадении вернуть 400, не выполняя файловую операцию | +| `Content-Length` в ответе | Обязателен, кроме ответа 204. Равен размеру передаваемого тела в байтах; для пустого тела — 0, для gzip — размеру сжатых данных. В ответе 204 этот заголовок запрещён | +| `Connection` в ответе | `close` | +| `Content-Type` в ответе | Описывает тип содержимого тела. Для непустого тела нужен корректный MIME-тип. Для листинга допустимы `text/plain` и `text/html`. Для любых файлов, включая текстовые, разрешено использовать `application/octet-stream`; определять MIME-тип по расширению не требуется | +| `Server` в ответе | Непустое название сервера | +| `Accept-Encoding` в запросе | Сообщает, что клиент готов принять сжатые данные. В задании поддерживается только значение `gzip` | +| `Content-Encoding` в ответе | Сообщает, каким алгоритмом сжато тело ответа. При сжатии имеет значение `gzip`; в несжатом ответе отсутствует | + +Проверка `Host`, `Content-Type` и `Server` входит в G5 и G7. Коды ответа, длина и границы тела, закрытие соединения и заданное условием содержимое проверяются во всех группах. + +В G7 успешные GET-ответы на запрос с `Accept-Encoding: gzip` должны содержать сжатые данные. Это относится и к файлам, и к листингам. Без этого заголовка ответ не сжимается и не содержит `Content-Encoding`. Сжатие ошибок не требуется. Готовые библиотеки gzip использовать можно. `Transfer-Encoding` в ответах не используется. + +## Компоненты и оценивание + +Автоматические тесты определяют предварительную оценку. Можно реализовать часть задания: каждая группа проверяет только перечисленные для неё возможности. Например, в G1 достаточно чтения существующих текстовых файлов и ответа на служебный GET `/`, а создание и удаление файлов появляются в G4. + +Для получения баллов за группу нужно пройти все её проверки. Группы оцениваются независимо: ошибка в одной группе не лишает баллов за успешно пройденные другие. За все семь групп можно получить 10 баллов. + +| Группа | Баллы | Что проверяется | +|---|---|---| +| G1 | 3 | GET существующих текстовых файлов в ASCII, размером ≤ 8 МБ; настройка и запуск сервера | +| G2 | 1 | GET существующих файлов с произвольными байтами, размером ≤ 8 МБ | +| G3 | 1 | GET файлов и директорий, ошибки отсутствующих путей; файлы в ASCII, размером ≤ 8 МБ | +| G4 | 2 | GET, POST, PUT и DELETE, листинг и ошибки; произвольные файлы размером ≤ 8 МБ | +| G5 | 1 | Возможности G4 и проверка `Host`, `Content-Type`, `Server` | +| G6 | 1 | Возможности G4 и работа с большими файлами | +| G7 | 1 | Возможности G6, дополнительные заголовки из G5 и gzip | + +В G1 и G3 используются текстовые файлы в кодировке ASCII. В остальных группах файлы могут содержать произвольные байты, поэтому их содержимое нельзя считать текстом. + +В G1–G5 размер каждого файла не превышает 8 МБ. Это ограничение действует и на содержимое файлов, передаваемое в запросах POST и PUT. В G6 и G7 файлы могут быть больше 8 МБ. В этом задании 1 МБ = 1024 × 1024 байта. + +В тестирующей системе решение работает в Docker с лимитом памяти **128 МБ**. В G6 и G7 файлы могут превышать объём доступной памяти: нужно уметь и отдавать их клиенту, и принимать при POST и PUT, не загружая целиком в память. Проверить соблюдение лимита можно локально с Docker по инструкции ниже. Запуск без Docker этот лимит не проверяет. + +Для передачи большого файла с gzip и корректным `Content-Length` можно предварительно сжать его во временный файл, определить размер результата и затем передать его клиенту по частям. Само сжатие также должно укладываться в ограничение памяти. + +### Отчёт и защита + +Вместе с кодом сдайте краткий отчёт `solution/readme.md`. Опишите устройство решения и используемые библиотеки, а также объясните следующие механизмы, указав соответствующие файлы и функции в своём коде: + +- как определяются границы заголовков и тела; +- как обрабатываются большие файлы и от чего зависит расход памяти, если реализованы G6/G7; +- как определяется длина сжатого ответа, если реализована G7. + +Без отчёта автоматические тесты запускаются, но решение не засчитывается по [общим правилам сдачи](../readme.md). Использование LLM укажите согласно [политике курса](../../llm-policy.md). + +На защите нужно объяснить сданную реализацию, разобрать предложенный сценарий и при необходимости внести небольшую правку либо проследить выполнение кода. Ответы подтверждают баллы соответствующих компонентов; прохождение тестов само по себе не гарантирует итоговую оценку. + +Если вы используете заготовку, на защите нужно понимать, как ваш код взаимодействует с ней: как запускается сервер, откуда берутся параметры и как соединение передаётся обработчику запроса. + +## Заготовка и ограничения реализации + +Нельзя использовать готовые библиотеки HTTP или парсеры HTTP-сообщений, в том числе из стандартной библиотеки языка. Можно использовать TCP-сокеты, `socketserver`, библиотеки для файлов, командной строки и gzip. + +В `solution` находится Python-заготовка с настройкой параметров, TCP-сервером, логированием и структурами сообщений. Вам предстоит реализовать чтение и разбор запроса, обработку методов и формирование ответа. Методы в `http_messages.py` предназначены для стартовой строки и заголовков; тело обрабатывается отдельно. Наличие констант других методов не означает, что их нужно поддерживать. + +Можно изменить структуру заготовки или выбрать другой язык; использовать именно её классы необязательно. Решение должно поддерживать описанные параметры командной строки и переменные окружения. В `solution/Dockerfile` должно быть описание сборки образа и запуска вашего сервера. Если вы меняете язык или способ запуска, обновите этот файл. + +Весь код решения, необходимые для сборки файлы и отчёт разместите в папке `solution`. При сдаче отправляется только эта папка; изменения в тестах, `tests/launch.tmpl` и других файлах вне неё в тестирующую систему не попадут. + +## Тестирование + +Тесты написаны на Go и находятся в папке `tests`; точка входа — [TestHW](./tests/hw_test.go). Во время разработки удобно запускать их без Docker для быстрой проверки после изменений. Перед сдачей проверьте решение и с Docker: этот режим воспроизводит окружение тестирующей системы и ограничение памяти. + +### Как устроены тесты + +Внутри каждой группы есть несколько запусков. Для каждого запуска тестер создаёт набор файлов и директорий, запускает ваш сервер с соответствующей рабочей директорией и отправляет ему последовательность HTTP-запросов. Сервер работает до конца этого запуска, поэтому изменения после POST, PUT и DELETE влияют на последующие запросы. Запуск считается успешным, если сервер правильно обработал все запросы; для получения баллов нужно пройти все запуски группы. + +Наборы файлов и запросов генерируются случайным образом, но воспроизводятся при повторном запуске той же версии тестов. Числовые идентификаторы в журнале позволяют повторить нужную последовательность. Помимо сгенерированных запросов, есть отдельные проверки конкретных случаев, например пустого файла или разбиения запроса между несколькими чтениями TCP-потока. Тесты помогают находить ошибки, но не заменяют проверку соответствия всему условию. + +### Без Docker + +Установите Go. В `tests/launch.tmpl` задана команда, которой тестер запускает сервер. При необходимости измените её для своей системы или языка; в Windows вместо `python3` может потребоваться `python`. Сохраните в шаблоне `{{.CommandLineArgs}}`: на это место тестер подставляет параметры запуска. + +Если используете Python-заготовку, установите её зависимости. Затем запустите тесты следующими командами из папки задания: + +```sh +python3 -m pip install -r solution/requirements.txt +cd tests +go test +``` + +Для отдельной группы или запуска используйте фильтр: + +```sh +go test -run 'TestHW/G1' +go test -run 'TestHW/G1/1337' +``` + +Имена проверок видны в журнале. Например, `TestHW/G2/93/42300` обозначает группу G2, запуск 93 и запрос 42300. В G1 и G2 запросы только читают файлы, поэтому можно повторить отдельный запрос, указав его полный идентификатор: `go test -run 'TestHW/G2/93/42300'`. + +В группах с POST, PUT и DELETE запросы используют общее изменяемое состояние файлов. Поэтому ошибку в такой группе воспроизводите всем запуском: отдельный запрос может получить другое начальное состояние. + +### С Docker + +Перед первым запуском и после объявления об обновлении тестов загрузите образ: + +```sh +docker pull distsys.ru/course/http-server:latest +``` + +Запускайте из папки задания: + +```sh +docker run --privileged --rm -v ./solution:/hw/solution distsys.ru/course/http-server:latest +``` + +Для отдельной группы добавьте `-run 'TestHW/G1'` после имени образа. Тестер собирает контейнер вашего сервера и запускает его с лимитом памяти 128 МБ. Общий лимит запуска образа тестов — 10 минут. Скорость зависит от компьютера и Docker-окружения. + +Можно собрать образ сервера самостоятельно и проверить его локальным тестером: + +```sh +cd solution +docker build -t hw3img . +cd ../tests +go test --docker -timeout 10m +``` + +Для сборки самого образа тестов выполните из папки задания: + +```sh +docker build -t hw3tests ./tests +docker run --privileged --rm -v ./solution:/hw/solution hw3tests +``` + +### Как читать результаты + +В журнале видны команда запуска с параметрами и переменными окружения, весь вывод сервера в stdout/stderr и причины ошибок. При отладке начните с первого неудачного запроса: его идентификатор позволяет повторить соответствующий запуск с помощью фильтра `-run`. + +После ошибки оставшиеся запросы этого запуска и оставшиеся запуски группы пропускаются, а за группу начисляется 0 баллов. Тестирование других групп продолжается. В конце выводятся баллы по группам и строка `SCORE: N`. Если использовался фильтр, эта оценка учитывает только выбранные проверки; оценку за всё задание показывает полный прогон. + +После локальной проверки отправьте решение по [общей инструкции](../readme.md). + +### Бонус за пробелы в тестах + +Если найдёте ошибку в тестах или требование, нарушение которого они не обнаруживают, опишите ситуацию в отчёте; при необходимости приложите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл, а за тест, обнаруживающий её, или описание его логики — ещё 1 балл. diff --git a/homework/03-http-server/solution/Dockerfile b/homework/03-http-server/solution/Dockerfile new file mode 100644 index 0000000..0029ffc --- /dev/null +++ b/homework/03-http-server/solution/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /http/server + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt + +COPY . . + +ENTRYPOINT ["python3", "server.py"] diff --git a/homework/03-http-server/solution/http_messages.py b/homework/03-http-server/solution/http_messages.py new file mode 100644 index 0000000..1b46616 --- /dev/null +++ b/homework/03-http-server/solution/http_messages.py @@ -0,0 +1,124 @@ +import dataclasses +import typing as t + + +@dataclasses.dataclass +class HTTPRequest: + method: str + path: str + version: str + parameters: t.Dict[str, str] + headers: t.Dict[str, str] + + @staticmethod + def from_bytes(data: bytes) -> "HTTPRequest": + # data contains the request line and headers, including the final CRLF. + # Read the body separately in HTTPHandler; it may exceed available RAM. + # Query parameters are not required: parameters can be an empty dict. + # TODO: Parse the request line and headers. + pass + + +@dataclasses.dataclass +class HTTPResponse: + version: str + status: str + headers: t.Dict[str, str] + + def to_bytes(self) -> bytes: + # Return the status line and headers, ending with an empty line. + # HTTPHandler sends the body separately. + # TODO: Serialize the response headers. + pass + +# Common HTTP strings and constants + + +CR = b'\r' +LF = b'\n' +CRLF = CR + LF + +HTTP_VERSION = "1.1" + +OPTIONS = 'OPTIONS' +GET = 'GET' +HEAD = 'HEAD' +POST = 'POST' +PUT = 'PUT' +DELETE = 'DELETE' + +METHODS = [ + OPTIONS, + GET, + HEAD, + POST, + PUT, + DELETE, +] + +# Only GET, POST, PUT and DELETE are required in this assignment. + +HEADER_HOST = "Host" +HEADER_CONTENT_LENGTH = "Content-Length" +HEADER_CONTENT_TYPE = "Content-Type" +HEADER_CONTENT_ENCODING = "Content-Encoding" +HEADER_ACCEPT_ENCODING = "Accept-Encoding" +HEADER_CREATE_DIRECTORY = "Create-Directory" +HEADER_SERVER = "Server" +HEADER_REMOVE_DIRECTORY = "Remove-Directory" + +GZIP = "gzip" + +TEXT_PLAIN = "text/plain" +APPLICATION_OCTET_STREAM = "application/octet-stream" +APPLICATION_GZIP = "application/gzip" + +OK = "200" +BAD_REQUEST = "400" +NOT_FOUND = "404" +METHOD_NOT_ALLOWED = "405" +NOT_ACCEPTABLE = "406" +CONFLICT = "409" + +HTTP_REASON_BY_STATUS = { + "100": "Continue", + "101": "Switching Protocols", + "200": "OK", + "201": "Created", + "202": "Accepted", + "203": "Non-Authoritative Information", + "204": "No Content", + "205": "Reset Content", + "206": "Partial Content", + "300": "Multiple Choices", + "301": "Moved Permanently", + "302": "Found", + "303": "See Other", + "304": "Not Modified", + "305": "Use Proxy", + "307": "Temporary Redirect", + "400": "Bad Request", + "401": "Unauthorized", + "402": "Payment Required", + "403": "Forbidden", + "404": "Not Found", + "405": "Method Not Allowed", + "406": "Not Acceptable", + "407": "Proxy Authentication Required", + "408": "Request Time-out", + "409": "Conflict", + "410": "Gone", + "411": "Length Required", + "412": "Precondition Failed", + "413": "Request Entity Too Large", + "414": "Request-URI Too Large", + "415": "Unsupported Media Type", + "416": "Requested range not satisfiable", + "417": "Expectation Failed", + "500": "Internal Server Error", + "501": "Not Implemented", + "502": "Bad Gateway", + "503": "Service Unavailable", + "504": "Gateway Time-out", + "505": "HTTP Version not supported", +} diff --git a/homework/03-http-server/solution/requirements.txt b/homework/03-http-server/solution/requirements.txt new file mode 100644 index 0000000..41eca21 --- /dev/null +++ b/homework/03-http-server/solution/requirements.txt @@ -0,0 +1 @@ +click==8.3.0 \ No newline at end of file diff --git a/homework/03-http-server/solution/server.py b/homework/03-http-server/solution/server.py new file mode 100644 index 0000000..ca8537b --- /dev/null +++ b/homework/03-http-server/solution/server.py @@ -0,0 +1,86 @@ +import logging +import pathlib +from dataclasses import dataclass +from socketserver import StreamRequestHandler +import typing as t +import click +import socket + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +@dataclass +class HTTPServer: + server_address: t.Tuple[str, int] + socket: socket.socket + server_domain: str + working_directory: pathlib.Path + + +class HTTPHandler(StreamRequestHandler): + server: HTTPServer + + # Use self.rfile and self.wfile to interact with the client + # Access domain and working directory with self.server.{attr} + def handle(self) -> None: + first_line = self.rfile.readline() + logger.info(f"Handle connection from {self.client_address}, first_line {first_line}") + + # TODO: Read the remaining headers, handle the request and send a response. + # Read the body separately using its Content-Length, not until EOF. + # Keep memory bounded when processing large files. + + pass + + +@click.command() +@click.option("--host", envvar="SERVER_HOST", default="0.0.0.0", type=str) +@click.option("--port", envvar="SERVER_PORT", default=8080, type=int) +@click.option("--server-domain", envvar="SERVER_DOMAIN", default="localhost", type=str) +@click.option("--working-directory", envvar="SERVER_WORKING_DIRECTORY", type=str) +def main(host, port, server_domain, working_directory): + if not working_directory: + raise SystemExit(1) + + working_directory_path = pathlib.Path(working_directory) + + logger.info( + f"Starting server on {host}:{port}, domain {server_domain}, working directory {working_directory}" + ) + + # Create a server socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + # Set SO_REUSEADDR option + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Bind the socket object to the address and port + s.bind((host, port)) + # Start listening for incoming connections + s.listen() + + logger.info(f"Listening at {s.getsockname()}") + server = HTTPServer((host, port), s, server_domain, working_directory_path) + + while True: + # Accept any new connection (request, client_address) + try: + conn, addr = s.accept() + except OSError: + break + + try: + # Handle the request + HTTPHandler(conn, addr, server) + + # Close the connection + conn.shutdown(socket.SHUT_WR) + conn.close() + except Exception as e: + logger.error(e) + conn.close() + + +if __name__ == "__main__": + main() diff --git a/homework/03-http-server/tests/.dockerignore b/homework/03-http-server/tests/.dockerignore new file mode 100644 index 0000000..23178f6 --- /dev/null +++ b/homework/03-http-server/tests/.dockerignore @@ -0,0 +1,7 @@ +target +**/target +**/__pycache__ +**/.pytest_cache +*.pyc +tmp/ +tests.log diff --git a/homework/03-http-server/tests/.gitignore b/homework/03-http-server/tests/.gitignore new file mode 100644 index 0000000..a9a5aec --- /dev/null +++ b/homework/03-http-server/tests/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/homework/03-http-server/tests/Dockerfile b/homework/03-http-server/tests/Dockerfile new file mode 100644 index 0000000..cd6e46a --- /dev/null +++ b/homework/03-http-server/tests/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 +FROM docker:dind + +RUN apk add --no-cache bash ca-certificates go wget + +WORKDIR /hw +COPY go.mod go.sum tests/ +RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \ + cd tests && go mod download +COPY . tests +RUN --mount=type=cache,id=distsys-course-go-mod,target=/go/pkg/mod,sharing=locked \ + --mount=type=cache,id=distsys-course-go-build,target=/root/.cache/go-build,sharing=locked \ + cd tests && go test -c -o /usr/local/bin/hw3test . +COPY entrypoint.sh /usr/local/bin/ +COPY verify-zram-scratch.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/verify-zram-scratch.sh + +ENTRYPOINT ["timeout", "-k", "10", "600", "entrypoint.sh"] diff --git a/homework/03-http-server/tests/compare.go b/homework/03-http-server/tests/compare.go new file mode 100644 index 0000000..a81f4eb --- /dev/null +++ b/homework/03-http-server/tests/compare.go @@ -0,0 +1,124 @@ +package hw3test + +import ( + "bytes" + "fmt" + "github.com/stretchr/testify/require" + "io" + "math/rand" + "os" + "path" +) + +// RequireFileContent requires that the contents of the file match the contents of the reader. +func RequireFileContent(t *TC, workdir, p string, f *EnvFile) { + fullpath := path.Join(workdir, p) + file, err := os.Open(fullpath) + require.NoError(t, err, "failed to open file %s, expected it exists on disk", p) + defer file.Close() + + stat, err := file.Stat() + require.NoError(t, err, "failed to stat file %s", p) + require.Equal(t, f.Size, stat.Size(), "file %s has wrong size", p) + require.NoError(t, CompareFileContent(t, file, f), "file %s has wrong content", p) +} + +// CompareFileContent compares first f.Size bytes of the reader with generated content. +func CompareFileContent(t *TC, r io.Reader, f *EnvFile) error { + actual := make([]byte, 64*1024) + expected := make([]byte, len(actual)) + var generator io.Reader + if f.TextOnly { + generator = f.Open() + } else { + generator = newComparisonReader(f.GenSeed) + } + for offset := int64(0); offset < f.Size; { + count := int64(len(actual)) + if remaining := f.Size - offset; remaining < count { + count = remaining + } + if _, err := io.ReadFull(r, actual[:count]); err != nil { + return fmt.Errorf("unexpected file error at position %d: %w", offset, err) + } + if _, err := io.ReadFull(generator, expected[:count]); err != nil { + return fmt.Errorf("unexpected generator error at position %d: %w", offset, err) + } + if !bytes.Equal(actual[:count], expected[:count]) { + for i := int64(0); i < count; i++ { + if actual[i] != expected[i] { + return fmt.Errorf("position %d, expected byte %d, got %d", offset+i, expected[i], actual[i]) + } + } + } + offset += count + } + + return nil +} + +// comparisonReader emits the same bytes as rand.New(source).Read, seven bytes +// from each Int63 value. It fills complete seven-byte groups without the +// per-byte branch in math/rand.Read. Request bodies still use EnvFile.Open. +type comparisonReader struct { + source rand.Source + value uint64 + remaining uint8 +} + +func newComparisonReader(seed int64) *comparisonReader { + return &comparisonReader{source: rand.NewSource(seed)} +} + +func (r *comparisonReader) Read(p []byte) (int, error) { + n := len(p) + for len(p) > 0 && r.remaining > 0 { + p[0] = byte(r.value) + r.value >>= 8 + r.remaining-- + p = p[1:] + } + for len(p) >= 7 { + value := uint64(r.source.Int63()) + p[0] = byte(value) + p[1] = byte(value >> 8) + p[2] = byte(value >> 16) + p[3] = byte(value >> 24) + p[4] = byte(value >> 32) + p[5] = byte(value >> 40) + p[6] = byte(value >> 48) + p = p[7:] + } + if len(p) > 0 { + r.value = uint64(r.source.Int63()) + r.remaining = 7 + for i := range p { + p[i] = byte(r.value) + r.value >>= 8 + r.remaining-- + } + } + return n, nil +} + +// RequireDir ensures that the directory exists on disk. +func RequireDir(t *TC, workdir string, p string, dir *EnvDir) { + fullpath := path.Join(workdir, p) + stat, err := os.Stat(fullpath) + require.NoError(t, err, "failed to stat directory %s, expected it exists on disk", p) + require.True(t, stat.IsDir(), "expected %s to be a directory", p) +} + +// RequireNotExists ensures that the file/directory does not exist on disk. +func RequireNotExists(t *TC, workdir string, p string) { + fullpath := path.Join(workdir, p) + _, err := os.Stat(fullpath) + require.True(t, os.IsNotExist(err), "expected %s to not exist on disk", p) +} + +// RequireExists ensures that the file/directory exists on disk. +func RequireExists(t *TC, workdir string, p string) { + fullpath := path.Join(workdir, p) + _, err := os.Stat(fullpath) + require.NoError(t, err, "failed to stat %s, expected it exists on disk", p) +} diff --git a/homework/03-http-server/tests/compare_reader_test.go b/homework/03-http-server/tests/compare_reader_test.go new file mode 100644 index 0000000..e4c35ee --- /dev/null +++ b/homework/03-http-server/tests/compare_reader_test.go @@ -0,0 +1,60 @@ +package hw3test + +import ( + "bytes" + "io" + "math/rand" + "testing" +) + +func TestComparisonReaderMatchesEnvFile(t *testing.T) { + for _, seed := range []int64{-7824, 0, 1, 7824, 9223372036854775807} { + for _, chunkSize := range []int{1, 6, 7, 8, 13, 64 * 1024, 131071} { + const total = 1024*1024 + 19 + expected := make([]byte, total) + actual := make([]byte, total) + _, err := io.ReadFull(rand.New(rand.NewSource(seed)), expected) + if err != nil { + t.Fatal(err) + } + reader := newComparisonReader(seed) + for offset := 0; offset < total; { + end := offset + chunkSize + if end > total { + end = total + } + if _, err := io.ReadFull(reader, actual[offset:end]); err != nil { + t.Fatal(err) + } + offset = end + } + if !bytes.Equal(actual, expected) { + t.Fatalf("different bytes for seed %d and chunk size %d", seed, chunkSize) + } + } + } +} + +func BenchmarkComparisonReaders(b *testing.B) { + const size = 128 * 1024 * 1024 + buffer := make([]byte, 64*1024) + for _, candidate := range []struct { + name string + open func() io.Reader + }{ + {"math/rand", func() io.Reader { return rand.New(rand.NewSource(7824)) }}, + {"comparison", func() io.Reader { return newComparisonReader(7824) }}, + } { + b.Run(candidate.name, func(b *testing.B) { + b.SetBytes(size) + for i := 0; i < b.N; i++ { + reader := candidate.open() + for remaining := size; remaining > 0; remaining -= len(buffer) { + if _, err := io.ReadFull(reader, buffer); err != nil { + b.Fatal(err) + } + } + } + }) + } +} diff --git a/homework/03-http-server/tests/contract.go b/homework/03-http-server/tests/contract.go new file mode 100644 index 0000000..b7623d0 --- /dev/null +++ b/homework/03-http-server/tests/contract.go @@ -0,0 +1,301 @@ +package hw3test + +import ( + "bufio" + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/stretchr/testify/require" +) + +// Contract cases use a small separate tree, so snapshots never scan G6/G7 files. +func RunContractTests(t *TC, runner Runner, tmpRoot, group string) { + t.RunByName("contract", func(t *TC) { + workdir, err := os.MkdirTemp(tmpRoot, "contract-") + require.NoError(t, err) + defer os.RemoveAll(workdir) + require.NoError(t, os.Chmod(workdir, 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(workdir, "dir", "nested"), 0755)) + ascii := []byte("hello\r\nworld\n") + binary := []byte{0, 255, 13, 10, 13, 10, 128, 1} + if group == "G1" || group == "G3" { + binary = ascii + } + for name, body := range map[string][]byte{ + "alpha.txt": ascii, "binary": binary, "empty": {}, + ".hidden": []byte("hidden"), "dir/nested/child": []byte("child"), + } { + require.NoError(t, os.WriteFile(filepath.Join(workdir, filepath.FromSlash(name)), body, 0644)) + } + port, err := GetFreePort() + require.NoError(t, err) + opts := RunOpts{Port: port, WorkingDirectory: workdir, ListenAddr: "0.0.0.0", ServerDomain: "localhost"} + opts.GenerateRunConfig(t, rand.New(rand.NewSource(9103)), &EnvGen{AllowEnv: true}) + stop, err := runner.Run(t, opts) + require.NoError(t, err) + defer stop() + require.NoError(t, WaitForServer(t, opts)) + extra := group == "G5" || group == "G7" + + request := func(method, path, headers string, body []byte) string { + return fmt.Sprintf("%s %s HTTP/1.1\r\nhOsT: LOCALHOST\r\ncOnTeNt-LeNgTh: %d\r\n%s\r\n%s", method, path, len(body), headers, body) + } + run := func(name, raw string, split bool, codes []int, expected []byte, unchanged bool) { + t.RunByName(name, func(t *TC) { + before, err := snapshotTree(workdir) + require.NoError(t, err) + parts := [][]byte{[]byte(raw)} + if split { + // Split inside a header and inside CRLFCRLF; coalesce its end with body bytes. + boundary := strings.Index(raw, "\r\n\r\n") + parts = [][]byte{[]byte(raw[:9]), []byte(raw[9 : boundary+3]), []byte(raw[boundary+3:])} + } + resp, body := contractExchange(t, opts, parts) + require.Contains(t, codes, resp.StatusCode) + if resp.StatusCode >= 400 { + require.NotEmpty(t, bytes.TrimSpace(body), "error explanation is empty") + } + if extra { + require.NotEmpty(t, resp.Header.Get("Server")) + if len(body) > 0 { + mediaType, err := parseContentType(resp.Header.Get("Content-Type")) + require.NoError(t, err) + if methodIsDirectoryGet(raw) && resp.StatusCode == 200 { + require.Contains(t, []string{"text/plain", "text/html"}, mediaType) + } + } + } + if expected != nil { + require.Empty(t, resp.Header.Get("Content-Encoding")) + require.Equal(t, expected, body) + } + if unchanged { + after, err := snapshotTree(workdir) + require.NoError(t, err) + require.NoError(t, compareSnapshots(before, after)) + } + }) + } + + if group == "G4" || group == "G5" || group == "G6" || group == "G7" { + run("post-root-file", request("POST", "/", "", binary), false, []int{409}, nil, true) + run("post-root-dir", request("POST", "/", "Create-Directory: True\r\n", nil), false, []int{409}, nil, true) + run("put-root", request("PUT", "/", "", binary), false, []int{409}, nil, true) + for _, value := range []string{"absent", "False", "True"} { + header := "" + if value != "absent" { + header = "Remove-Directory: " + value + "\r\n" + } + run("delete-root-"+value, request("DELETE", "/", header, nil), false, []int{403}, nil, true) + } + if extra { + raw := strings.Replace(request("DELETE", "/", "Remove-Directory: True\r\n", nil), "LOCALHOST", "wrong.example", 1) + run("wrong-host-delete-root", raw, false, []int{400}, nil, true) + } + } + + switch group { + case "G1": + run("split-headers", request("GET", "/alpha.txt", "", nil), true, []int{200}, ascii, true) + // No Content-Length is also valid for an empty request. + run("no-request-body", "GET /alpha.txt HTTP/1.1\r\nHost: localhost\r\n\r\n", false, []int{200}, ascii, true) + run("empty-file", request("GET", "/empty", "", nil), false, []int{200}, []byte{}, true) + case "G2": + run("binary-body", request("GET", "/binary", "", nil), true, []int{200}, binary, true) + case "G3": + run("missing", request("GET", "/missing", "", nil), false, []int{404}, nil, true) + t.RunByName("root-listing", func(t *TC) { + resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", "/", "", nil))}) + require.Equal(t, 200, resp.StatusCode) + require.Empty(t, resp.Header.Get("Content-Encoding")) + for _, name := range []string{"alpha.txt", "binary", "empty", ".hidden", "dir"} { + require.Contains(t, string(body), name) + } + }) + case "G4", "G5": + for _, tc := range []struct { + name, method, path, headers string + code int + }{ + {"post-existing-file", "POST", "/alpha.txt", "", 409}, + {"post-existing-dir", "POST", "/dir", "Create-Directory: True\r\n", 409}, + {"post-missing-parent", "POST", "/missing/new", "", 404}, + {"post-dir-missing-parent", "POST", "/missing/new", "Create-Directory: True\r\n", 404}, + {"post-file-as-parent", "POST", "/alpha.txt/child", "", 404}, + {"put-missing", "PUT", "/missing", "", 404}, + {"put-dir", "PUT", "/dir", "", 409}, + {"delete-missing", "DELETE", "/missing", "", 404}, + {"delete-dir-absent", "DELETE", "/dir", "", 406}, + {"delete-dir-false", "DELETE", "/dir", "rEmOvE-dIrEcToRy: False\r\n", 406}, + } { + body := []byte(nil) + if tc.method == "PUT" || (tc.method == "POST" && tc.headers == "") { + body = binary + } + run(tc.name, request(tc.method, tc.path, tc.headers, body), false, []int{tc.code}, nil, true) + } + run("create-file-false", request("POST", "/new", "cReAtE-dIrEcToRy: False\r\n", binary), true, []int{200, 201}, nil, false) + run("read-created", request("GET", "/new", "", nil), false, []int{200}, binary, true) + run("create-empty", request("POST", "/new-empty", "", nil), false, []int{200, 201}, nil, false) + run("read-empty", request("GET", "/new-empty", "", nil), false, []int{200}, []byte{}, true) + run("replace-shorter", request("PUT", "/alpha.txt", "", []byte("x")), true, []int{200, 204}, nil, false) + run("read-shorter", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte("x"), true) + run("replace-empty", request("PUT", "/alpha.txt", "", nil), false, []int{200, 204}, nil, false) + run("read-replaced-empty", request("GET", "/alpha.txt", "", nil), false, []int{200}, []byte{}, true) + run("create-dir", request("POST", "/created-dir", "cReAtE-dIrEcToRy: True\r\n", nil), false, []int{200, 201}, nil, false) + t.RunByName("created-dir-on-disk", func(t *TC) { + entries, err := os.ReadDir(filepath.Join(workdir, "created-dir")) + require.NoError(t, err) + require.Empty(t, entries) + }) + run("recursive-delete", request("DELETE", "/dir", "rEmOvE-dIrEcToRy: True\r\n", nil), false, []int{200}, nil, false) + t.RunByName("deleted-tree-on-disk", func(t *TC) { + _, err := os.Stat(filepath.Join(workdir, "dir")) + require.True(t, os.IsNotExist(err), "directory must be removed") + }) + if extra { + for _, method := range []string{"GET", "POST", "PUT", "DELETE"} { + raw := strings.Replace(request(method, "/alpha.txt", "", []byte("changed")), "LOCALHOST", "wrong.example", 1) + run("wrong-host-"+method, raw, false, []int{400}, nil, true) + } + } + case "G7": + for _, path := range []string{"/binary", "/dir", "/empty"} { + t.RunByName("gzip-"+strings.TrimPrefix(path, "/"), func(t *TC) { + before, err := snapshotTree(workdir) + require.NoError(t, err) + resp, body := contractExchange(t, opts, [][]byte{[]byte(request("GET", path, "aCcEpT-eNcOdInG: gzip\r\n", nil))}) + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) + require.NotEmpty(t, resp.Header.Get("Server")) + mediaType, err := parseContentType(resp.Header.Get("Content-Type")) + require.NoError(t, err) + gz, err := gzip.NewReader(bytes.NewReader(body)) + require.NoError(t, err) + decoded, err := io.ReadAll(gz) + require.NoError(t, err) + require.NoError(t, gz.Close()) + if path == "/binary" { + require.Equal(t, binary, decoded) + } + if path == "/empty" { + require.Empty(t, decoded) + } + if path == "/dir" { + require.Contains(t, string(decoded), "nested") + require.Contains(t, []string{"text/plain", "text/html"}, mediaType) + } + after, err := snapshotTree(workdir) + require.NoError(t, err) + require.NoError(t, compareSnapshots(before, after)) + }) + } + } + }) +} + +func methodIsDirectoryGet(raw string) bool { return strings.HasPrefix(raw, "GET / HTTP/") } + +// The client leaves its sending side open: a server reading to EOF must time out. +func contractExchange(t *TC, opts RunOpts, parts [][]byte) (*http.Response, []byte) { + req, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(bytes.Join(parts, nil)))) + require.NoError(t, err) + defer req.Body.Close() + address, err := url.Parse(opts.Address()) + require.NoError(t, err) + conn, err := net.DialTimeout("tcp", address.Host, 10*time.Second) + require.NoError(t, err) + defer conn.Close() + require.NoError(t, conn.SetDeadline(time.Now().Add(10*time.Second))) + for i, part := range parts { + _, err := io.Copy(conn, bytes.NewReader(part)) + require.NoError(t, err) + if i+1 < len(parts) { + time.Sleep(20 * time.Millisecond) + } + } + resp, body, err := readContractResponse(bufio.NewReader(conn), req.ContentLength > 0) + require.NoError(t, err) + return resp, body +} + +func readContractResponse(reader *bufio.Reader, requestHasBody bool) (*http.Response, []byte, error) { + resp, err := http.ReadResponse(reader, nil) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + if err := validateResponseFraming(resp); err != nil { + return resp, nil, err + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, err + } + if resp.StatusCode != 204 && int64(len(body)) != resp.ContentLength { + return resp, nil, fmt.Errorf("response length mismatch") + } + if err := checkResponseEnd(reader, requestHasBody); err != nil { + return resp, nil, err + } + return resp, body, nil +} + +type treeEntry struct { + Mode os.FileMode + Size int64 + Digest [32]byte +} + +func snapshotTree(root string) (map[string]treeEntry, error) { + result := make(map[string]treeEntry) + err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + entry := treeEntry{Mode: info.Mode()} + if info.Mode().IsRegular() { + entry.Size = info.Size() + f, err := os.Open(p) + if err != nil { + return err + } + h := sha256.New() + _, copyErr := io.Copy(h, f) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + copy(entry.Digest[:], h.Sum(nil)) + } + result[rel] = entry + return nil + }) + return result, err +} + +func compareSnapshots(before, after map[string]treeEntry) error { + if !reflect.DeepEqual(before, after) { + return fmt.Errorf("request changed the file system unexpectedly") + } + return nil +} diff --git a/homework/03-http-server/tests/contract_test.go b/homework/03-http-server/tests/contract_test.go new file mode 100644 index 0000000..8501b5e --- /dev/null +++ b/homework/03-http-server/tests/contract_test.go @@ -0,0 +1,144 @@ +package hw3test + +import ( + "bufio" + "errors" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestContractResponseRejectsBadFraming(t *testing.T) { + for _, tc := range []struct { + name, response string + valid bool + }{ + {"ok", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 4\r\n\r\ndata", true}, + {"short", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 5\r\n\r\ndata", false}, + {"long", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\ndata", false}, + {"no-close", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", false}, + {"no-length", "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n", false}, + {"204", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", true}, + {"204-length", "HTTP/1.1 204 No Content\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", false}, + {"204-body", "HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\nx", false}, + {"chunked", "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := readContractResponse(bufio.NewReader(strings.NewReader(tc.response)), false) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v, error=%v", tc.valid, err) + } + }) + } +} + +type resetReader struct{} + +func (resetReader) Read([]byte) (int, error) { + return 0, &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset")} +} + +func TestContractResponseAfterEarlyRejection(t *testing.T) { + for _, tc := range []struct { + name, body string + requestHasBody bool + valid bool + }{ + {"complete error then reset", "error", true, true}, + {"reset without request body", "error", false, false}, + {"truncated error", "err", true, false}, + {"extra response byte", "error!", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + wire := "HTTP/1.1 409 Conflict\r\nConnection: close\r\nContent-Length: 5\r\n\r\n" + tc.body + reader := bufio.NewReader(io.MultiReader(strings.NewReader(wire), resetReader{})) + _, _, err := readContractResponse(reader, tc.requestHasBody) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v, error=%v", tc.valid, err) + } + }) + } +} + +func TestSnapshotDetectsErrorSideEffects(t *testing.T) { + for _, mutation := range []string{"overwrite", "create", "delete", "mkdir"} { + t.Run(mutation, func(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "file") + if err := os.WriteFile(file, []byte("before"), 0644); err != nil { + t.Fatal(err) + } + before, err := snapshotTree(root) + if err != nil { + t.Fatal(err) + } + after, err := snapshotTree(root) + if err != nil { + t.Fatal(err) + } + if err := compareSnapshots(before, after); err != nil { + t.Fatal(err) + } + switch mutation { + case "overwrite": + err = os.WriteFile(file, []byte("after!"), 0644) + case "create": + err = os.WriteFile(filepath.Join(root, "new"), nil, 0644) + case "delete": + err = os.Remove(file) + case "mkdir": + err = os.Mkdir(filepath.Join(root, "new"), 0755) + } + if err != nil { + t.Fatal(err) + } + after, err = snapshotTree(root) + if err != nil { + t.Fatal(err) + } + if err := compareSnapshots(before, after); err == nil { + t.Fatal("side effect was accepted") + } + }) + } +} + +func TestMissingPathActionsAndRoot(t *testing.T) { + env := &Env{RootDir: &EnvDir{Listing: map[string]EnvNode{"file": &EnvFile{}, "dir": &EnvDir{Listing: map[string]EnvNode{}}}}} + opts := &RunOpts{ServerDomain: "localhost"} + for _, tc := range []struct { + method, path string + status int + }{ + {"POST", "missing/new", 404}, {"POST", "file/new", 404}, + {"POST", "dir", 409}, {"PUT", "missing", 404}, {"PUT", "dir", 409}, {"DELETE", "missing", 404}, + {"POST", "/", 409}, {"PUT", "/", 409}, {"DELETE", "/", 403}, + } { + q := Query{Method: tc.method, Path: tc.path, HostHeader: "LOCALHOST"} + action, ok := q.Action(env, opts).(HttpErrorAction) + if !ok || action.Status != tc.status { + t.Errorf("%s %s: got %#v", tc.method, tc.path, q.Action(env, opts)) + } + } + q := Query{Method: "GET", Path: "", HostHeader: "LOCALHOST"} + if _, ok := q.Action(env, opts).(GetDirAction); !ok { + t.Fatal("root GET is not a listing") + } + for _, root := range []string{"", "/"} { + for _, remove := range []bool{false, true} { + q := Query{Method: "DELETE", Path: root, HostHeader: "localhost", RemoveDirectory: remove} + action, ok := q.Action(env, opts).(HttpErrorAction) + if !ok || action.Status != 403 { + t.Fatalf("root DELETE with RemoveDirectory=%v: got %#v", remove, action) + } + q.HostHeader = "wrong.example" + action, ok = q.Action(env, opts).(HttpErrorAction) + if !ok || action.Status != 400 { + t.Fatalf("Host must be checked before root DELETE: got %#v", action) + } + } + } +} diff --git a/homework/03-http-server/tests/entrypoint.sh b/homework/03-http-server/tests/entrypoint.sh new file mode 100644 index 0000000..74d7372 --- /dev/null +++ b/homework/03-http-server/tests/entrypoint.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +set -u +set -o pipefail + +fatal() { + printf 'harness: %s\n' "$*" >&2 + exit 1 +} + +wait_for_docker() { + attempts=30 + while [ "$attempts" -gt 0 ]; do + docker info >/dev/null 2>&1 && return 0 + attempts=$((attempts - 1)) + sleep 1 + done + return 1 +} + +cleanup() { + docker ps -q --filter ancestor=hw3img | while IFS= read -r container_id; do + [ -n "$container_id" ] || continue + docker rm --force "$container_id" >/dev/null 2>&1 || true + done +} + +run_tests() { + local arg + local -a original_args=("$@") + local -a test_args=(-docker -test.timeout=10m) + while (($#)); do + arg="$1" + shift + case "$arg" in + -run|-count|-timeout|-parallel|-skip) + if (($# == 0)); then + fatal "$arg requires a value" + fi + test_args+=("-test.${arg#-}" "$1") + shift + ;; + -run=*|-count=*|-timeout=*|-parallel=*|-skip=*) + test_args+=("-test.${arg#-}") + ;; + -v|-short|-failfast|-v=*|-short=*|-failfast=*) + test_args+=("-test.${arg#-}") + ;; + -test.*|-docker) + test_args+=("$arg") + ;; + *) + # Keep Go's flag handling for less common test and build options. + go test --docker -timeout 10m "${original_args[@]}" + return $? + ;; + esac + done + /usr/local/bin/hw3test "${test_args[@]}" +} + +validate_score_file() { + score_lines="$(grep -c '^SCORE:' "$1" || true)" + valid_score_lines="$(grep -Ec '^SCORE: [0-9]+([.][0-9]{1,2})?$' "$1" || true)" + [ "$score_lines" -eq 1 ] && [ "$valid_score_lines" -eq 1 ] \ + || fatal "test command must report exactly one valid SCORE line" +} + +configure_registry_proxy() { + proxy_url="${DISTSYS_REGISTRY_PROXY_URL:-}" + ca_file="/run/distsys-registry-proxy/ca.crt" + + [ -n "$proxy_url" ] || return 1 + printf '%s' "$proxy_url" | grep -Eq '^http://[A-Za-z0-9.-]+(:[0-9]+)?$' || { + printf 'warning: invalid registry proxy URL; using direct Docker pulls\n' >&2 + return 1 + } + [ -f "$ca_file" ] && [ ! -L "$ca_file" ] || { + printf 'warning: registry proxy CA is unavailable; using direct Docker pulls\n' >&2 + return 1 + } + + downloaded_ca="$(mktemp)" || return 1 + if ! wget -q -T 5 -O "$downloaded_ca" "${proxy_url}/ca.crt" \ + || ! cmp -s "$ca_file" "$downloaded_ca"; then + rm -f "$downloaded_ca" + printf 'warning: registry proxy is unavailable or its CA differs; using direct Docker pulls\n' >&2 + return 1 + fi + rm -f "$downloaded_ca" + + cp "$ca_file" /usr/local/share/ca-certificates/distsys-registry-proxy.crt + update-ca-certificates >/dev/null + proxy_no_proxy='localhost,127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' + export HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" NO_PROXY="$proxy_no_proxy" + export http_proxy="$proxy_url" https_proxy="$proxy_url" no_proxy="$proxy_no_proxy" +} + +configure_registry_proxy || unset HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy + +if [ "${HW3_REQUIRE_ZRAM:-0}" = 1 ]; then + /usr/local/bin/verify-zram-scratch.sh || fatal "HW3_REQUIRE_ZRAM=1 but /hw/tests/tmp is not mounted from a zram ext4 device" +fi + +# Start docker daemon +trap cleanup EXIT +dockerd-entrypoint.sh --storage-driver=overlay2 >/var/log/dockerd-entrypoint.log 2>&1 & +if ! wait_for_docker; then + tail -n 200 /var/log/dockerd-entrypoint.log >&2 || true + fatal "inner Docker daemon did not become ready" +fi + +# Build server image +docker build solution -t hw3img || fatal "could not build solution image" + +# Run tests +cd tests +export NO_COLOR=1 +test_status=0 +run_tests "$@" 2>&1 | tee tests.log || test_status=$? +validate_score_file tests.log + +# Go reports a non-zero status when some grading groups fail. A valid score is +# the grading result; absence of a score remains an infrastructure failure. +if [ "$test_status" -ne 0 ]; then + printf 'harness: grading tests exited with status %s after reporting a score\n' \ + "$test_status" >&2 +fi diff --git a/homework/03-http-server/tests/env.go b/homework/03-http-server/tests/env.go new file mode 100644 index 0000000..9b044ef --- /dev/null +++ b/homework/03-http-server/tests/env.go @@ -0,0 +1,233 @@ +package hw3test + +import ( + "fmt" + "io" + "math/rand" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +// Env contains information about all files in the test environment. +// Can be written to disk. +type Env struct { + RootDir *EnvDir +} + +// Lookup returns the node for the given path. +func (e *Env) Lookup(p string) (*EnvDir, EnvNode) { + p = path.Clean(strings.TrimPrefix(p, "/")) + if p == "." { + return nil, e.RootDir + } + dir, file := path.Split(p) + + dirs := strings.Split(dir, "/") + dirs = dirs[:len(dirs)-1] + + parent := e.RootDir + for _, d := range dirs { + nxt, ok := parent.Listing[d] + if !ok { + return nil, nil + } + parent, ok = nxt.(*EnvDir) + if !ok { + // not a directory + return nil, nil + } + } + + nxt := parent.Listing[file] + return parent, nxt +} + +// Clone returns deep clone of an Env. +func (e *Env) Clone() *Env { + return &Env{ + RootDir: e.RootDir.Clone().(*EnvDir), + } +} + +// EnvNode is a file/dir. +type EnvNode interface { + // WriteToDisk persists node and its children to disk. + WriteToDisk(path string) error + + // Stats aggregates stats of the node and its children. + Stats(path string, stats *Stats) + + // Clone creates a deep copy of the node. + Clone() EnvNode +} + +// EnvDir is a virtual directory, that can be written to disk. +type EnvDir struct { + Listing map[string]EnvNode + Depth int +} + +func (d *EnvDir) WriteToDisk(p string) error { + err := os.Mkdir(p, 0777) + if err != nil { + return err + } + + for name, writter := range d.Listing { + nxt := path.Join(p, name) + err := writter.WriteToDisk(nxt) + if err != nil { + return err + } + } + return nil +} + +// WriteToDiskSelected keeps the size and directory structure of every initial +// file, but only materializes contents for paths that a query may read. +func (d *EnvDir) WriteToDiskSelected(p string, needed map[string]bool) error { + return d.writeToDiskSelected(p, "", needed) +} + +func (d *EnvDir) writeToDiskSelected(p, relative string, needed map[string]bool) error { + if err := os.Mkdir(p, 0777); err != nil { + return err + } + for name, node := range d.Listing { + childPath := filepath.Join(p, name) + childRelative := path.Join(relative, name) + switch child := node.(type) { + case *EnvDir: + if err := child.writeToDiskSelected(childPath, childRelative, needed); err != nil { + return err + } + case *EnvFile: + if needed[childRelative] { + if err := child.WriteToDisk(childPath); err != nil { + return err + } + } else if err := child.WriteSparseToDisk(childPath); err != nil { + return err + } + default: + return fmt.Errorf("unsupported environment node %T", child) + } + } + return nil +} + +func (d *EnvDir) Stats(p string, stats *Stats) { + stats.Dirs++ + stats.DirPaths = append(stats.DirPaths, p) + for name, node := range d.Listing { + node.Stats(path.Join(p, name), stats) + } +} + +func (d *EnvDir) Clone() EnvNode { + newDir := &EnvDir{ + Listing: map[string]EnvNode{}, + Depth: d.Depth, + } + + for name, node := range d.Listing { + newDir.Listing[name] = node.Clone() + } + return newDir +} + +func (d *EnvDir) CreateDir(name string) (dir *EnvDir, exist bool) { + _, exist = d.Listing[name] + if exist { + return nil, exist + } + + newDir := &EnvDir{ + Listing: map[string]EnvNode{}, + Depth: d.Depth + 1, + } + d.Listing[name] = newDir + return newDir, false +} + +// EnvFile is a virtual file, that can be written to disk. +type EnvFile struct { + GenSeed int64 + Size int64 + TextOnly bool +} + +func (f *EnvFile) Open() io.Reader { + var gen io.Reader = rand.New(rand.NewSource(f.GenSeed)) + if f.TextOnly { + gen = &TextReader{gen} + } + gen = io.LimitReader(gen, f.Size) + return gen +} + +func (f *EnvFile) WriteToDisk(p string) error { + file, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) + if err != nil { + return err + } + defer file.Close() + + reader := f.Open() + _, err = io.CopyBuffer(file, reader, make([]byte, 256*1024)) + return err +} + +func (f *EnvFile) WriteSparseToDisk(p string) error { + file, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) + if err != nil { + return err + } + defer file.Close() + return file.Truncate(f.Size) +} + +func (f *EnvFile) Stats(p string, stats *Stats) { + stats.Files++ + stats.Size += f.Size + stats.FilePaths = append(stats.FilePaths, p) +} + +func (f *EnvFile) Clone() EnvNode { + return &EnvFile{ + GenSeed: f.GenSeed, + Size: f.Size, + TextOnly: f.TextOnly, + } +} + +// Stats is a helper for listing all files/dirs in environment. +type Stats struct { + // Number of directories in the environment. + Files int + + // Count of directories in the environment. + Dirs int + + // Size of all files in the environment. + Size int64 + + // FilePaths to all files in the environment. + FilePaths []string + + // DirPaths to all directories in the environment. + DirPaths []string +} + +// Normalize will fix stats to be deterministic and don't contain root dir. +func (s *Stats) Normalize() { + sort.Strings(s.FilePaths) + sort.Strings(s.DirPaths) + + if len(s.DirPaths) > 0 && s.DirPaths[0] == "" { + s.DirPaths = s.DirPaths[1:] + } +} diff --git a/homework/03-http-server/tests/env_gen.go b/homework/03-http-server/tests/env_gen.go new file mode 100644 index 0000000..9d89585 --- /dev/null +++ b/homework/03-http-server/tests/env_gen.go @@ -0,0 +1,73 @@ +package hw3test + +import "math/rand" + +// EnvGen contains config for generating test environment. +// Test environment is a directory with files and subdirectories. +type EnvGen struct { + // Generated file tree will not contain subdirectories deeper than this depth. + MaxDepth int + // Generated file tree will not contain more than this number of subdirs. + MaxDirs int + // Generated file tree will not contain more than this number of files. + MaxFiles int + // Generated files will be text files. + TextOnly bool + // Maximum file size in KB + MaxFileSizeKB int + // TempDirectory will contain subdirectory for every new environment. + TempDirectory string + // SparseUnusedFiles avoids writing initial file contents that no query can read. + SparseUnusedFiles bool + // Allow to use env for configuration. + AllowEnv bool + // Filename generator. + FilenameGen func(r *rand.Rand) string +} + +// GenerateFile returns file with random contents. +func (g *EnvGen) GenerateFile(r *rand.Rand) *EnvFile { + return &EnvFile{ + GenSeed: r.Int63(), + Size: r.Int63n(1024 * int64(g.MaxFileSizeKB)), + TextOnly: g.TextOnly, + } +} + +// Generate generates whole file tree. +func (g *EnvGen) Generate(seed int64) (*Env, error) { + r := rand.New(rand.NewSource(seed)) + root := &EnvDir{Listing: map[string]EnvNode{}} + + dirs := []*EnvDir{root} + + for i := 0; i < g.MaxDirs; i++ { + parent := dirs[r.Intn(len(dirs))] + if parent.Depth >= g.MaxDepth { + continue + } + + name := g.FilenameGen(r) + newDir, exist := parent.CreateDir(name) + if exist { + continue + } + dirs = append(dirs, newDir) + } + + for i := 0; i < g.MaxFiles; i++ { + parent := dirs[r.Intn(len(dirs))] + + name := g.FilenameGen(r) + _, exist := parent.Listing[name] + if exist { + continue + } + + parent.Listing[name] = g.GenerateFile(r) + } + + return &Env{ + RootDir: root, + }, nil +} diff --git a/homework/03-http-server/tests/env_selected_test.go b/homework/03-http-server/tests/env_selected_test.go new file mode 100644 index 0000000..f2c3127 --- /dev/null +++ b/homework/03-http-server/tests/env_selected_test.go @@ -0,0 +1,44 @@ +package hw3test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" +) + +func TestWriteToDiskSelected(t *testing.T) { + selected := &EnvFile{GenSeed: 42, Size: 4096} + unused := &EnvFile{GenSeed: 43, Size: 4096} + root := &EnvDir{Listing: map[string]EnvNode{ + "nested": &EnvDir{Listing: map[string]EnvNode{ + "selected": selected, + "unused": unused, + }}, + }} + location := filepath.Join(t.TempDir(), "environment") + if err := root.WriteToDiskSelected(location, map[string]bool{"nested/selected": true}); err != nil { + t.Fatal(err) + } + + gotSelected, err := os.ReadFile(filepath.Join(location, "nested", "selected")) + if err != nil { + t.Fatal(err) + } + wantSelected, err := io.ReadAll(selected.Open()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(gotSelected, wantSelected) { + t.Fatal("selected file contents differ from generated contents") + } + + gotUnused, err := os.ReadFile(filepath.Join(location, "nested", "unused")) + if err != nil { + t.Fatal(err) + } + if len(gotUnused) != int(unused.Size) || !bytes.Equal(gotUnused, make([]byte, unused.Size)) { + t.Fatal("unused file must retain its size without generated contents") + } +} diff --git a/homework/03-http-server/tests/framing.go b/homework/03-http-server/tests/framing.go new file mode 100644 index 0000000..1939bdc --- /dev/null +++ b/homework/03-http-server/tests/framing.go @@ -0,0 +1,49 @@ +package hw3test + +import ( + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// validateResponseFraming checks headers before reading the response body. +func validateResponseFraming(resp *http.Response) error { + // net/http removes Connection: close from Header and records it in Close. + // With HTTP/1.1 and explicit framing, Close reflects that header. + closeFound := resp.ProtoMajor == 1 && resp.ProtoMinor == 1 && resp.Close + for _, value := range resp.Header.Values("Connection") { + for _, token := range strings.Split(value, ",") { + closeFound = closeFound || strings.EqualFold(strings.TrimSpace(token), "close") + } + } + if !closeFound { + return fmt.Errorf("expected Connection: close") + } + if len(resp.TransferEncoding) != 0 || len(resp.Header.Values("Transfer-Encoding")) != 0 { + return fmt.Errorf("Transfer-Encoding is not supported") + } + lengths := resp.Header.Values("Content-Length") + if resp.StatusCode == http.StatusNoContent { + if len(lengths) != 0 { + return fmt.Errorf("204 must not contain Content-Length") + } + } else if len(lengths) != 1 || resp.ContentLength < 0 { + return fmt.Errorf("expected exactly one valid Content-Length") + } + return nil +} + +// Bound a stalled peer, without imposing a total duration on large transfers. +type idleDeadlineReader struct { + net.Conn + Timeout time.Duration +} + +func (r *idleDeadlineReader) Read(p []byte) (int, error) { + if err := r.Conn.SetReadDeadline(time.Now().Add(r.Timeout)); err != nil { + return 0, err + } + return r.Conn.Read(p) +} diff --git a/homework/03-http-server/tests/gen_utils.go b/homework/03-http-server/tests/gen_utils.go new file mode 100644 index 0000000..ebbb859 --- /dev/null +++ b/homework/03-http-server/tests/gen_utils.go @@ -0,0 +1,49 @@ +package hw3test + +import ( + "io" + "math/rand" +) + +func SimpleFilenameGenerator(maxLen int) func(r *rand.Rand) string { + return func(r *rand.Rand) string { + return GenFilename(r, maxLen) + } +} + +func GenFilename(r *rand.Rand, maxLen int) string { + // TODO: better filename generation, right now it's only uppercase english letters + n := r.Intn(maxLen) + 1 + b := make([]byte, n) + for i := 0; i < n; i++ { + b[i] = byte(r.Intn(26) + 65) + } + return string(b) +} + +// TextReader is io.Reader filtering out non-text characters. +type TextReader struct { + r io.Reader +} + +func (t *TextReader) Read(p []byte) (n int, err error) { + for { + n, err = t.r.Read(p) + if n == 0 || err != nil { + return + } + + m := 0 + for i := 0; i < n; i++ { + // p[i] is a plain text character + if p[i] == '\n' || (p[i] >= 32 && p[i] <= 126) { + p[m] = p[i] + m++ + } + } + if m == 0 { + continue + } + return m, nil + } +} diff --git a/homework/03-http-server/tests/go.mod b/homework/03-http-server/tests/go.mod new file mode 100644 index 0000000..3478333 --- /dev/null +++ b/homework/03-http-server/tests/go.mod @@ -0,0 +1,16 @@ +module hw3test + +go 1.18 + +require ( + github.com/stretchr/testify v1.8.0 + go.uber.org/zap v1.23.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/homework/03-http-server/tests/go.sum b/homework/03-http-server/tests/go.sum new file mode 100644 index 0000000..1084bf5 --- /dev/null +++ b/homework/03-http-server/tests/go.sum @@ -0,0 +1,28 @@ +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= +go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/homework/03-http-server/tests/hw_test.go b/homework/03-http-server/tests/hw_test.go new file mode 100644 index 0000000..0f397bd --- /dev/null +++ b/homework/03-http-server/tests/hw_test.go @@ -0,0 +1,309 @@ +package hw3test + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +var ( + useDocker bool +) + +func init() { + flag.BoolVar(&useDocker, "docker", false, "Run solution in Docker") +} + +func TestHW(tt *testing.T) { + if !flag.Parsed() { + flag.Parse() + } + + t := NewTestContext(tt) + Info(t, "Starting tests") + + // loading commandline args template from disk, to run solution with it + launchTmpl := os.Getenv("LAUNCH_TMPL") + if launchTmpl == "" { + launchTmpl = "./launch.tmpl" + if useDocker { + launchTmpl = "./launch_docker.tmpl" + } + } + + tmplContent, err := os.ReadFile(launchTmpl) + require.NoError(t, err, "failed to read template file") + launchTemplate := template.Must(template.New("launch").Parse(string(tmplContent))) + + runner := NewCmdRunner(launchTemplate, useDocker) + workdir, err := os.Getwd() + require.NoError(t, err, "failed to get current directory") + tmpRoot := filepath.Join(workdir, "tmp") + require.NoError(t, os.MkdirAll(tmpRoot, 0777), "failed to create tmp dir") + workspace, err := filepath.EvalSymlinks(workdir) + require.NoError(t, err, "failed to resolve test directory") + tmpRoot, err = filepath.EvalSymlinks(tmpRoot) + require.NoError(t, err, "failed to resolve tmp dir") + rel, err := filepath.Rel(workspace, tmpRoot) + require.NoError(t, err, "failed to verify tmp dir") + require.Equal(t, "tmp", rel, "tmp dir must stay inside the test directory") + tmpDir, err := os.MkdirTemp(tmpRoot, "run-") + require.NoError(t, err, "failed to create run-specific tmp dir") + require.NoError(t, os.Chmod(tmpDir, 0755), "failed to make tmp dir accessible to the server") + t.Cleanup(func() { + resolved, err := filepath.EvalSymlinks(tmpDir) + if os.IsNotExist(err) { + return + } + require.NoError(t, err, "failed to resolve run-specific tmp dir") + rel, err := filepath.Rel(tmpRoot, resolved) + require.NoError(t, err, "failed to verify run-specific tmp dir") + require.Equal(t, filepath.Base(tmpDir), rel, "run-specific tmp dir must stay inside tmp dir") + require.NoError(t, os.RemoveAll(tmpDir), "failed to remove run-specific tmp dir") + }) + + score := 0 + + type RunResult struct { + Name string + Scored int + Max int + } + runResults := []RunResult{} + + runGroup := func(name string, points int, f func(t *TC)) { + t.RunByName(name, func(t *TC) { + Info(t, "Starting tests group", zap.Int("points", points)) + t.Cleanup(func() { + r := RunResult{ + Name: name, + Max: points, + } + + ok := !t.Failed() + if ok { + score += points + r.Scored = points + + Info(t, "Tests group passed", zap.String("name", name), zap.Int("score", score)) + } else { + Warn(t, "Tests group failed", zap.String("name", name), zap.Int("score", score)) + } + + runResults = append(runResults, r) + }) + f(t) + }) + } + + textEnv := &EnvGen{ + MaxDepth: 1, + MaxDirs: 3, + MaxFiles: 5, + TextOnly: true, + MaxFileSizeKB: 64, + TempDirectory: tmpDir, + FilenameGen: SimpleFilenameGenerator(8), + AllowEnv: false, + } + binaryEnv := &EnvGen{ + MaxDepth: 4, + MaxDirs: 16, + MaxFiles: 25, + TextOnly: false, + MaxFileSizeKB: 1024, + TempDirectory: tmpDir, + FilenameGen: SimpleFilenameGenerator(16), + AllowEnv: true, + } + largeEnv := &EnvGen{ + MaxDepth: 1, + MaxDirs: 6, + MaxFiles: 6, + TextOnly: false, + MaxFileSizeKB: 192 * 1024, // 192 MB + TempDirectory: tmpDir, + SparseUnusedFiles: true, + FilenameGen: SimpleFilenameGenerator(16), + AllowEnv: true, + } + + // Simple GET queries for existing text files, 3 points. + runGroup("G1", 3, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G1") + RunTestEmptyWorkDir(t, 42, runner) + + env := textEnv + queries := &QueriesGen{ + Count: 20, + GetFile: true, + GetFileNoErrors: true, + GetDirectory: false, + Post: false, + Put: false, + Delete: false, + Compression: false, + AllHeaders: false, + } + RunTests(t, 1337, runner, env, queries) + RunTests(t, 1338, runner, env, queries) + RunTests(t, 1339, runner, env, queries) + }) + + // Simple GET queries for existing binary files, 1 point. + runGroup("G2", 1, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G2") + env := binaryEnv + queries := &QueriesGen{ + Count: 40, + GetFile: true, + GetFileNoErrors: true, + GetDirectory: false, + Post: false, + Put: false, + Delete: false, + Compression: false, + AllHeaders: false, + } + RunTests(t, 93, runner, env, queries) + RunTests(t, 2945, runner, env, queries) + RunTests(t, 3110, runner, env, queries) + }) + + // Any GET queries, 1 point. + runGroup("G3", 1, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G3") + env := textEnv + queries := &QueriesGen{ + Count: 30, + GetFile: true, + GetFileNoErrors: false, + GetDirectory: true, + Post: false, + Put: false, + Delete: false, + Compression: false, + AllHeaders: false, + } + RunTests(t, 5311, runner, env, queries) + RunTests(t, 2863, runner, env, queries) + RunTests(t, 6712, runner, env, queries) + RunTests(t, 7233, runner, env, queries) + RunTests(t, 7067, runner, env, queries) + RunTests(t, 3930, runner, env, queries) + }) + + // Simple file server, 2 points. + runGroup("G4", 2, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G4") + env := binaryEnv + queries := &QueriesGen{ + Count: 30, + GetFile: true, + GetDirectory: true, + Post: true, + Put: true, + Delete: true, + Compression: false, + AllHeaders: false, + } + RunTests(t, 3152, runner, env, queries) + RunTests(t, 2929, runner, env, queries) + RunTests(t, 6554, runner, env, queries) + RunTests(t, 1388, runner, env, queries) + RunTests(t, 1672, runner, env, queries) + RunTests(t, 1769, runner, env, queries) + }) + + // Extra headers, 1 point. + runGroup("G5", 1, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G5") + env := binaryEnv + queries := &QueriesGen{ + Count: 30, + GetFile: true, + GetDirectory: true, + Post: true, + Put: true, + Delete: true, + Compression: false, + AllHeaders: true, + } + RunTests(t, 7942, runner, env, queries) + RunTests(t, 1479, runner, env, queries) + RunTests(t, 3324, runner, env, queries) + RunTests(t, 6519, runner, env, queries) + RunTests(t, 3746, runner, env, queries) + RunTests(t, 1961, runner, env, queries) + }) + + // Large files, 1 point. + runGroup("G6", 1, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G6") + env := largeEnv + queries := &QueriesGen{ + Count: 25, + GetFile: true, + GetDirectory: true, + LargePuts: &LargePutPolicy{ + RunSeed: 3793, + LargeSizes: []int64{160 << 20, 191 << 20}, + OtherMaxBytes: 8 << 20, + }, + Post: true, + Put: true, + Delete: true, + Compression: false, + AllHeaders: false, + } + RunTests(t, 7824, runner, env, queries) + RunTests(t, 1671, runner, env, queries) + RunTests(t, 3793, runner, env, queries) + RunTests(t, 272, runner, env, queries) + RunTests(t, 2715, runner, env, queries) + RunTests(t, 1436, runner, env, queries) + }) + + // Large files and gzip, 1 point. + runGroup("G7", 1, func(t *TC) { + RunContractTests(t, runner, tmpDir, "G7") + env := largeEnv + queries := &QueriesGen{ + Count: 30, + GetFile: true, + GetDirectory: true, + LargePuts: &LargePutPolicy{ + RunSeed: 3224, + LargeSizes: []int64{160 << 20, 191 << 20}, + OtherMaxBytes: 8 << 20, + }, + Post: true, + Put: true, + Delete: true, + Compression: true, + AllHeaders: true, + } + RunTests(t, 3224, runner, env, queries) + RunTests(t, 7507, runner, env, queries) + RunTests(t, 4172, runner, env, queries) + RunTests(t, 7777, runner, env, queries) + RunTests(t, 6666, runner, env, queries) + RunTests(t, 6094, runner, env, queries) + RunTests(t, 6442, runner, env, queries) + }) + + for _, r := range runResults { + Info(t, fmt.Sprintf("Score for group [%s]: %d / %d", r.Name, r.Scored, r.Max)) + } + + Info(t, "Tests finished", zap.Int("score", score)) + fmt.Println("==================================================================================") + fmt.Printf("SCORE: %d\n", score) + fmt.Println("==================================================================================") +} diff --git a/homework/03-http-server/tests/large_put_policy_test.go b/homework/03-http-server/tests/large_put_policy_test.go new file mode 100644 index 0000000..27135eb --- /dev/null +++ b/homework/03-http-server/tests/large_put_policy_test.go @@ -0,0 +1,119 @@ +package hw3test + +import ( + "math/rand" + "reflect" + "testing" +) + +func TestLargePutPlansKeepBoundaryWritesAndQueries(t *testing.T) { + tests := []struct { + name string + runSeeds []int64 + count int + largeSeed int64 + allHeaders bool + compression bool + }{ + {"G6", []int64{7824, 1671, 3793, 272, 2715, 1436}, 25, 3793, false, false}, + {"G7", []int64{3224, 7507, 4172, 7777, 6666, 6094, 6442}, 30, 3224, true, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + context := NewTestContext(t) + envGen := &EnvGen{ + MaxDepth: 1, + MaxDirs: 6, + MaxFiles: 6, + MaxFileSizeKB: 192 * 1024, + FilenameGen: SimpleFilenameGenerator(16), + } + policy := &LargePutPolicy{ + RunSeed: test.largeSeed, + LargeSizes: []int64{160 << 20, 191 << 20}, + OtherMaxBytes: 8 << 20, + } + options := RunOpts{ServerDomain: "localhost"} + largePosts := 0 + largeGets := 0 + largeGzipGets := 0 + for _, runSeed := range test.runSeeds { + env, err := envGen.Generate(runSeed) + if err != nil { + t.Fatal(err) + } + r := rand.New(rand.NewSource(runSeed)) + seeds := make([]int64, test.count) + for i := range seeds { + seeds[i] = r.Int63n(100000) + } + generator := &QueriesGen{ + Count: test.count, GetFile: true, GetDirectory: true, + Post: true, Put: true, Delete: true, + Compression: test.compression, AllHeaders: test.allHeaders, + LargePuts: policy, + } + queries := generator.Generate(context, env, envGen, seeds, options, runSeed) + generator.LargePuts = nil + baseline := generator.Generate(context, env, envGen, seeds, options, runSeed) + if len(queries) != len(baseline) { + t.Fatalf("run %d changed query count", runSeed) + } + state := env.Clone() + var largePutSizes []int64 + for i, query := range queries { + if query.Method == "PUT" { + baseline[i].FileContent.Size = query.FileContent.Size + } + if !reflect.DeepEqual(query, baseline[i]) { + t.Fatalf("run %d changed query %d beyond PUT body size", runSeed, i) + } + action := query.Action(state, &options) + if query.Path == "" && query.Method != "GET" { + t.Fatal("generated a modifying request to the working directory") + } + if get, ok := action.(GetFileAction); ok && get.File.Size > 128<<20 { + largeGets++ + if get.Compression { + largeGzipGets++ + } + } + if query.Method == "PUT" { + if query.FileContent.Size > 128<<20 { + if _, ok := action.(ReplaceFileAction); !ok { + t.Fatalf("run %d has a large PUT that cannot update a file", runSeed) + } + largePutSizes = append(largePutSizes, query.FileContent.Size) + } else if query.FileContent.Size > policy.OtherMaxBytes { + t.Fatalf("run %d has an unbounded PUT", runSeed) + } + } + if query.Method == "POST" && query.FileContent != nil && query.FileContent.Size > 128<<20 { + if _, ok := action.(CreateFileAction); ok { + largePosts++ + } + } + if action != nil { + action.ApplyEnv(context, state) + } + } + if runSeed == test.largeSeed { + if !reflect.DeepEqual(largePutSizes, policy.LargeSizes) { + t.Fatalf("run %d large PUT sizes: got %v, want %v", runSeed, largePutSizes, policy.LargeSizes) + } + } else if len(largePutSizes) != 0 { + t.Fatalf("run %d has unexpected large PUTs", runSeed) + } + } + if largePosts == 0 { + t.Fatal("no successful POST larger than the solution memory limit") + } + if largeGets == 0 { + t.Fatal("no successful GET larger than the solution memory limit") + } + if test.compression && largeGzipGets == 0 { + t.Fatal("no gzip GET larger than the solution memory limit") + } + }) + } +} diff --git a/homework/03-http-server/tests/launch.tmpl b/homework/03-http-server/tests/launch.tmpl new file mode 100644 index 0000000..cf80c1e --- /dev/null +++ b/homework/03-http-server/tests/launch.tmpl @@ -0,0 +1,2 @@ +{{- /*gotype: hw3test.RunOpts */ -}} +python3 ../solution/server.py {{.CommandLineArgs}} diff --git a/homework/03-http-server/tests/launch_docker.tmpl b/homework/03-http-server/tests/launch_docker.tmpl new file mode 100644 index 0000000..d9da35f --- /dev/null +++ b/homework/03-http-server/tests/launch_docker.tmpl @@ -0,0 +1,2 @@ +{{- /*gotype: hw3test.RunOpts */ -}} +docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}} diff --git a/homework/03-http-server/tests/logs.go b/homework/03-http-server/tests/logs.go new file mode 100644 index 0000000..881a943 --- /dev/null +++ b/homework/03-http-server/tests/logs.go @@ -0,0 +1,94 @@ +package hw3test + +import ( + "fmt" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "os" + "sync" +) + +func logLevelFromEnv() zap.AtomicLevel { + levelStr := os.Getenv("LOG_LEVEL") + if levelStr == "" { + return zap.NewAtomicLevelAt(zap.DebugLevel) + } + + level, err := zap.ParseAtomicLevel(levelStr) + if err != nil { + panic(fmt.Errorf("failed to parse log level: %w", err)) + } + return level +} + +var global *zap.Logger +var globalOnce sync.Once + +func Global() *zap.Logger { + if global == nil { + globalOnce.Do(func() { + encoder := zapcore.EncoderConfig{ + // Keys can be anything except the empty string. + TimeKey: "", + LevelKey: "L", + NameKey: "_", + CallerKey: "C", + FunctionKey: "F", + MessageKey: "M", + StacktraceKey: "S", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalColorLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + } + cfg := zap.Config{ + Level: logLevelFromEnv(), + Development: true, + DisableCaller: true, + DisableStacktrace: true, + Encoding: "console", + EncoderConfig: encoder, + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + } + options := []zap.Option{ + zap.AddCallerSkip(1), + } + + var err error + global, err = cfg.Build(options...) + if err != nil { + panic(fmt.Errorf("failed to initialize global logger: %w", err)) + } + }) + } + + return global +} + +func Logger(t *TC) *zap.Logger { + logger := Global() + logger = logger.Named(t.Name()) + return logger +} + +func Debug(t *TC, msg string, args ...zap.Field) { + Logger(t).Debug(msg, args...) + _ = Logger(t).Sync() +} + +func Info(t *TC, msg string, args ...zap.Field) { + Logger(t).Info(msg, args...) + _ = Logger(t).Sync() +} + +func Warn(t *TC, msg string, args ...zap.Field) { + Logger(t).Warn(msg, args...) + _ = Logger(t).Sync() +} + +func Error(t *TC, msg string, args ...zap.Field) { + Logger(t).Error(msg, args...) + _ = Logger(t).Sync() +} diff --git a/homework/03-http-server/tests/query.go b/homework/03-http-server/tests/query.go new file mode 100644 index 0000000..2bcba29 --- /dev/null +++ b/homework/03-http-server/tests/query.go @@ -0,0 +1,424 @@ +package hw3test + +import ( + "compress/gzip" + "fmt" + "github.com/stretchr/testify/require" + "io" + "mime" + "net/http" + "os" + "path" + "strings" +) + +func parseContentType(value string) (string, error) { + mediaType, _, err := mime.ParseMediaType(value) + if err != nil { + return "", err + } + if !strings.Contains(mediaType, "/") { + return "", fmt.Errorf("content type %q has no subtype", value) + } + return mediaType, nil +} + +// Query represents a query to the solution server. +type Query struct { + // Seed is used as a query ID. + Seed int64 + + // Method of a request, e.g. "GET", "POST", "PUT", "DELETE". + Method string + + // Simple file path, splitted by slashes. E.g. "foo/bar/baz". + Path string + + // If true, will use `Accept-Encoding` for GET query. + Gzip bool + + // If true, will pass `Create-Directory: True` in POST query. + CreateDirectory bool + + // If true, will pass `Remove-Directory: True` in DELETE query. + RemoveDirectory bool + + // Pass the Host header. + HostHeader string + + // Verify all server headers: `Content-Length`, `Content-Type`, `Server`. + VerifyHeaders bool + + // FileContent when creating a file. + FileContent *EnvFile +} + +func (q *Query) CreateRequest(t *TC, queryURL string) *http.Request { + var body io.Reader + if q.FileContent != nil { + body = q.FileContent.Open() + if q.FileContent.Size == 0 { + body = http.NoBody + } + } + + req, err := http.NewRequest(q.Method, queryURL, body) + require.NoError(t, err, "failed to create go request") + + if q.FileContent != nil { + req.ContentLength = q.FileContent.Size + } + if q.CreateDirectory { + req.Header.Set("Create-Directory", "True") + } + if q.RemoveDirectory { + req.Header.Set("Remove-Directory", "True") + } + if q.HostHeader != "" { + req.Host = q.HostHeader + } + if q.Gzip { + req.Header.Set("Accept-Encoding", "gzip") + } + + return req +} + +func (q *Query) CommonValidate(t *TC, r *http.Request, resp *http.Response) { + require.NoError(t, validateResponseFraming(resp)) + if q.VerifyHeaders { + require.NotEmpty(t, resp.Header.Get("Server"), "missing Server header") + } +} + +type Action interface { + // VerifyBefore verifies file content on disk before sending query to the server. + VerifyBefore(t *TC, workdir string) + + // VerifyAfter verifies file content on disk after sending query to the server. + VerifyAfter(t *TC, workdir string) + + // ApplyEnv applies the action to the environment. + ApplyEnv(t *TC, env *Env) (changed bool) + + // VerifyResponse verifies the response from the server. + VerifyResponse(t *TC, r *http.Request, resp *http.Response) +} + +// HttpErrorAction is an Action, which expect specific status code in response. +type HttpErrorAction struct { + Status int +} + +func (h HttpErrorAction) VerifyBefore(t *TC, workdir string) { + return +} + +func (h HttpErrorAction) VerifyAfter(t *TC, workdir string) { + return +} + +func (h HttpErrorAction) ApplyEnv(t *TC, env *Env) bool { + return false +} + +func (h HttpErrorAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Equal(t, h.Status, resp.StatusCode, "expected http status code") + require.Positive(t, resp.ContentLength, "error response must contain an explanation") +} + +// GetFileAction is an Action, which returns file content in response. +type GetFileAction struct { + Path string + File *EnvFile + Compression bool + VerifyHeaders bool +} + +func (g GetFileAction) VerifyBefore(t *TC, workdir string) { + RequireFileContent(t, workdir, g.Path, g.File) +} + +func (g GetFileAction) VerifyAfter(t *TC, workdir string) { + RequireFileContent(t, workdir, g.Path, g.File) +} + +func (g GetFileAction) ApplyEnv(t *TC, env *Env) bool { + return false +} + +func (g GetFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Equal(t, 200, resp.StatusCode, "expected OK") + var body io.Reader = resp.Body + if g.Compression { + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response") + gz, err := gzip.NewReader(resp.Body) + require.NoError(t, err, "expected valid gzip response") + defer gz.Close() + body = gz + } else { + require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding") + require.Equal(t, g.File.Size, resp.ContentLength, "expected content length") + } + if g.VerifyHeaders && g.File.Size > 0 { + _, err := parseContentType(resp.Header.Get("Content-Type")) + require.NoError(t, err, "expected valid content type") + } + require.NoError(t, CompareFileContent(t, body, g.File), "file content mismatch") + remaining, err := io.Copy(io.Discard, body) + require.NoError(t, err, "failed to read remaining response body") + require.Zero(t, remaining, "response contains extra file content") +} + +// GetDirAction is an Action, which returns directory listing in response. +type GetDirAction struct { + Path string + Dir *EnvDir + Compression bool + VerifyHeaders bool +} + +func (g GetDirAction) VerifyBefore(t *TC, workdir string) { + RequireDir(t, workdir, g.Path, g.Dir) +} + +func (g GetDirAction) VerifyAfter(t *TC, workdir string) { + RequireDir(t, workdir, g.Path, g.Dir) +} + +func (g GetDirAction) ApplyEnv(t *TC, env *Env) bool { + return false +} + +func (g GetDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Equal(t, 200, resp.StatusCode, "expected OK") + var body io.Reader = resp.Body + if g.Compression { + require.Equal(t, "gzip", resp.Header.Get("Content-Encoding"), "expected gzip response") + gz, err := gzip.NewReader(resp.Body) + require.NoError(t, err, "expected valid gzip response") + defer gz.Close() + body = gz + } else { + require.Empty(t, resp.Header.Get("Content-Encoding"), "unexpected content encoding") + } + content, err := io.ReadAll(body) + require.NoError(t, err, "expected response to be read without errors") + if g.VerifyHeaders && len(content) > 0 { + mediaType, err := parseContentType(resp.Header.Get("Content-Type")) + require.NoError(t, err, "expected valid content type") + require.Contains(t, []string{"text/plain", "text/html"}, mediaType, "expected directory listing content type") + } + + strlist := string(content) + for name := range g.Dir.Listing { + require.Contains(t, strlist, name, "expected dir listing to contain child %s", name) + } +} + +// CreateDirAction is an Action, which creates a directory. +type CreateDirAction struct { + Path string + Name string + Parent *EnvDir + PathOnDisk string +} + +func (c CreateDirAction) VerifyBefore(t *TC, workdir string) { + RequireNotExists(t, workdir, c.Path) +} + +func (c CreateDirAction) VerifyAfter(t *TC, workdir string) { + RequireDir(t, workdir, c.Path, c.Parent) + entries, err := os.ReadDir(c.PathOnDisk) + require.NoError(t, err, "expected to read dir %s without errors", c.PathOnDisk) + require.Empty(t, entries, "expected dir %s to be empty", c.PathOnDisk) +} + +func (c CreateDirAction) ApplyEnv(t *TC, env *Env) bool { + _, exist := c.Parent.CreateDir(c.Name) + require.False(t, exist) + return true +} + +func (c CreateDirAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation") +} + +// CreateFileAction is an Action, which creates a file. +type CreateFileAction struct { + Path string + Name string + Parent *EnvDir + Content *EnvFile +} + +func (c CreateFileAction) VerifyBefore(t *TC, workdir string) { + RequireNotExists(t, workdir, c.Path) +} + +func (c CreateFileAction) VerifyAfter(t *TC, workdir string) { + RequireFileContent(t, workdir, c.Path, c.Content) +} + +func (c CreateFileAction) ApplyEnv(t *TC, env *Env) bool { + c.Parent.Listing[c.Name] = c.Content + return true +} + +func (c CreateFileAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Contains(t, []int{http.StatusOK, http.StatusCreated}, resp.StatusCode, "expected successful creation") +} + +// ReplaceFileAction is an Action, which replaces file content. +type ReplaceFileAction struct { + Path string + Name string + Parent *EnvDir + OldContent *EnvFile + NewContent *EnvFile +} + +func (r ReplaceFileAction) VerifyBefore(t *TC, workdir string) { + RequireFileContent(t, workdir, r.Path, r.OldContent) +} + +func (r ReplaceFileAction) VerifyAfter(t *TC, workdir string) { + RequireFileContent(t, workdir, r.Path, r.NewContent) +} + +func (r ReplaceFileAction) ApplyEnv(t *TC, env *Env) bool { + r.Parent.Listing[r.Name] = r.NewContent + return true +} + +func (r ReplaceFileAction) VerifyResponse(t *TC, req *http.Request, resp *http.Response) { + require.Contains(t, []int{http.StatusOK, http.StatusNoContent}, resp.StatusCode, "expected successful update") +} + +// DeleteAction is an Action, which deletes a file or directory. +type DeleteAction struct { + Path string + Parent *EnvDir + Name string +} + +func (d DeleteAction) VerifyBefore(t *TC, workdir string) { + RequireExists(t, workdir, d.Path) +} + +func (d DeleteAction) VerifyAfter(t *TC, workdir string) { + RequireNotExists(t, workdir, d.Path) +} + +func (d DeleteAction) ApplyEnv(t *TC, env *Env) bool { + delete(d.Parent.Listing, d.Name) + return true +} + +func (d DeleteAction) VerifyResponse(t *TC, r *http.Request, resp *http.Response) { + require.Equal(t, 200, resp.StatusCode, "expected OK") +} + +// Action returns action for the query, or nil if query shouldn't do anything. +func (q *Query) Action(env *Env, opts *RunOpts) Action { + if !strings.EqualFold(q.HostHeader, opts.ServerDomain) { + return HttpErrorAction{ + Status: 400, + } + } + + parent, child := env.Lookup(q.Path) + + switch q.Method { + case "GET": + if child == nil { + return HttpErrorAction{ + Status: 404, + } + } + if f, ok := child.(*EnvFile); ok { + return GetFileAction{ + Path: q.Path, + File: f, + Compression: q.Gzip, + VerifyHeaders: q.VerifyHeaders, + } + } + if d, ok := child.(*EnvDir); ok { + return GetDirAction{ + Path: q.Path, + Dir: d, + Compression: q.Gzip, + VerifyHeaders: q.VerifyHeaders, + } + } + + // shouldn't get here + return nil + case "POST": + if child != nil { + return HttpErrorAction{ + Status: 409, + } + } + if parent == nil { + return HttpErrorAction{Status: http.StatusNotFound} + } + _, name := path.Split(q.Path) + if q.CreateDirectory { + return CreateDirAction{ + Path: q.Path, + Name: name, + Parent: parent, + PathOnDisk: path.Join(opts.WorkingDirectory, q.Path), + } + } + return CreateFileAction{ + Path: q.Path, + Name: name, + Parent: parent, + Content: q.FileContent, + } + case "PUT": + if child == nil { + return HttpErrorAction{Status: http.StatusNotFound} + } + f, ok := child.(*EnvFile) + if !ok { + return HttpErrorAction{ + Status: 409, + } + } + + _, name := path.Split(q.Path) + return ReplaceFileAction{ + Path: q.Path, + Name: name, + Parent: parent, + OldContent: f, + NewContent: q.FileContent, + } + case "DELETE": + if child == env.RootDir { + return HttpErrorAction{Status: http.StatusForbidden} + } + if child == nil { + return HttpErrorAction{Status: http.StatusNotFound} + } + _, ok := child.(*EnvDir) + if ok && !q.RemoveDirectory { + return HttpErrorAction{ + Status: 406, + } + } + _, name := path.Split(q.Path) + return DeleteAction{ + Path: q.Path, + Parent: parent, + Name: name, + } + } + + return nil +} diff --git a/homework/03-http-server/tests/query_gen.go b/homework/03-http-server/tests/query_gen.go new file mode 100644 index 0000000..90bb323 --- /dev/null +++ b/homework/03-http-server/tests/query_gen.go @@ -0,0 +1,146 @@ +package hw3test + +import ( + "math/rand" + "strings" + + "github.com/stretchr/testify/require" +) + +// LargePutPolicy keeps a small number of guaranteed successful writes above +// the solution's memory limit, while bounding the remaining PUT bodies. +type LargePutPolicy struct { + RunSeed int64 + LargeSizes []int64 + OtherMaxBytes int64 +} + +// QueriesGen contains config for generating queries. +type QueriesGen struct { + // Number of queries to generate. + Count int + + GetFile bool // Allow GET requests for files. + GetFileNoErrors bool // Disable requests for non-existing files. + GetDirectory bool // Allow GET requests for directories. + Compression bool // Allow compression in GET requests. + Post bool // Allow POST requests. + Put bool // Allow PUT requests. + Delete bool // Allow DELETE requests. + AllHeaders bool // Allow extra headers. + LargePuts *LargePutPolicy +} + +func (g *QueriesGen) Generate(t *TC, env *Env, gen *EnvGen, seeds []int64, opts RunOpts, runSeed int64) []Query { + env = env.Clone() + largePuts := 0 + + stats := &Stats{} + env.RootDir.Stats("", stats) + stats.Normalize() + + var methods []string + if g.GetFile || g.GetDirectory { + methods = append(methods, "GET") + } + if g.Post { + methods = append(methods, "POST") + } + if g.Put { + methods = append(methods, "PUT") + } + if g.Delete { + methods = append(methods, "DELETE") + } + + var queries []Query + for _, seed := range seeds { + r := rand.New(rand.NewSource(seed)) + method := methods[r.Intn(len(methods))] + + var genPaths []string + if len(stats.DirPaths) > 0 && !(method == "GET" && !g.GetDirectory) { + genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))]) + genPaths = append(genPaths, stats.DirPaths[r.Intn(len(stats.DirPaths))]) + } + if len(stats.FilePaths) > 0 && !(method == "GET" && !g.GetFile) { + genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))]) + genPaths = append(genPaths, stats.FilePaths[r.Intn(len(stats.FilePaths))]) + } + if !(method == "GET" && g.GetFileNoErrors) { + // TODO: better non-existing path generator + randomPath := gen.FilenameGen(r) + "/" + gen.FilenameGen(r) + genPaths = append(genPaths, randomPath) + + if len(stats.DirPaths) > 0 { + randomDir := stats.DirPaths[r.Intn(len(stats.DirPaths))] + num := 2 + if method == "POST" { + num = 4 + } + + for i := 0; i < num; i++ { + genPaths = append(genPaths, randomDir+"/"+gen.FilenameGen(r)) + } + } + } + + if len(genPaths) == 0 { + continue + } + + hostHeader := opts.ServerDomain + if g.AllHeaders && r.Intn(5) == 0 { + // to get 400 + hostHeader = "hse.ru" + } + + path := strings.TrimPrefix(genPaths[r.Intn(len(genPaths))], "/") + if path == "" && method != "GET" { + // Keep the existing large-file scenarios and RNG sequence. + // Requests targeting the root are covered on a small tree in contract.go. + path = "MISSING_ROOT_TARGET" + } + gzipRequested := g.Compression && r.Intn(2) == 1 + query := Query{ + Seed: seed, + Method: method, + Path: path, + Gzip: method == "GET" && gzipRequested, + CreateDirectory: method == "POST" && (r.Intn(2) == 1), + RemoveDirectory: method == "DELETE" && (r.Intn(2) == 1), + HostHeader: hostHeader, + VerifyHeaders: g.AllHeaders, + } + + if (method == "POST" && !query.CreateDirectory) || method == "PUT" { + query.FileContent = gen.GenerateFile(r) + } + + action := query.Action(env, &opts) + if query.Method == "PUT" && g.LargePuts != nil { + policy := g.LargePuts + if _, ok := action.(ReplaceFileAction); ok && runSeed == policy.RunSeed && largePuts < len(policy.LargeSizes) { + query.FileContent.Size = policy.LargeSizes[largePuts] + largePuts++ + } else if query.FileContent.Size > policy.OtherMaxBytes { + query.FileContent.Size = policy.OtherMaxBytes + } + } + queries = append(queries, query) + + if action != nil { + changed := action.ApplyEnv(t, env) + if changed { + stats = &Stats{} + env.RootDir.Stats("", stats) + stats.Normalize() + } + } + } + if g.LargePuts != nil && runSeed == g.LargePuts.RunSeed { + require.Equal(t, len(g.LargePuts.LargeSizes), largePuts, "not enough successful PUT queries for large-file coverage") + } + + return queries +} diff --git a/homework/03-http-server/tests/readiness_test.go b/homework/03-http-server/tests/readiness_test.go new file mode 100644 index 0000000..bf62065 --- /dev/null +++ b/homework/03-http-server/tests/readiness_test.go @@ -0,0 +1,56 @@ +package hw3test + +import ( + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWaitForServerUsesConfiguredHost(t *testing.T) { + t.Setenv("SOLUTION_HOST", "127.0.0.1") + for _, tc := range []struct { + domain string + status int + }{ + {"localhost", http.StatusOK}, + {"files.example.com", http.StatusNotFound}, + {"redirect.example.com", http.StatusFound}, + } { + t.Run(tc.domain, func(t *testing.T) { + type probe struct{ method, target, host string } + received := make(chan probe, 16) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case received <- probe{r.Method, r.RequestURI, r.Host}: + default: + } + if tc.status == http.StatusFound { + w.Header().Set("Location", "/redirected") + } + w.WriteHeader(tc.status) + })) + defer server.Close() + opts := RunOpts{ + Port: server.Listener.Addr().(*net.TCPAddr).Port, + ServerDomain: tc.domain, + } + if err := WaitForServer(NewTestContext(t), opts); err != nil { + t.Fatal(err) + } + select { + case got := <-received: + if want := (probe{"GET", "/", tc.domain}); got != want { + t.Fatalf("readiness request = %+v, want %+v", got, want) + } + default: + t.Fatal("server was marked ready without receiving a request") + } + select { + case extra := <-received: + t.Fatalf("unexpected extra readiness request: %+v", extra) + default: + } + }) + } +} diff --git a/homework/03-http-server/tests/response_validation_test.go b/homework/03-http-server/tests/response_validation_test.go new file mode 100644 index 0000000..8d512da --- /dev/null +++ b/homework/03-http-server/tests/response_validation_test.go @@ -0,0 +1,269 @@ +package hw3test + +import ( + "bufio" + "bytes" + "compress/gzip" + "errors" + "io" + "net" + "net/http" + "strconv" + "testing" + "time" +) + +func TestResponseEndChecksWireBytes(t *testing.T) { + for _, tc := range []struct { + name string + response string + valid bool + }{ + {"valid body", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndata", true}, + {"extra body bytes", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndataextra", false}, + {"valid empty body", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", true}, + {"body after zero length", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\nextra", false}, + {"valid no content", "HTTP/1.1 204 No Content\r\n\r\n", true}, + {"body after no content", "HTTP/1.1 204 No Content\r\n\r\nextra", false}, + } { + t.Run(tc.name, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + server, err := listener.Accept() + if err != nil { + return + } + defer server.Close() + _, _ = io.WriteString(server, tc.response) + }() + client, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + reader := bufio.NewReader(client) + response, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if _, err := io.Copy(io.Discard, response.Body); err != nil { + t.Fatal(err) + } + if err := checkResponseEnd(reader, false); (err == nil) != tc.valid { + t.Fatalf("valid=%v, checkResponseEnd error: %v", tc.valid, err) + } + }) + } +} + +func TestParseContentTypeRequiresSubtype(t *testing.T) { + for _, tc := range []struct { + value string + valid bool + }{ + {"application/octet-stream", true}, + {"text/plain; charset=utf-8", true}, + {"nonsense", false}, + {"text/", false}, + {"", false}, + } { + _, err := parseContentType(tc.value) + if (err == nil) != tc.valid { + t.Errorf("content type %q: valid=%v, error=%v", tc.value, tc.valid, err) + } + } +} + +func TestRunQueryAcceptsEarlyErrorResponse(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + server, err := listener.Accept() + if err != nil { + return + } + defer server.Close() + reader := bufio.NewReader(server) + for { + line, err := reader.ReadString('\n') + if err != nil || line == "\r\n" { + break + } + } + _, _ = io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror") + }() + t.Setenv("SOLUTION_HOST", "127.0.0.1") + RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{ + Port: listener.Addr().(*net.TCPAddr).Port, + ServerDomain: "localhost", + }, Query{ + Method: "PUT", + Path: "item", + HostHeader: "other.example", + FileContent: &EnvFile{GenSeed: 42, Size: 8 * 1024 * 1024}, + }) +} + +func TestRunQueryWaitsForLargeRequestBeforeCloseTimeout(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + done := make(chan error, 1) + go func() { + server, err := listener.Accept() + if err != nil { + done <- err + return + } + defer server.Close() + request, err := http.ReadRequest(bufio.NewReader(server)) + if err != nil { + done <- err + return + } + if _, err := io.WriteString(server, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 5\r\n\r\nerror"); err != nil { + done <- err + return + } + time.Sleep(2500 * time.Millisecond) + _, err = io.Copy(io.Discard, request.Body) + done <- err + }() + t.Setenv("SOLUTION_HOST", "127.0.0.1") + RunQuery(NewTestContext(t), &Env{}, t.TempDir(), RunOpts{ + Port: listener.Addr().(*net.TCPAddr).Port, + ServerDomain: "localhost", + }, Query{ + Method: "POST", + Path: "item", + HostHeader: "other.example", + FileContent: &EnvFile{GenSeed: 42, Size: 160 << 20}, + }) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestIdleDeadlineWriter(t *testing.T) { + t.Run("stalled write", func(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond} + _, err := writer.Write([]byte("x")) + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("expected write timeout, got %v", err) + } + }) + t.Run("request body timeout", func(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + release := make(chan struct{}) + defer close(release) + go func() { + _, _ = http.ReadRequest(bufio.NewReader(server)) + <-release + }() + writer := &idleDeadlineWriter{Conn: client, Timeout: 50 * time.Millisecond} + request := (&Query{Method: "POST", FileContent: &EnvFile{GenSeed: 42, Size: 1 << 20}}). + CreateRequest(NewTestContext(t), "http://localhost/item") + if err := request.Write(writer); err == nil { + t.Fatal("expected request write to time out") + } + var netErr net.Error + if !errors.As(writer.lastError, &netErr) || !netErr.Timeout() { + t.Fatalf("expected underlying write timeout, got %v", writer.lastError) + } + }) +} + +func TestCompressedGetResponseValidation(t *testing.T) { + file := &EnvFile{GenSeed: 42, Size: 1024} + directory := &EnvDir{Listing: map[string]EnvNode{"item": file}} + + for _, tc := range []struct { + name string + body io.Reader + validate func(*TC, *http.Response) + }{ + { + name: "file", + body: file.Open(), + validate: func(t *TC, resp *http.Response) { + GetFileAction{File: file, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp) + }, + }, + { + name: "directory", + body: bytes.NewBufferString("item\n"), + validate: func(t *TC, resp *http.Response) { + GetDirAction{Dir: directory, Compression: true, VerifyHeaders: true}.VerifyResponse(t, nil, resp) + }, + }, + } { + t.Run(tc.name, func(tt *testing.T) { + testContext := NewTestContext(tt) + var encoded bytes.Buffer + writer := gzip.NewWriter(&encoded) + _, err := io.Copy(writer, tc.body) + if err != nil { + tt.Fatal(err) + } + if err := writer.Close(); err != nil { + tt.Fatal(err) + } + + resp := &http.Response{ + StatusCode: http.StatusOK, + ContentLength: int64(encoded.Len()), + Header: http.Header{ + "Content-Length": {strconv.Itoa(encoded.Len())}, + "Content-Encoding": {"gzip"}, + "Content-Type": {"text/plain"}, + "Server": {"test-server"}, + "Connection": {"close"}, + }, + Body: io.NopCloser(bytes.NewReader(encoded.Bytes())), + } + defer resp.Body.Close() + q := Query{Gzip: true, VerifyHeaders: true} + req := q.CreateRequest(testContext, "http://localhost/item") + if got := req.Header.Get("Accept-Encoding"); got != "gzip" { + tt.Fatalf("expected Accept-Encoding: gzip, got %q", got) + } + q.CommonValidate(testContext, req, resp) + tc.validate(testContext, resp) + }) + } +} + +func TestCompareFileContentBlocks(t *testing.T) { + file := &EnvFile{GenSeed: 17, Size: 64*1024 + 17} + context := NewTestContext(t) + if err := CompareFileContent(context, file.Open(), file); err != nil { + t.Fatalf("valid file was rejected: %v", err) + } + if err := CompareFileContent(context, io.LimitReader(file.Open(), file.Size-1), file); err == nil { + t.Fatal("truncated file was accepted") + } + content, err := io.ReadAll(file.Open()) + if err != nil { + t.Fatal(err) + } + content[len(content)-1] ^= 1 + if err := CompareFileContent(context, bytes.NewReader(content), file); err == nil { + t.Fatal("changed final byte was accepted") + } +} diff --git a/homework/03-http-server/tests/runner.go b/homework/03-http-server/tests/runner.go new file mode 100644 index 0000000..d6f7c57 --- /dev/null +++ b/homework/03-http-server/tests/runner.go @@ -0,0 +1,303 @@ +package hw3test + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + "unicode" + + "go.uber.org/zap" +) + +// RunOpts contains command line arguments for the solution. +// Those are passed to the template. +type RunOpts struct { + // Solution options + Port int + WorkingDirectory string + ListenAddr string // Host + ServerDomain string + + // Full run config that will be provided to the solution. + CommandLineArgs string + Env []string + + // Full run config if solution will be running in docker. + DockerCommandLineArgs string + DockerEnvArgs string + DockerPortArgs string + DockerVolumeArgs string + + // Hack to get exitcode of the solution. + ExitCode chan int +} + +func (o *RunOpts) Address() string { + host := os.Getenv("SOLUTION_HOST") + if host == "" { + host = "localhost" + } + + return fmt.Sprintf("http://%s:%d", host, o.Port) +} + +func (o *RunOpts) GenerateRunConfig(t *TC, r *rand.Rand, gen *EnvGen) { + o.CommandLineArgs, o.Env = o.BuildConfig(r, gen) + + dirsInDocker := []string{ + "/files", + "/files0", + "/files1", + "/files2", + "/files3", + } + dirInDocker := dirsInDocker[r.Intn(len(dirsInDocker))] + + if o.WorkingDirectory == "" { + dirInDocker = "" + } else { + o.DockerVolumeArgs = fmt.Sprintf(`-v "%s:%s"`, o.WorkingDirectory, dirInDocker) + } + + o.DockerPortArgs = fmt.Sprintf("-p %d:%d", o.Port, o.Port) + + dockerArgs, dockerEnv := RunOpts{ + ListenAddr: o.ListenAddr, + Port: o.Port, + WorkingDirectory: dirInDocker, + ServerDomain: o.ServerDomain, + }.BuildConfig(r, gen) + + o.DockerEnvArgs = "" + for _, env := range dockerEnv { + o.DockerEnvArgs += "--env \"" + env + "\" " + } + + o.DockerCommandLineArgs = dockerArgs +} + +// BuildConfig uses ListenAddr, Port, WorkingDirectory, ServerDomain. +func (o RunOpts) BuildConfig(r *rand.Rand, gen *EnvGen) (args string, env []string) { + if o.ListenAddr == "0.0.0.0" && r.Intn(2) == 1 { + // can omit default value + } else if gen.AllowEnv && r.Intn(3) == 1 { + // use env + env = append(env, fmt.Sprintf("SERVER_HOST=%s", o.ListenAddr)) + } else if o.ListenAddr != "" { + // use plain cmdline args + args += fmt.Sprintf(" \"--host=%s\"", o.ListenAddr) + + if r.Intn(2) == 1 { + // pass dummy env + env = append(env, fmt.Sprintf("SERVER_HOST=%s", "8.8.8.8")) + } + } + + if o.Port == 8080 && r.Intn(2) == 1 { + // can omit default value + } else if gen.AllowEnv && r.Intn(3) == 1 { + // use env + env = append(env, fmt.Sprintf("SERVER_PORT=%d", o.Port)) + } else { + // use plain cmdline args + args += fmt.Sprintf(" \"--port=%d\"", o.Port) + + if r.Intn(2) == 1 { + // pass dummy env + env = append(env, fmt.Sprintf("SERVER_PORT=%d", 80)) + } + } + + if o.WorkingDirectory == "" && r.Intn(2) == 1 { + // can omit default value + } else if gen.AllowEnv && r.Intn(3) == 1 { + // use env + env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", o.WorkingDirectory)) + } else if o.WorkingDirectory != "" { + // use plain cmdline args + args += fmt.Sprintf(" \"--working-directory=%s\"", o.WorkingDirectory) + + if r.Intn(2) == 1 { + // pass dummy env + env = append(env, fmt.Sprintf("SERVER_WORKING_DIRECTORY=%s", "/")) + } + } + + if o.ServerDomain == "localhost" && r.Intn(2) == 1 { + // can omit default value + } else if gen.AllowEnv && r.Intn(3) == 1 { + // use env + env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", o.ServerDomain)) + } else if o.ServerDomain != "" { + // use plain cmdline args + args += fmt.Sprintf(" \"--server-domain=%s\"", o.ServerDomain) + + if r.Intn(2) == 1 { + // pass dummy env + env = append(env, fmt.Sprintf("SERVER_DOMAIN=%s", "example.com")) + } + } + return args, env +} + +// Runner is a helper for running HTTP server solution. +type Runner interface { + // Run the solution with the given options. + // Returns a function that can be used to stop the solution. + Run(t *TC, opts RunOpts) (stop func(), err error) +} + +// CmdRunner runs command based on template. +type CmdRunner struct { + tmpl template.Template + useDocker bool +} + +func NewCmdRunner(tmpl *template.Template, useDocker bool) *CmdRunner { + return &CmdRunner{ + tmpl: *tmpl, + useDocker: useDocker, + } +} + +// splitCommand preserves quoted arguments without invoking a shell. +func splitCommand(command string) ([]string, error) { + var args []string + var arg strings.Builder + quoted := false + started := false + for _, char := range command { + switch { + case char == '"': + quoted = !quoted + started = true + case unicode.IsSpace(char) && !quoted: + if started { + args = append(args, arg.String()) + arg.Reset() + started = false + } + default: + arg.WriteRune(char) + started = true + } + } + if quoted { + return nil, fmt.Errorf("unclosed quote in launch command") + } + if started { + args = append(args, arg.String()) + } + if len(args) == 0 { + return nil, fmt.Errorf("empty launch command") + } + return args, nil +} + +func (r *CmdRunner) Run(t *TC, opts RunOpts) (stop func(), err error) { + var b bytes.Buffer + err = r.tmpl.Execute(&b, opts) + if err != nil { + return nil, fmt.Errorf("failed to execute template: %v", err) + } + + envOpts := opts.Env + if r.useDocker { + envOpts = nil + } + + cmdParts, err := splitCommand(b.String()) + if err != nil { + return nil, err + } + var cidDir, cidFile string + if r.useDocker { + if len(cmdParts) < 2 || !strings.EqualFold(filepath.Base(cmdParts[0]), "docker") || cmdParts[1] != "run" { + return nil, fmt.Errorf("Docker launch template must start with docker run") + } + cidDir, err = os.MkdirTemp("", "hw3-container-") + if err != nil { + return nil, fmt.Errorf("failed to create container ID directory: %w", err) + } + cidFile = filepath.Join(cidDir, "id") + cmdParts = append(append([]string{}, cmdParts[:2]...), append([]string{"--cidfile", cidFile}, cmdParts[2:]...)...) + } + Info(t, "Running command", zap.Strings("command", cmdParts), zap.Strings("env", envOpts)) + + cmd := exec.Command(cmdParts[0], cmdParts[1:]...) + cmd.Env = append(os.Environ(), envOpts...) + cmd.Stdout = NewProxyWriter(os.Stderr) + cmd.Stderr = NewProxyWriter(os.Stderr) + + err = cmd.Start() + if err != nil { + if cidDir != "" { + _ = os.Remove(cidDir) + } + return nil, fmt.Errorf("failed to run command: %v", err) + } + + go func() { + err := cmd.Wait() + if err != nil && err.Error() != "signal: killed" && err.Error() != "exit status 1" { + Warn(t, "Command finished with error", zap.Error(err)) + } + if opts.ExitCode != nil { + if e, ok := err.(*exec.ExitError); ok { + opts.ExitCode <- e.ExitCode() + } else { + opts.ExitCode <- 0 + } + } + }() + + return func() { + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + Error(t, "Failed to kill command", zap.Error(err)) + } + } + if r.useDocker { + id, err := os.ReadFile(cidFile) + if err == nil && len(strings.TrimSpace(string(id))) > 0 { + output, removeErr := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(id))).CombinedOutput() + if removeErr != nil && !strings.Contains(string(output), "No such container") && !strings.Contains(string(output), "is already in progress") { + Error(t, "Failed to remove Docker container", zap.Error(removeErr), zap.ByteString("output", output)) + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + Error(t, "Failed to read container ID", zap.Error(err)) + } + _ = os.Remove(cidFile) + _ = os.Remove(cidDir) + } + }, nil +} + +// ProxyWriter is used to forward solution output to standard output. +type ProxyWriter struct { + w io.Writer + disable bool +} + +func NewProxyWriter(w io.Writer) *ProxyWriter { + return &ProxyWriter{ + w: w, + disable: boolFromEnv("DISABLE_SOLUTION_OUTPUT", false), + } +} + +func (w *ProxyWriter) Write(b []byte) (n int, err error) { + if w.disable { + return len(b), nil + } + // TODO: if there will be sync problems, we can take a global lock + // and read until \n, then flush and apply color + return w.w.Write(b) +} diff --git a/homework/03-http-server/tests/runner_test.go b/homework/03-http-server/tests/runner_test.go new file mode 100644 index 0000000..1bca893 --- /dev/null +++ b/homework/03-http-server/tests/runner_test.go @@ -0,0 +1,38 @@ +package hw3test + +import ( + "reflect" + "testing" +) + +func TestSplitCommand(t *testing.T) { + for _, test := range []struct { + name string + command string + want []string + wantErr bool + }{ + { + name: "working directory with spaces", + command: `python3 "../solution with spaces/server.py" "--working-directory=C:\Users\student name\files"`, + want: []string{"python3", "../solution with spaces/server.py", `--working-directory=C:\Users\student name\files`}, + }, + { + name: "Docker volume with spaces", + command: `docker run -v "C:\Users\student name\files:/files" hw3img`, + want: []string{"docker", "run", "-v", `C:\Users\student name\files:/files`, "hw3img"}, + }, + {name: "empty command", command: " ", wantErr: true}, + {name: "unclosed quote", command: `python3 "unterminated`, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := splitCommand(test.command) + if (err != nil) != test.wantErr { + t.Fatalf("splitCommand(%q) error = %v, wantErr %v", test.command, err, test.wantErr) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("splitCommand(%q) = %#v, want %#v", test.command, got, test.want) + } + }) + } +} diff --git a/homework/03-http-server/tests/test_starter.py b/homework/03-http-server/tests/test_starter.py new file mode 100644 index 0000000..994644e --- /dev/null +++ b/homework/03-http-server/tests/test_starter.py @@ -0,0 +1,92 @@ +"""Run from the assignment directory: python -m unittest discover -s tests -p test_starter.py.""" +import importlib.util +import os +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +from click.testing import CliRunner + +STARTER = Path(__file__).resolve().parents[1] / "solution" / "server.py" +spec = importlib.util.spec_from_file_location("hw3_starter", STARTER) +starter = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = starter +spec.loader.exec_module(starter) +ENV_NAMES = ("SERVER_HOST", "SERVER_PORT", "SERVER_DOMAIN", "SERVER_WORKING_DIRECTORY") + + +class StarterTests(unittest.TestCase): + def run_config(self, args, env): + clean_env = {k: v for k, v in os.environ.items() if k not in ENV_NAMES} + clean_env.update(env) + with patch.dict(os.environ, clean_env, clear=True), patch.object(starter.socket, "socket") as factory, patch.object(starter, "HTTPServer") as server: + factory.return_value.accept.side_effect = OSError("stop after bind") + result = CliRunner().invoke(starter.main, args) + return result, factory, server + + def test_defaults(self): + result, factory, server = self.run_config(["--working-directory", str(STARTER.parent)], {}) + self.assertEqual(result.exit_code, 0, result.output) + factory.return_value.bind.assert_called_once_with(("0.0.0.0", 8080)) + self.assertEqual(server.call_args.args[2:], ("localhost", STARTER.parent)) + + def test_environment(self): + env = dict(zip(ENV_NAMES, ("127.0.0.1", "9090", "example.com", str(STARTER.parent)))) + result, factory, server = self.run_config([], env) + self.assertEqual(result.exit_code, 0, result.output) + factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9090)) + self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent)) + + def test_cli_overrides_environment(self): + env = dict(zip(ENV_NAMES, ("8.8.8.8", "80", "wrong.example", "wrong-directory"))) + result, factory, server = self.run_config( + ["--host", "127.0.0.1", "--port", "9091", "--server-domain", "example.com", + "--working-directory", str(STARTER.parent)], env) + self.assertEqual(result.exit_code, 0, result.output) + factory.return_value.bind.assert_called_once_with(("127.0.0.1", 9091)) + self.assertEqual(server.call_args.args[2:], ("example.com", STARTER.parent)) + + def test_missing_directory(self): + for args, env in [([], {}), ([], {"SERVER_WORKING_DIRECTORY": ""}), + (["--working-directory", ""], {"SERVER_WORKING_DIRECTORY": str(STARTER.parent)})]: + with self.subTest(args=args, env=env): + result, factory, _ = self.run_config(args, env) + self.assertEqual(result.exit_code, 1, result.output) + factory.assert_not_called() + + def test_tcp_startup(self): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + with tempfile.TemporaryDirectory() as workdir: + process = subprocess.Popen( + [sys.executable, str(STARTER), "--host", "127.0.0.1", "--port", str(port), + "--working-directory", workdir], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 5 + while True: + self.assertIsNone(process.poll(), "starter exited before accepting a connection") + try: + client = socket.create_connection(("127.0.0.1", port), timeout=0.5) + break + except OSError: + if time.monotonic() >= deadline: + self.fail("starter did not bind within 5 seconds") + time.sleep(0.05) + with client: + client.sendall(b"GET / HTTP/1.1\r\n") + # The starter has no HTTP handler yet, but its TCP lifecycle works. + self.assertEqual(client.recv(1), b"") + finally: + process.terminate() + process.wait(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/homework/03-http-server/tests/test_utils.go b/homework/03-http-server/tests/test_utils.go new file mode 100644 index 0000000..36b0083 --- /dev/null +++ b/homework/03-http-server/tests/test_utils.go @@ -0,0 +1,103 @@ +package hw3test + +import ( + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "testing" +) + +// TC implements *testing.T and context.Context at the same time. +// Also has some helper methods. +type TC struct { + *testing.T + ctx context.Context +} + +func NewTestContext(t *testing.T) *TC { + return &TC{ + ctx: context.Background(), + T: t, + } +} + +func (tc *TC) Errorf(format string, args ...any) { + str := fmt.Sprintf(format, args...) + if strings.HasPrefix(str, "\n\tError Trace:") { + _, after, ok := strings.Cut(str, "\tError:") + if ok { + str = "\n\tError:" + after + } + } + Error(tc, str) + tc.T.Errorf(str) +} + +func (tc *TC) RunByName(name string, f func(*TC)) bool { + return tc.Run(name, func(t *testing.T) { + f(tc.derive(t)) + }) +} + +func (tc *TC) RunBySeed(seed int64, f func(*TC)) bool { + return tc.Run(strconv.FormatInt(seed, 10), func(t *testing.T) { + f(tc.derive(t)) + }) +} + +func (tc *TC) Done() <-chan struct{} { + return tc.ctx.Done() +} + +func (tc *TC) Err() error { + return tc.ctx.Err() +} + +func (tc *TC) Value(key any) any { + return tc.ctx.Value(key) +} + +// derive copies *TC and replaces *testing.T +func (tc TC) derive(t *testing.T) *TC { + tc.T = t + return &tc +} + +// Get bool from envvars +func boolFromEnv(key string, def bool) bool { + value := os.Getenv(key) + if value == "" { + return def + } + + value = strings.ToLower(value) + switch value { + case "t": + return true + case "true": + return true + case "y": + return true + case "yes": + return true + } + return false +} + +// GetFreePort asks the kernel for a free open port that is ready to use. +func GetFreePort() (int, error) { + addr, err := net.ResolveTCPAddr("tcp", "localhost:0") + if err != nil { + return 0, err + } + + l, err := net.ListenTCP("tcp", addr) + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} diff --git a/homework/03-http-server/tests/tester.go b/homework/03-http-server/tests/tester.go new file mode 100644 index 0000000..b87592d --- /dev/null +++ b/homework/03-http-server/tests/tester.go @@ -0,0 +1,308 @@ +package hw3test + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "os" + "path" + "path/filepath" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// RunTestEmptyWorkDir will check that server does exit(1) if working directory is empty. +func RunTestEmptyWorkDir(t *TC, seed int64, runner Runner) { + t.RunBySeed(seed, func(t *TC) { + r := rand.New(rand.NewSource(seed)) + + port, err := GetFreePort() + require.NoError(t, err, "failed to get free port for the server") + runOpts := RunOpts{ + Port: port, + WorkingDirectory: "", + ServerDomain: "localhost", + ListenAddr: "0.0.0.0", + ExitCode: make(chan int), + } + + // Start the solution. + runOpts.GenerateRunConfig(t, r, &EnvGen{}) + stop, err := runner.Run(t, runOpts) + require.NoError(t, err, "failed to start solution") + defer stop() + + select { + case <-time.After(time.Second * 10): + require.FailNow(t, "Server didn't exit(1) in 10 seconds") + case ec := <-runOpts.ExitCode: + require.Equal(t, 1, ec, "Server exited with wrong code") + } + }) +} + +func RunTests(t *TC, seed int64, runner Runner, envGen *EnvGen, queriesGen *QueriesGen) { + failed := !t.RunBySeed(seed, func(t *TC) { + env, err := envGen.Generate(seed) + require.NoError(t, err, "failed to generate environment") + + r := rand.New(rand.NewSource(seed)) + + queriesSeeds := make([]int64, queriesGen.Count) + for i := range queriesSeeds { + queriesSeeds[i] = r.Int63n(100000) + } + + envDirName := GenFilename(r, 16) + envDir := filepath.Join(envGen.TempDirectory, envDirName) + + runOpts := RunOpts{ + WorkingDirectory: envDir, + ServerDomain: "localhost", + ListenAddr: "0.0.0.0", + } + if queriesGen.AllHeaders { + possibleDomains := []string{ + "localhost", + "cs.hse.ru", + "example.com", + "z0r.de", + "distsys-course.homework.net", + } + runOpts.ServerDomain = possibleDomains[r.Intn(len(possibleDomains))] + } + + queries := queriesGen.Generate(t, env, envGen, queriesSeeds, runOpts, seed) + if envGen.SparseUnusedFiles { + needed := make(map[string]bool) + for _, query := range queries { + if query.Method == http.MethodGet || query.Method == http.MethodPut { + needed[path.Clean(query.Path)] = true + } + } + err = env.RootDir.WriteToDiskSelected(envDir, needed) + } else { + err = env.RootDir.WriteToDisk(envDir) + } + require.NoError(t, err, "failed to write environment to disk") + defer os.RemoveAll(envDir) + port, err := GetFreePort() + require.NoError(t, err, "failed to get free port for the server") + runOpts.Port = port + + // Start the solution. + runOpts.GenerateRunConfig(t, r, envGen) + stop, err := runner.Run(t, runOpts) + require.NoError(t, err, "failed to start solution") + defer stop() + + // Await server to bind to port. + err = WaitForServer(t, runOpts) + require.NoError(t, err, "failed to wait for server") + + // Run the queries. + for i, query := range queries { + query := query + ok := t.RunBySeed(query.Seed, func(t *TC) { + RunQuery(t, env, envDir, runOpts, query) + }) + shouldAbort := !ok + if shouldAbort { + Warn(t, "Skipping next queries because of the failed query", zap.Int("skipped", len(queries)-1-i), zap.String("failed", fmt.Sprintf("%s/%v", t.Name(), query.Seed))) + break + } + } + }) + + shouldAbort := failed + if shouldAbort { + Warn(t, "Skipping next tests in a group because last test has failed") + t.FailNow() + } +} + +func WaitForServer(t *TC, opts RunOpts) error { + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + deadline := time.Now().Add(10 * time.Second) + var lastErr error + for attempt := 0; time.Until(deadline) > 0; attempt++ { + probeTimeout := time.Until(deadline) + if probeTimeout > time.Second { + probeTimeout = time.Second + } + ctx, cancel := context.WithTimeout(t, probeTimeout) + req, err := http.NewRequestWithContext(ctx, "GET", opts.Address(), nil) + if err != nil { + cancel() + return fmt.Errorf("failed to create context request: %w", err) + } + req.Host = opts.ServerDomain + resp, err := client.Do(req) + if err == nil { + if resp != nil { + resp.Body.Close() + } + cancel() + return nil + } + cancel() + lastErr = err + if attempt == 0 || attempt%10 == 0 { + Debug(t, "Waiting for server startup", zap.Int("attempt", attempt), zap.Error(err)) + } + if remaining := time.Until(deadline); remaining > 0 { + if remaining > 100*time.Millisecond { + remaining = 100 * time.Millisecond + } + time.Sleep(remaining) + } + } + Warn(t, "Server didn't get up in time, aborting", zap.Error(lastErr)) + return fmt.Errorf("server did not start in 10 seconds: %w", lastErr) +} + +// RunQuery runs a single query. Server address and its configuration is taken from runOpts. +// Initial environment is described by env, but it may be changed with queries. Actual +// environment is located in envDir. Query describes the query itself. +func RunQuery(t *TC, env *Env, envDir string, opts RunOpts, query Query) { + queryURL := fmt.Sprintf("%s/%s", opts.Address(), query.Path) + Debug( + t, + "Sending query", + zap.String("method", query.Method), + zap.String("path", query.Path), + zap.String("url", queryURL), + ) + + action := query.Action(env, &opts) + if action != nil { + action.VerifyBefore(t, envDir) + } + + req := query.CreateRequest(t, queryURL) + req.Close = true + conn, err := net.DialTimeout("tcp", req.URL.Host, 10*time.Second) + require.NoError(t, err, "failed to connect to server") + defer conn.Close() + writeDone := make(chan error, 1) + writer := &idleDeadlineWriter{Conn: conn, Timeout: 15 * time.Second} + go func() { writeDone <- req.Write(writer) }() + reader := bufio.NewReader(&idleDeadlineReader{Conn: conn, Timeout: 30 * time.Second}) + + resp, err := http.ReadResponse(reader, req) + require.NoError(t, err, "failed to run query on server") + defer resp.Body.Close() + body := &countingBody{ReadCloser: resp.Body} + resp.Body = body + + query.CommonValidate(t, req, resp) + + if action != nil { + action.VerifyResponse(t, req, resp) + } + _, err = io.Copy(io.Discard, resp.Body) + require.NoError(t, err, "failed to read response body") + if resp.StatusCode != http.StatusNoContent { + require.Equal(t, resp.ContentLength, body.Size, "Content-Length differs from response body size") + } + endDone := make(chan error, 1) + go func() { endDone <- checkResponseEnd(reader, req.Body != nil) }() + var endErr, writeErr error + writeCompleted := false + select { + case endErr = <-endDone: + // The server closed the connection before the request writer finished. + case writeErr = <-writeDone: + writeCompleted = true + var netErr net.Error + // http.Request.Write wraps body write errors without preserving Unwrap. + // Inspect the original socket error captured by the writer instead. + if errors.As(writer.lastError, &netErr) && netErr.Timeout() { + require.NoError(t, writer.lastError, "request upload stalled") + } + // A server may finish reading a large request after sending its response. + // Start the close timeout only after the request writer has finished. + closeTimeout := 2 * time.Second + if req.Body != nil { + closeTimeout = 10 * time.Second + } + require.NoError(t, conn.SetReadDeadline(time.Now().Add(closeTimeout))) + endErr = <-endDone + } + require.NoError(t, endErr, "response contains bytes beyond Content-Length or connection was not closed") + // The server may reply without consuming the whole request body. In that + // case the response and the file-system checks determine correctness. + if req.Body == nil { + if !writeCompleted { + writeErr = <-writeDone + } + require.NoError(t, writeErr, "failed to send query to server") + } + if query.VerifyHeaders && body.Size > 0 { + _, err = parseContentType(resp.Header.Get("Content-Type")) + require.NoError(t, err, "expected valid content type") + } + if action != nil { + action.VerifyAfter(t, envDir) + action.ApplyEnv(t, env) + } +} + +// idleDeadlineWriter bounds pauses in sending a request without limiting the +// total time for a large request that continues to make progress. +type idleDeadlineWriter struct { + net.Conn + Timeout time.Duration + lastError error +} + +func (w *idleDeadlineWriter) Write(p []byte) (int, error) { + if err := w.Conn.SetWriteDeadline(time.Now().Add(w.Timeout)); err != nil { + w.lastError = err + return 0, err + } + n, err := w.Conn.Write(p) + if err != nil { + w.lastError = err + } + return n, err +} + +func checkResponseEnd(reader *bufio.Reader, requestHasBody bool) error { + _, err := reader.ReadByte() + if err == io.EOF { + return nil + } + // Closing a connection with an unread request body can produce a TCP reset + // after a complete error response. Its error code differs by platform. + var netErr *net.OpError + if requestHasBody && errors.As(err, &netErr) && netErr.Op == "read" && !netErr.Timeout() { + return nil + } + if err != nil { + return err + } + return fmt.Errorf("unexpected byte after response body") +} + +type countingBody struct { + io.ReadCloser + Size int64 +} + +func (b *countingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.Size += int64(n) + return n, err +} diff --git a/homework/03-http-server/tests/verify-zram-scratch.sh b/homework/03-http-server/tests/verify-zram-scratch.sh new file mode 100644 index 0000000..a5fe103 --- /dev/null +++ b/homework/03-http-server/tests/verify-zram-scratch.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +# Docker exposes the filesystem source after the separator in mountinfo. +# A bind mount whose host source is an ext4 filesystem on zram retains that +# source; a plain host directory on the HDD must not pass this check. +awk ' + $5 == "/hw/tests/tmp" { + for (i = 6; i <= NF - 2; i++) { + if ($i == "-" && $(i + 1) == "ext4" && $(i + 2) ~ /^\/dev\/zram[0-9]+$/) + found = 1 + } + } + END { exit !found } +' /proc/self/mountinfo