Compare commits

...

13 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
8 changed files with 644 additions and 51 deletions
Generated
+37
View File
@@ -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"
+3 -1
View File
@@ -12,4 +12,6 @@ panic = "abort"
[dependencies]
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::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];
loop {
let bytes_read = self.stream.read(&mut buffer).await?;
if bytes_read == 0 {
println!(
"Обработчик {}: клиент {} отключился",
self.id,
self.client_addr
);
return Ok(());
}
while let Some(message) = self.read_message().await? {
self.message_count += 1;
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?;
self.print_message(message).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.
* **Следующий шаг:** Найти рабочий способ отправки тестового запроса в 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` в одном соединении.
+2 -2
View File
@@ -1,5 +1,6 @@
mod connection; //модуль отвечающий за соединения
mod protocol; //модуль отвечающий за протокол
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!(
+7 -6
View File
@@ -5,12 +5,13 @@
2. Вернуть структуру Header c понятными полями заголовка:
a. version: u8 //Версия протокола
b. command: u8 //Тип сообщения
0x01 UPLOAD клиент → сервер
0x02 DOWNLOAD клиент → сервер
0x03 DELETE клиент → сервер
0x04 STAT клиент → сервер
0x80 RESPONSE в обе стороны
0x81 FILE_DATA сервер → клиент
Код Команда Кто отправляет
0x01 UPLOAD клиент → сервер
0x02 DOWNLOAD клиент → сервер
0x03 DELETE клиент → сервер
0x04 STAT клиент → сервер
0x80 RESPONSE клиент ↔ сервер
0x81 FILE_DATA сервер → клиент
c. flags: u16 //Дополнительные признаки
0x0001 — разрешено перезаписать существующий файл
0x0002 — передаётся контрольная сумма
+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
}
}