Compare commits

...

15 Commits

Author SHA1 Message Date
Stanislav N Mikhailov 9bc62acbc3 feat(upload): read fixed-size upload prefix 2026-08-11 21:39:50 +03:00
Stanislav N Mikhailov 46f2721570 wip 2026-08-10 17:29:15 +03:00
Stanislav N Mikhailov e45b86cd20 refactor(upload): route upload commands to dedicated handler 2026-08-10 14:17:29 +03:00
Stanislav N Mikhailov 3c3487b554 feat(protocol): add typed command decoding and dispatch
- add Command enum for protocol command codes
- convert raw u8 command values via TryFrom
- validate commands through Command conversion
- dispatch decoded commands in Connection
- reject unknown command codes
2026-08-07 17:46:07 +03:00
Stanislav N Mikhailov dd6ef1d96d chore(upload): document implementation steps and remove unused nom
import
2026-08-07 15:45:20 +03:00
Stanislav N Mikhailov 94030c34ca refactor(protocol): centralize header parsing and validation 2026-08-07 03:41:19 +03:00
Stanislav N Mikhailov b80b8e9e04 docs: add comments explaining parser logic 2026-08-06 23:32:42 +03:00
Stanislav N Mikhailov 80b8c125ad Fix flags validation logic (compiled and verified) 2026-08-06 16:50:07 +03:00
Stanislav N Mikhailov 6a269ca087 WIP: draft validation for flags (does not compile yet) 2026-08-06 16:22:28 +03:00
Stanislav N Mikhailov d59e9d83a4 Test git rights 2026-08-03 16:27:03 +03:00
Stanislav N Mikhailov 7bacf7ac73 refactor: sanitize AI code 2026-08-03 14:52:42 +03:00
Stanislav N Mikhailov 0f2cb0384c feat(protocol): parse and validate binary packet headers 2026-07-21 22:57:50 +03:00
Stanislav N Mikhailov 9f2d7f635e feat(protocol): add initial parser for common message header 2026-07-18 21:58:32 +03:00
Stanislav N Mikhailov 359337bc10 added protocol.MD 2026-07-18 17:23:21 +03:00
Stanislav N Mikhailov 1611d1c70b added protocol.MD 2026-07-18 17:22:53 +03:00
8 changed files with 1148 additions and 46 deletions
Generated
+37
View File
@@ -163,6 +163,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -208,9 +214,20 @@ name = "nocloud-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"local-ip-address", "local-ip-address",
"nom",
"thiserror",
"tokio", "tokio",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -346,6 +363,26 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.52.3"
+3 -1
View File
@@ -12,4 +12,6 @@ panic = "abort"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
local-ip-address = "0.6" local-ip-address = "0.6"
thiserror = "2.0"
nom = "8.0.0"
+112 -39
View File
@@ -1,8 +1,20 @@
use std::net::SocketAddr; use std::{
io,
net::SocketAddr,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::{
use tokio::net::{TcpStream}; io::{AsyncReadExt, AsyncWriteExt},
use std::io; net::TcpStream,
};
use crate::{protocol::{
Command, HEADER_SIZE, Header, decode_header,
}, upload};
struct IncomingMessage {
header: Header,
}
pub struct Connection { pub struct Connection {
id: u64, id: u64,
@@ -27,46 +39,107 @@ impl Connection {
pub async fn run(mut self) -> io::Result<()> { pub async fn run(mut self) -> io::Result<()> {
println!( println!(
"Обработчик {} создан для {}", "Соединение {} установлено с {}",
self.id, self.id,
self.client_addr self.client_addr
); );
let mut buffer = [0_u8; 1024]; while let Some(message) = self.read_message().await? {
loop {
let bytes_read = self.stream.read(&mut buffer).await?;
if bytes_read == 0 {
println!(
"Обработчик {}: клиент {} отключился",
self.id,
self.client_addr
);
return Ok(());
}
self.message_count += 1; self.message_count += 1;
self.print_message(message).await?;
let received =
String::from_utf8_lossy(&buffer[..bytes_read]);
println!(
"Обработчик {}: сообщение №{}: {:?}",
self.id,
self.message_count,
received
);
let response = format!(
"Обработчик {}: сообщение №{}: {}\n",
self.id,
self.message_count,
received
);
self.stream.write_all(response.as_bytes()).await?;
} }
println!(
"Соединение {} с {} закрыто клиентом",
self.id,
self.client_addr
);
Ok(())
}
async fn read_message(
&mut self,
) -> io::Result<Option<IncomingMessage>> {
let Some(header) = self.read_header().await? else {
return Ok(None);
};
//Заголовок скачен и валиден. Получаем команду из заголовка
match header.command() {
Command::Upload => {
println!("Команда UPLOAD");
let upl_result=
upload::handle(&mut self.stream, header).await.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Ошибка UPLOAD:{error:}")
)
}
)?;
}
Command::Download => {
println!("Команда DOWNLOAD");
}
Command::Delete => {
println!("Команда DELETE");
}
Command::Stat => {
println!("Команда STAT");
}
Command::FileData => {
println!("Команда FILEDATA");
}
Command::Response => {
println!("Команда RESPONSE");
}
}
//let body = self.read_body(header.body_size()).await?;
Ok(None)
}
async fn read_header(&mut self) -> io::Result<Option<Header>> {
let mut buffer = [0_u8; HEADER_SIZE];
// Первый байт читаем отдельно, чтобы отличить нормальное
// закрытие соединения от оборванного заголовка.
let bytes_read = self.stream.read(&mut buffer[..1]).await?;
if bytes_read == 0 {
return Ok(None);
}
// Читаем остальную часть буфера от 1 до конца общего заголовка
self.stream.read_exact(&mut buffer[1..HEADER_SIZE]).await?;
//Парсим скачанный заголовок через nocloud_core::protocol parse_header
let header =
decode_header(&buffer).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Ошибка разбора заголовка: {error:?}"),
)
})?;
Ok(Some(header))
}
async fn print_message(
&mut self,
message: IncomingMessage,
) -> io::Result<()> {
println!(
"Соединение {}: сообщение №{}, заголовок: {:?}",
self.id,
self.message_count,
message.header
);
Ok(())
} }
} }
+262 -3
View File
@@ -1,6 +1,265 @@
# Статус разработки проекта # Статус разработки проекта
## 📌 Текущее состояние ## 📌 Текущее состояние
* **Где остановился:** Сервер успешно запускается и слушает сетевой сокет.
* **Проблема:** Не удалось проверить эхо-ответ через PowerShell. - **Где остановился:** Окультурил контракт между protocol connection, работает точно также, но
* **Следующий шаг:** Найти рабочий способ отправки тестового запроса в PowerShell или использовать альтернативный инструмент. - архитектурно красивей.
- Далее, нужно реализовать UPLOAD с проверкой валидностей имени файла, размеров. И последующей передачей в storage/
- Ниже - чеклист для реализации.
- **Проблема:**
- - нет проблем 😇
## Чек-лист реализации `UPLOAD`
### 1. Подготовить диспетчеризацию команд
- [✔] Оставить `decode_header()` единственной публичной функцией получения проверенного `Header`.
- [✔] Убедиться, что `connection.rs` больше не вызывает `header.validate()`.
- [✔] После получения `Header` выполнить `match` по `header.command()`.
- [✔] Для `0x01` вызвать обработчик `UPLOAD`.
~~[✔] Для остальных команд пока возвращать ошибку `UnsupportedCommand`.~~
- [✔] Для остальных вызвать свои обработчики
- [✔] Проверить: тестовый заголовок `command = 0x01` попадает в обработчик `UPLOAD`.
Ожидаемый промежуточный результат:
```text
Получена команда UPLOAD
```
### 2. Создать постоянный модуль загрузки
- [✔] Создать `upload.rs`.
- [✔] Подключить его в `main.rs`:
```rust
mod upload;
```
- [✔ Создать в нём асинхронную функцию `handle()`.
- [✔] Передать в неё:
- `&mut` сетевой поток;
- проверенный `Header`.
- [✔] Пока только вывести заголовок и вернуть `Ok(())`.
- [✔] Убедиться, что после вызова управление возвращается в цикл `Connection`.
Первая форма может иметь такой смысл:
```rust
pub async fn handle(
stream: &mut TcpStream,
header: Header,
) -> io::Result<()> {
println!("Обработчик UPLOAD получил заголовок: {header:?}");
Ok(())
}
```
### 3. Убрать чтение всего тела в `Vec`
- [✔] Не вызывать текущий `read_body()` для команды `UPLOAD`.
- [✔] Не создавать:
```rust
vec![0_u8; header.body_size() as usize]
```
- [✔] Удалить или временно оставить `read_body()` только для будущих небольших команд.
- [✔] Убедиться, что `UPLOAD` получает поток, стоящий точно перед первым байтом тела.
### 4. Описать метаданные загрузки
- [✔] В `protocol.rs` создать структуру:
```rust
pub struct UploadMetadata {
file_size: u64,
file_name: String,
}
```
- [✔] Добавить геттеры для `file_size` и `file_name`.
- [✔] Добавить константу максимальной длины имени:
```rust
MAX_RELATIVE_PATH_BYTES
```
- [✔] Пока не добавлять в структуру содержимое файла.
### 5. Прочитать фиксированную часть метаданных
Формат:
```text
file_size: 8 байт
name_size: 2 байта
```
- [✔] В `upload::handle()` создать буфер размером 10 байт.
- [✔] Прочитать в него ровно 10 байт через `read_exact()`.
- [✔] Пока вывести полученные байты.
- [✔] Проверить обрыв соединения внутри этих десяти байт.
Ожидаемый результат:
```text
Получены 10 байт метаданных UPLOAD
```
### 6. Разобрать фиксированную часть в `protocol.rs`
- [ ] Создать приватный парсер `parse_upload_prefix()`.
- [ ] Разобрать:
- `file_size`;
- `name_size`.
- [ ] Создать промежуточную структуру:
```rust
UploadPrefix {
file_size: u64,
name_size: u16,
}
```
- [ ] Создать публичную функцию `decode_upload_prefix()`.
- [ ] Скрыть ошибки `nom` внутри `protocol.rs`.
- [ ] Проверить, что парсер использовал все 10 байт.
- [ ] Вернуть проверенный `UploadPrefix`.
### 7. Проверить длину имени
- [ ] Отклонить `name_size = 0`.
- [ ] Отклонить `name_size > MAX_FILENAME_SIZE`.
- [ ] Не выделять память для имени до проверки размера.
- [ ] Добавить отдельные ошибки:
- пустое имя;
- слишком длинное имя.
### 8. Прочитать имя файла
- [ ] После проверки `name_size` выделить `Vec<u8>` только под имя.
- [ ] Прочитать ровно `name_size` байт.
- [ ] Передать эти байты в `protocol.rs`.
- [ ] Проверить UTF-8.
- [ ] Получить `String`.
- [ ] Собрать `UploadMetadata`.
Ожидаемый результат:
```text
UploadMetadata {
file_size: 5,
file_name: "a.txt",
}
```
### 9. Проверить согласованность размеров
- [ ] Вычислить ожидаемый размер тела:
```text
10 + name_size + file_size
```
- [ ] Использовать `checked_add()`.
- [ ] Сравнить результат с `header.body_size()`.
- [ ] Отклонить пакет, если размеры не совпадают.
- [ ] Добавить ошибку переполнения размера.
- [ ] Добавить ошибку несовпадения `body_size`.
### 10. Добавить лимит размера файла
- [ ] Добавить временную настройку `MAX_FILE_SIZE`.
- [ ] Проверять `file_size` до создания файла.
- [ ] Не связывать `MAX_FILE_SIZE` с размером сетевого буфера.
- [ ] Позднее вынести лимит в конфигурацию сервера.
### 11. Создать модуль хранилища
- [ ] Создать `storage.rs`.
- [ ] Подключить его в `main.rs`.
- [ ] Создать тип `Storage`.
- [ ] Передавать `Storage` в `Connection`.
- [ ] Передавать ссылку на `Storage` в `upload::handle()`.
- [ ] Пока реализовать только выбор каталога хранения.
### 12. Проверить имя на уровне хранилища
- [ ] Запретить пустое имя.
- [ ] Запретить абсолютный путь.
- [ ] Запретить компоненты `..`.
- [ ] Запретить нулевой байт.
- [ ] Убедиться, что итоговый путь остаётся внутри хранилища.
- [ ] Определить политику перезаписи существующего файла.
### 13. Создать временный файл
- [ ] Формировать имя с `request_id`, например:
```text
a.txt.nocloud-part-17
```
- [ ] Создавать временный файл только после всех проверок метаданных.
- [ ] Не создавать сразу итоговый файл.
- [ ] Убедиться, что ошибка создания корректно возвращается обработчику.
### 14. Принять содержимое файла порциями
- [ ] Создать фиксированный буфер, например 64 КиБ.
- [ ] Завести счётчик `remaining_file_bytes = file_size`.
- [ ] На каждой итерации читать не больше:
```text
min(remaining_file_bytes, buffer.len())
```
- [ ] Записывать прочитанную порцию во временный файл.
- [ ] Уменьшать `remaining_file_bytes`.
- [ ] Завершить цикл при достижении нуля.
- [ ] Не читать байты следующего сообщения.
### 15. Обработать оборванную передачу
- [ ] Если клиент отключился раньше `file_size`, считать загрузку незавершённой.
- [ ] Закрыть временный файл.
- [ ] Удалить временный файл либо сохранить для будущей докачки.
- [ ] Пока выбрать простую политику: удалять.
- [ ] Не создавать итоговый файл при ошибке.
### 16. Завершить загрузку
- [ ] Сбросить буферы файла на диск.
- [ ] Закрыть временный файл.
- [ ] Переименовать временный файл в итоговый.
- [ ] Считать переименование точкой успешного завершения операции.
### 17. Отправить ответ
- [ ] Сформировать `RESPONSE` с тем же `request_id`.
- [ ] При успехе вернуть статус `0`.
- [ ] При ошибке вернуть соответствующий код.
- [ ] Не отправлять текстовую строку вместо сообщения протокола.
- [ ] После ответа вернуться в цикл `Connection`.
### 18. Провести испытания
- [ ] Корректный маленький файл.
- [ ] Пустой файл размером `0`.
- [ ] Имя длиной `0`.
- [ ] Имя длиннее лимита.
- [ ] Некорректный UTF-8.
- [ ] `body_size` меньше вычисленного.
- [ ] `body_size` больше вычисленного.
- [ ] `file_size` превышает лимит.
- [ ] Обрыв во время метаданных.
- [ ] Обрыв посередине файла.
- [ ] Попытка передать `../test.txt`.
- [ ] Повторная загрузка существующего файла.
- [ ] Два последовательных `UPLOAD` в одном соединении.
+3 -3
View File
@@ -1,5 +1,6 @@
mod connection; mod connection; //модуль отвечающий за соединения
mod protocol; mod protocol; //модуль отвечающий за протокол ...
mod upload; //Модуль отвечающий за загрузку файла
use tokio::net::{TcpListener}; use tokio::net::{TcpListener};
use tokio::io; use tokio::io;
@@ -30,7 +31,6 @@ async fn main() -> io::Result<()> {
client_addr, client_addr,
stream, stream,
); );
tokio::spawn(async move { tokio::spawn(async move {
if let Err(error) = connection.run().await { if let Err(error) = connection.run().await {
eprintln!( eprintln!(
+510
View File
@@ -0,0 +1,510 @@
# Protocol.MD NoCloud Protocol 0.1
## Задачи
1. Читать поля сообщения
2. Проверить их формат
2. Вернуть структуру Header c понятными полями заголовка:
a. version: u8 //Версия протокола
b. command: u8 //Тип сообщения
Код Команда Кто отправляет
0x01 UPLOAD клиент → сервер
0x02 DOWNLOAD клиент → сервер
0x03 DELETE клиент → сервер
0x04 STAT клиент → сервер
0x80 RESPONSE клиент ↔ сервер
0x81 FILE_DATA сервер → клиент
c. flags: u16 //Дополнительные признаки
0x0001 — разрешено перезаписать существующий файл
0x0002 — передаётся контрольная сумма
0x0004 — передача является возобновлением (докачка)
0x0008 - 0x00FF - резерв
d. request_id:u32 //Номер запроса
c. body_size: u64 Размер тела после заголовка
3. И, если операция касается передячи файла - дополнительными полями следующими сразу за телом заголовка
a. file_size: u64
b. name_size u16
c. file_name UTF-8
d. file_data u8
## Обрабатываемые запросы
Протокол будет в два этажа:
1. Общий заголовок сообщения — позволяет понять, что за команда пришла.
2. Тело команды — устроено по-разному для загрузки, скачивания, ответа и т. д.
## 1. Правила транспортного уровня
* Протокол работает поверх одного TCP-соединения.
* Позже TCP будет обёрнут в TLS/mTLS, но формат сообщений не изменится.
* Все целые числа передаются в big-endian.
* Один запрос обрабатывается целиком, затем начинается следующий.
* Файлы читаются и записываются порциями, но прикладных «блоков файла» пока нет.
* Клиент не отправляет следующий запрос, пока не получил ответ на предыдущий.
Последнее ограничение сильно упрощает первую реализацию. `request_id` мы всё равно предусмотрим, чтобы позже разрешить несколько запросов в одном соединении.
---
# 2. Общий заголовок сообщения
Каждое сообщение начинается с одинаковых 20 байт:
| Поле | Тип | Размер | Назначение |
| ------------ | --------: | -----: | --------------------------- |
| `magic` | `[u8; 4]` | 4 | Сигнатура `NCLD` |
| `version` | `u8` | 1 | Версия протокола |
| `command` | `u8` | 1 | Тип сообщения |
| `flags` | `u16` | 2 | Дополнительные признаки |
| `request_id` | `u32` | 4 | Номер запроса |
| `body_size` | `u64` | 8 | Размер тела после заголовка |
Итого:
```text
4 + 1 + 1 + 2 + 4 + 8 = 20 байт
```
В сетевом потоке:
```text
┌────────┬─────────┬─────────┬───────┬────────────┬───────────┐
│ magic │ version │ command │ flags │ request_id │ body_size │
│ 4 байта│ 1 байт │ 1 байт │ 2 байта│ 4 байта │ 8 байт │
└────────┴─────────┴─────────┴───────┴────────────┴───────────┘
```
## Зачем нужны эти поля
### `magic`
```text
NCLD
```
В байтах:
```text
4E 43 4C 44
```
Позволяет серверу понять:
> Передо мной действительно сообщение NoCloud, а не HTTP-запрос, мусор или поток со смещённой границей.
Если первые четыре байта не `NCLD`, соединение закрывается.
### `version`
Первоначально:
```text
version = 1
```
Если через год изменим формат протокола, сервер сможет отличать старых клиентов от новых.
### `command`
Определяет, как интерпретировать тело сообщения.
Первоначальный набор:
| Код | Название | Направление |
| -----: | ----------- | --------------- |
| `0x01` | `UPLOAD` | клиент → сервер |
| `0x02` | `DOWNLOAD` | клиент → сервер |
| `0x03` | `DELETE` | клиент → сервер |
| `0x04` | `STAT` | клиент → сервер |
| `0x80` | `RESPONSE` | в обе стороны |
| `0x81` | `FILE_DATA` | сервер → клиент |
На первом практическом этапе реализуем только:
```text
UPLOAD
RESPONSE
```
Остальные пока просто резервируем.
### `flags`
В первой версии:
```text
flags = 0
```
Позже сюда можно поместить признаки:
```text
0x0001 — разрешено перезаписать существующий файл
0x0002 — передаётся контрольная сумма
0x0004 — передача является возобновлением
```
Если все флаги равны нулю, они нам сейчас не мешают, но формат заголовка не придётся ломать позднее.
### `request_id`
Клиент назначает каждому запросу номер:
```text
UPLOAD request_id = 17
RESPONSE request_id = 17
```
Благодаря этому клиент понимает, на какой запрос ответил сервер.
Поначалу запросы будут строго последовательными, поэтому поле кажется избыточным. Но стоит оно всего четыре байта, а пригодится почти наверняка.
### `body_size`
Показывает, сколько байт идёт после общего заголовка.
Это позволяет:
* проверить допустимость размера;
* знать границу следующего сообщения;
* отбрасывать неизвестную команду;
* обнаруживать оборванную передачу.
---
# 3. Команда `UPLOAD`
После общего заголовка идёт тело загрузки:
| Поле | Тип | Размер |
| ----------- | ----: | ---------------: |
| `file_size` | `u64` | 8 байт |
| `name_size` | `u16` | 2 байта |
| `file_name` | UTF-8 | `name_size` байт |
| `file_data` | байты | `file_size` байт |
В потоке:
```text
Общий заголовок, 20 байт
┌───────────┬───────────┬────────────┬─────────────┐
│ file_size │ name_size │ file_name │ file_data │
│ 8 байт │ 2 байта │ N байт │ M байт │
└───────────┴───────────┴────────────┴─────────────┘
```
Размер тела должен быть равен:
```text
body_size = 8 + 2 + name_size + file_size
```
Например, клиент отправляет файл:
```text
hello.txt
```
размером 1000 байт. Имя занимает 9 байт в UTF-8:
```text
file_size = 1000
name_size = 9
body_size = 8 + 2 + 9 + 1000 = 1019
```
## Как сервер это обрабатывает
Сервер не создаёт структуру, содержащую весь `file_data`. Он действует последовательно:
```text
прочитать общий заголовок
убедиться, что command = UPLOAD
прочитать file_size
прочитать name_size
проверить name_size
прочитать имя
проверить имя файла
создать временный файл
прочитать file_size байт порциями
проверить успешное завершение
переименовать временный файл в итоговый
отправить RESPONSE
```
Буфер сервера может быть, например, 64 КиБ:
```text
файл размером 10 ГБ
прочитали до 64 КиБ
записали на диск
прочитали следующую порцию
```
Оперативная память не зависит от размера файла.
## Почему сначала временный файл
Допустим, загружается:
```text
report.pdf
```
Сервер сначала создаёт что-то вроде:
```text
report.pdf.nocloud-part-17
```
Если клиент оборвал передачу на 70%, мы не получим повреждённый `report.pdf`, выглядящий как готовый файл.
После успешной передачи:
```text
report.pdf.nocloud-part-17
↓ rename
report.pdf
```
---
# 4. Ответ `RESPONSE`
Сервер отвечает на запрос сообщением с тем же `request_id`.
Тело ответа:
| Поле | Тип | Размер |
| -------------- | ----: | ------------------: |
| `status` | `u16` | 2 байта |
| `message_size` | `u16` | 2 байта |
| `message` | UTF-8 | `message_size` байт |
Например:
```text
command = RESPONSE
request_id = 17
body_size = 6
status = 0
message_size = 2
message = "OK"
```
Первоначальные статусы:
| Код | Значение |
| --: | ----------------------- |
| `0` | Успех |
| `1` | Неизвестная команда |
| `2` | Некорректный заголовок |
| `3` | Недопустимое имя файла |
| `4` | Файл уже существует |
| `5` | Ошибка файловой системы |
| `6` | Не хватает места |
| `7` | Передача оборвана |
| `8` | Нет доступа |
| `9` | Неподдерживаемая версия |
Текст `message` предназначен для человека и журналов:
```text
"file already exists"
```
Программа принимает решение по числовому `status`, а не сравнивает строки.
---
# 5. Команда `DOWNLOAD`
Клиент запрашивает файл.
Тело запроса:
| Поле | Тип | Размер |
| ----------- | ----: | ---------------: |
| `name_size` | `u16` | 2 байта |
| `file_name` | UTF-8 | `name_size` байт |
```text
body_size = 2 + name_size
```
Если файл не существует, сервер возвращает обычный `RESPONSE` с ошибкой.
Если существует, сервер отвечает `FILE_DATA`.
---
# 6. Ответ `FILE_DATA`
Тело ответа:
| Поле | Тип | Размер |
| ----------- | ----: | ---------------: |
| `file_size` | `u64` | 8 байт |
| `name_size` | `u16` | 2 байта |
| `file_name` | UTF-8 | `name_size` байт |
| `file_data` | байты | `file_size` байт |
То есть загрузка и скачивание используют почти одинаковое представление файла. Отличается направление и код команды:
```text
UPLOAD:
клиент → сервер
FILE_DATA:
сервер → клиент
```
После успешного получения файла клиент может отправить серверу `RESPONSE` с тем же `request_id`.
---
# 7. Ограничения, которые сервер обязан проверять
Данным клиента доверять нельзя, даже когда позже появится mTLS. Известный клиент тоже может содержать ошибку.
Установлю стартовые ограничения:
```text
максимальная длина имени: 1024 байта
максимальный текст ошибки: 4096 байт
максимальный размер файла: задаётся настройкой сервера
version должна быть равна 1
неизвестные flags запрещены
```
Имя файла не должно:
* быть пустым;
* начинаться с `/` или `\`;
* содержать `..`;
* содержать нулевой байт;
* превращаться в абсолютный путь;
* позволять выйти из каталога хранилища.
Иначе клиент сможет прислать:
```text
../../etc/passwd
```
и попытаться записать файл за пределами хранилища.
---
# 8. Контрольная сумма
Будет в следующих реализациях
TCP уже гарантирует, что доставленные байты не были незаметно переставлены или повреждены в пути. Хеш нужен на другом уровне:
* проверить файл целиком;
* идентифицировать одинаковые файлы;
* обнаружить ошибку хранения;
* в будущем возобновлять и дедуплицировать передачи.
Во второй итерации можно добавить флаг:
```text
flags & 0x0002 != 0
```
и после имени передавать:
```text
hash_algorithm: u8
hash_size: u8
hash: [u8; hash_size]
```
Например SHA-256:
```text
hash_algorithm = 1
hash_size = 32
hash = 32 байта
```
Но сначала надо добиться безошибочной передачи обычного файла.
---
# 9. Как выглядит полная загрузка
```text
Клиент
│ Общий заголовок:
│ command = UPLOAD
│ request_id = 17
│ body_size = ...
│ file_size
│ name_size
│ file_name
│ file_data
Сервер
│ проверяет заголовок
│ создаёт временный файл
│ принимает данные порциями
│ переименовывает готовый файл
│ RESPONSE:
│ request_id = 17
│ status = 0
Клиент
```
Если передача оборвалась посередине, ответ уже отправить некому. Сервер удаляет или сохраняет временный файл для будущего возобновления — это мы решим отдельно.
---
# 10. Что сознательно не входит в версию 0.1
Пока отсутствуют:
* авторизация внутри протокола;
* каталоги и списки файлов;
* синхронизация изменений;
* время модификации;
* идентификатор файла;
* контрольная сумма;
* передача отдельных блоков;
* параллельные запросы;
* возобновление загрузки;
* разрешение конфликтов;
* сжатие.
Первая реализация задумана только для:
> Клиент отправляет один файл, сервер безопасно сохраняет его и возвращает подтверждение.
После этого поверх уже работающей передачи буду строить настоящую синхронизацию.
+177
View File
@@ -0,0 +1,177 @@
// Парсим заголовок на базе Nom
pub const HEADER_SIZE: usize = 20;
use nom::{
bytes::complete::tag,
// verify — комбинатор nom: проверяет результат парсера по условию.
// Пока не использую, т.к. собственная валидация через thiserror
// позволяет возвращать более конкретные ошибки.
// use nom::combinator::verify;
number::complete::{
be_u8,
be_u16,
be_u32,
be_u64,
},
IResult,
Parser,
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum HeaderError {
#[error("Некорректный формат заголовка")]
InvalidFormat,
#[error("Неподдерживаемая версия протокола: {0}")]
UnsupportedVersion(u8),
#[error("Неизвестная команда протокола: {0}")]
UnsupportedCommand(u8),
#[error("Неизвестный набор флагов: {0}")]
UnsupportedFlagSet(u16),
}
#[derive(Debug)]
pub struct Header {
version: u8,
command: u8,
flags: u16,
request_id: u32,
body_size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
/// Клиент загружает файл на сервер.
Upload,
/// Клиент запрашивает скачивание файла с сервера.
Download,
/// Клиент запрашивает удаление файла на сервере.
Delete,
/// Клиент запрашивает информацию о файле.
Stat,
/// Ответ на ранее отправленный запрос.
Response,
/// Сервер передаёт клиенту запрошенный файл.
FileData,
}
impl TryFrom<u8> for Command {
type Error = HeaderError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x01 => Ok(Command::Upload), //клиент → сервер
0x02 => Ok(Command::Download),
0x03 => Ok(Command::Delete),
0x04 => Ok(Command::Stat),
0x80 => Ok(Command::Response),
0x81 => Ok(Command::FileData),
other => Err(HeaderError::UnsupportedCommand(other)),
}
}
}
pub fn decode_header(
input: &[u8; HEADER_SIZE],
) -> Result<Header, HeaderError> {
// Парсим заголовок:
let (remaining, header) = parse_header(input)
.map_err(|_| HeaderError::InvalidFormat)?;
// Выходим с ошибкой, если заголовок был недочитан парсером:
if !remaining.is_empty() {
return Err(HeaderError::InvalidFormat);
}
//Валидация заголовка
header.validate()?;
Ok(header)
}
fn parse_header(
input: &[u8],
) -> IResult<&[u8],Header> {
//1. Парсим магические байты "NCLD"
let (input, _) = tag(&b"NCLD"[..]).parse(input)?;
//2. Парсим и валидируем версию
let (input, version) = be_u8(input)?;
//3. Парсим и валидируем Header
let (input, command) = be_u8(input)?;
//4. Парсим и валидируем flags
let (input, flags) = be_u16(input)?;
//5. Парсим Request без валидации
let (input, request_id) = be_u32(input)?;
//6. Парсим и валидируем величину заголовка
let (input, body_size) = be_u64(input)?;
let local_header = Header{
version,
command,
flags,
request_id,
body_size,
};
Ok((input,local_header))
}
// Реализация публичных методов-геттеров для безопасного доступа к приватным полям
impl Header {
/// Возвращает версию протокола пакета
pub fn version(&self) -> u8 {
// Так как тип u8 реализует трейт Copy, значение просто копируется наружу
self.version
}
/// Возвращает код команды (например, 1, 2, 80 или 81)
pub fn command(&self) -> Command {
Command::try_from(self.command)
.expect("Header уже прошёл валидацию команды")
}
/// Возвращает битовые флаги пакета (уже прошедшие валидацию)
pub fn flags(&self) -> u16 {
self.flags
}
/// Возвращает уникальный идентификатор запроса клиента
pub fn request_id(&self) -> u32 {
self.request_id
}
/// Возвращает размер тела сообщения в байтах
pub fn body_size(&self) -> u64 {
self.body_size
}
///Валидаторы для данных из заголовка:
///
// Валидация Header
pub fn validate(&self) -> Result<(), HeaderError> {
//Валидация version
if self.version != 1 {
return Err(
HeaderError::UnsupportedVersion(self.version)
);
}
// Валидация command
Command::try_from(self.command)?;
// Валидация flags
if !(0..=7).contains(&self.flags){
return Err(HeaderError::UnsupportedFlagSet (self.flags));
};
Ok(())
}
}
+44
View File
@@ -0,0 +1,44 @@
use tokio::{io::{self, AsyncReadExt}, net::TcpStream, stream};
use crate::protocol::Header;
const UPLOAD_PREFIX_SIZE: usize = 10; //Размер префикса в UPLOAD
pub const MAX_RELATIVE_PATH_BYTES: usize = 128 * 1024; //Размер пути с именем
struct UploadMetadata {
path_size: u64,
file_name: String,
}
pub async fn handle(stream: &mut TcpStream, header: Header) -> io::Result<()> {
println!("Обработчик UPLOAD получил заголовок: {header:?}");
let mut prefix_buffer = [0_u8; UPLOAD_PREFIX_SIZE];
stream.read_exact(&mut buffer).await?;
println!(
"Получены {UPLOAD_PREFIX_SIZE} байт метаданных UPLOAD: {buffer:?}"
);
Ok(())
}
impl UploadMetadata {
pub(crate) fn new(
file_size: u64,
relative_path: String,
) -> Self {
Self {
file_size,
relative_path,
}
}
pub fn file_size(&self) -> u64 {
self.file_size
}
pub fn relative_path(&self) -> &str {
&self.relative_path
}
}