Add HW 3
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tmp
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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}}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user