Compare commits
15 Commits
e4e0bf33f0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc62acbc3 | |||
| 46f2721570 | |||
| e45b86cd20 | |||
| 3c3487b554 | |||
| dd6ef1d96d | |||
| 94030c34ca | |||
| b80b8e9e04 | |||
| 80b8c125ad | |||
| 6a269ca087 | |||
| d59e9d83a4 | |||
| 7bacf7ac73 | |||
| 0f2cb0384c | |||
| 9f2d7f635e | |||
| 359337bc10 | |||
| 1611d1c70b |
Generated
+37
@@ -163,6 +163,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
@@ -208,9 +214,20 @@ name = "nocloud-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"local-ip-address",
|
||||
"nom",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -346,6 +363,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
|
||||
@@ -13,3 +13,5 @@ panic = "abort"
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
local-ip-address = "0.6"
|
||||
thiserror = "2.0"
|
||||
nom = "8.0.0"
|
||||
|
||||
+103
-30
@@ -1,8 +1,20 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::{
|
||||
io,
|
||||
net::SocketAddr,
|
||||
};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream};
|
||||
use std::io;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
};
|
||||
|
||||
use crate::{protocol::{
|
||||
Command, HEADER_SIZE, Header, decode_header,
|
||||
}, upload};
|
||||
|
||||
struct IncomingMessage {
|
||||
header: Header,
|
||||
}
|
||||
|
||||
pub struct Connection {
|
||||
id: u64,
|
||||
@@ -27,46 +39,107 @@ impl Connection {
|
||||
|
||||
pub async fn run(mut self) -> io::Result<()> {
|
||||
println!(
|
||||
"Обработчик {} создан для {}",
|
||||
"Соединение {} установлено с {}",
|
||||
self.id,
|
||||
self.client_addr
|
||||
);
|
||||
|
||||
let mut buffer = [0_u8; 1024];
|
||||
while let Some(message) = self.read_message().await? {
|
||||
self.message_count += 1;
|
||||
self.print_message(message).await?;
|
||||
}
|
||||
|
||||
loop {
|
||||
let bytes_read = self.stream.read(&mut buffer).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 {
|
||||
println!(
|
||||
"Обработчик {}: клиент {} отключился",
|
||||
self.id,
|
||||
self.client_addr
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.message_count += 1;
|
||||
// Читаем остальную часть буфера от 1 до конца общего заголовка
|
||||
self.stream.read_exact(&mut buffer[1..HEADER_SIZE]).await?;
|
||||
|
||||
let received =
|
||||
String::from_utf8_lossy(&buffer[..bytes_read]);
|
||||
//Парсим скачанный заголовок через 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,
|
||||
received
|
||||
message.header
|
||||
);
|
||||
|
||||
let response = format!(
|
||||
"Обработчик {}: сообщение №{}: {}\n",
|
||||
self.id,
|
||||
self.message_count,
|
||||
received
|
||||
);
|
||||
|
||||
self.stream.write_all(response.as_bytes()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+262
-3
@@ -1,6 +1,265 @@
|
||||
# Статус разработки проекта
|
||||
|
||||
## 📌 Текущее состояние
|
||||
* **Где остановился:** Сервер успешно запускается и слушает сетевой сокет.
|
||||
* **Проблема:** Не удалось проверить эхо-ответ через PowerShell.
|
||||
* **Следующий шаг:** Найти рабочий способ отправки тестового запроса в PowerShell или использовать альтернативный инструмент.
|
||||
|
||||
- **Где остановился:** Окультурил контракт между protocol connection, работает точно также, но
|
||||
- архитектурно красивей.
|
||||
- Далее, нужно реализовать 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
@@ -1,5 +1,6 @@
|
||||
mod connection;
|
||||
mod protocol;
|
||||
mod connection; //модуль отвечающий за соединения
|
||||
mod protocol; //модуль отвечающий за протокол ...
|
||||
mod upload; //Модуль отвечающий за загрузку файла
|
||||
|
||||
use tokio::net::{TcpListener};
|
||||
use tokio::io;
|
||||
@@ -30,7 +31,6 @@ async fn main() -> io::Result<()> {
|
||||
client_addr,
|
||||
stream,
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = connection.run().await {
|
||||
eprintln!(
|
||||
|
||||
+510
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user