This commit is contained in:
2026-09-24 21:30:37 +03:00
parent 0a1eb51bf8
commit 6210b46c1f
35 changed files with 4012 additions and 0 deletions
+236
View File
@@ -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 балл.
@@ -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"]
@@ -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",
}
@@ -0,0 +1 @@
click==8.3.0
@@ -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()
@@ -0,0 +1,7 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
tmp/
tests.log
+1
View File
@@ -0,0 +1 @@
tmp
+18
View File
@@ -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"]
+124
View File
@@ -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)
}
@@ -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)
}
}
}
})
}
}
+301
View File
@@ -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
}
@@ -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)
}
}
}
}
+128
View File
@@ -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
+233
View File
@@ -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:]
}
}
+73
View File
@@ -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
}
@@ -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")
}
}
+49
View File
@@ -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)
}
@@ -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
}
}
+16
View File
@@ -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
)
+28
View File
@@ -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=
+309
View File
@@ -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("==================================================================================")
}
@@ -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")
}
})
}
}
@@ -0,0 +1,2 @@
{{- /*gotype: hw3test.RunOpts */ -}}
python3 ../solution/server.py {{.CommandLineArgs}}
@@ -0,0 +1,2 @@
{{- /*gotype: hw3test.RunOpts */ -}}
docker run --memory=128m --memory-swap=128m --memory-swappiness=0 --rm {{.DockerVolumeArgs}} {{.DockerPortArgs}} {{.DockerEnvArgs}} hw3img {{.DockerCommandLineArgs}}
+94
View File
@@ -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()
}
+424
View File
@@ -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
}
+146
View File
@@ -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
}
@@ -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:
}
})
}
}
@@ -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")
}
}
+303
View File
@@ -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)
}
@@ -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)
}
})
}
}
@@ -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()
+103
View File
@@ -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
}
+308
View File
@@ -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
}
@@ -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