Skip to content

Commit 38b4218

Browse files
docs: complete architecture documentation (dev-sys-do#9)
* fix(network): improve error handling in ProtocolMessage parsing and TcpListener binding refactor(storage): simplify filename sanitization and directory creation logic * docs(README): update CLI example to recommend a minimal block size of 2048 bytes * docs(architecture): add flow diagrams for FerrisShare protocol (v1 and v2) * docs(README): clarify purpose of README and link to architecture documentation
1 parent 42ebb08 commit 38b4218

8 files changed

Lines changed: 174 additions & 49 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
Ferrisshare is a small Rust peer-to-peer file transfer toy used for a systems programming project.
44
It implements a tiny text-based protocol over TCP to send a single file from a sender (CLI) to a receiver (listener).
5-
6-
This README is intentionally short — it explains what the project is and how to run the listener and the CLI sender locally for development.
5+
For a detailed protocol and architecture overview, see [docs/architecture.md](docs/architecture.md).
76

87
## What it is
98

@@ -35,9 +34,13 @@ Send a file with the CLI (sender). Example: send the repository `README.md` to l
3534

3635
```bash
3736
# In another terminal
38-
cargo run --bin cli -- send --addr 127.0.0.1:9000 --file README.md --block-size 1024
37+
cargo run --bin cli -- send --addr 127.0.0.1:9000 --file README.md --block-size 2048
3938
```
4039

40+
**Recommended minimal block size**
41+
42+
We recommend using a minimal block size of 2048 bytes (as shown in the example above). Larger blocks reduce protocol overhead and typically improve throughput for local transfers. Be aware larger blocks use more memory and may be less forgiving on very unreliable networks — adjust down if you see timeouts or memory pressure.
43+
4144
Logs printed to both terminals show the protocol exchange (HELLO, OK, YEET blocks, OK-HOUSTEN responses, MISSION-ACCOMPLISHED, SUCCESS, BYE-RIS).
4245

4346
## Notes and troubleshooting

docs/architecture.md

Lines changed: 155 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,38 +16,50 @@ The primary goals of this project are:
1616
4. **Reliability**: Implement a simple protocol with handshake verification to ensure successful transfers
1717
5. **Simplicity**: Provide a straightforward CLI interface similar to common networking tools
1818

19-
### Non-Goals
20-
21-
- **Discovery Protocol**: The sender must know the receiver's IP address (no automatic peer discovery)
22-
- **Encryption**: File transfers are not encrypted (local network trust assumed)
2319
- **Resume Support**: Interrupted transfers cannot be resumed
2420
- **Multi-file Transfers**: Each transfer handles exactly one file
2521

26-
## **Choice of Dependencies**
22+
### 1.1 **Choice of Dependencies**
2723

28-
### Tokyo
24+
This project uses a small set of well-established crates chosen to support an async, networked CLI tool implemented in Rust. Below are the main dependencies and why they were selected.
2925

30-
The project uses **Tokio**, an asynchronous runtime for Rust, to manage networking operations and concurrency. Tokio provides powerful primitives such as `TcpStream`, `TcpListener`, and asynchronous task spawning (`tokio::spawn`), allowing efficient, non-blocking I/O.
26+
#### Tokio
3127

32-
This choice is motivated by several reasons:
28+
Tokio is the async runtime and is central to the project. Reasons for using Tokio include:
3329

34-
1. **Asynchronous I/O efficiency** – Tokio leverages Rust’s `async/await` syntax to handle thousands of simultaneous client connections without blocking threads.
30+
1. **Asynchronous I/O efficiency** – Tokio leverages Rust’s `async/await` syntax to handle many simultaneous client connections without blocking threads.
3531
2. **Task scheduling and runtime** – Tokio includes a lightweight task scheduler that runs asynchronous functions concurrently on a single or multi-threaded runtime.
36-
3. **Ecosystem integration** – Many crates (like `warp`, `hyper`, `reqwest`, `tokio-tungstenite`) are built on top of Tokio, ensuring good compatibility and extensibility.
37-
4. **Fine-grained control** – Tokio allows precise management of I/O events, making it suitable for custom protocol implementations, chunked file transfer, and streaming optimizations.
38-
5. **Performance and safety** – The runtime is highly optimized for low-latency operations, while maintaining Rust’s guarantees of memory safety and thread safety.
32+
3. **Ecosystem integration** – Many crates (like `warp`, `hyper`, `reqwest`, `tokio-tungstenite`) are built on top of Tokio, ensuring compatibility and extensibility.
33+
4. **Performance and safety** – The runtime is optimized for low-latency operations while preserving Rust’s memory- and thread-safety guarantees.
34+
35+
Practical notes for this repo:
36+
37+
- Tokio primitives used: `TcpListener`, `TcpStream`, `tokio::spawn`, `tokio::fs`, and `tokio::sync::mpsc`.
38+
- The code creates an `mpsc::channel::<TcpStream>(1)` in `src/main.rs` and sends accepted `TcpStream`s from the listener to the handler task. This decouples socket acceptance from protocol handling, provides backpressure (buffer size 1), and keeps a clear service boundary between network IO and command processing.
39+
- When changing concurrency or channel buffer sizes, review the places that consume the channel (network handler) and tests that rely on the current backpressure semantics.
40+
41+
#### clap
42+
43+
`clap` (with the `derive` feature) is used for command-line parsing. It provides ergonomic derive-based parsing for flags and subcommands used by the binaries (see `src/cli/main.rs`). Use `clap` to add user-facing options, help text, and subcommands. Keep CLI changes backward-compatible where possible.
44+
45+
#### dotenv
3946

40-
Without Tokio, the implementation would require manually managing threads and blocking I/O, which would be less efficient, harder to scale, and more error-prone.
47+
`dotenv` is used in `src/main.rs` to load local environment variables from a `.env` file during development. The project uses environment variables for configuration keys (see `src/application/config.rs`): `FERRIS_BASE_PATH`, `FERRIS_PORT`, and `FERRIS_HOST`. `Config::from_env()` provides sensible defaults when vars are absent.
4148

42-
## **FerrisShare File Transfer Protocol**
49+
Other dependencies
4350

44-
### **Overview**
51+
- `async-trait` — used to express async traits for domain ports/interfaces implemented by infra repositories.
52+
- `anyhow` — convenience error handling for higher-level paths or tooling code.
53+
54+
If you add dependencies, prefer small, widely-used crates and keep Tokio feature flags minimal to avoid pulling unnecessary code.
55+
56+
### 1.2 **Overview**
4557

4658
The protocol defines a simple, **text-based command layer** over TCP for transferring a single file between two peers on the same network. It relies on TCP for reliable, ordered delivery, while adding **application-level commands** to coordinate the transfer, manage file chunks, and confirm completion. The connection is **bi-directional**, allowing the receiver to respond directly through the same TCP stream.
4759

48-
FerrisShare uses an asynchronous channel (`mpsc::channel`) to transmit accepted TCP connections from the network listener to the handler responsible for processing protocol commands. This mechanism decouples the management of incoming connections from the business logic, ensures synchronization between asynchronous tasks, and guarantees that only one active connection is handled at a time. The channel thus facilitates internal communication and enhances the modularity of the network service.
60+
![FerrisShare protocol flow](./ferrisshare_logigramme_v1.png)
4961

50-
### **Protocol Commands**
62+
#### **Protocol Commands**
5163

5264
| Command | Sender | Arguments | Response | Description |
5365
| ------------------------ | ------ | ------------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ |
@@ -59,9 +71,130 @@ FerrisShare uses an asynchronous channel (`mpsc::channel`) to transmit accepted
5971
| **MISSION-ACCOMPLISHED** | Client || `SUCCESS` / `ERROR` | Marks the end of file transmission. The server verifies that all blocks were received correctly. |
6072
| **BYE-RIS** | Either ||| Gracefully terminates or cancels the transfer. |
6173

62-
### **Notes**
74+
## 2. **High-Level Architecture**
75+
76+
### 2.1 Overview
77+
78+
FerrisShare is organized following a **hexagonal (ports and adapters)** architecture.
79+
The system is divided into three primary layers:
80+
81+
1. **Core Domain (`src/core/domain`)**
82+
Defines the business logic, entities, and service traits (ports).
83+
It is _infrastructure-agnostic_ and models how files are transferred, validated, and finalized.
84+
85+
2. **Application Layer (`src/application`)**
86+
Orchestrates interactions between domain services and infrastructure.
87+
It is responsible for:
88+
89+
- managing runtime state (via `FerrisShareState`),
90+
- loading configuration from the environment (via `main.rs`),
91+
- wiring dependencies and initializing services (via `main.rs`).
92+
93+
3. **Infrastructure Layer (`src/infra`)**
94+
Provides concrete implementations of domain ports, such as:
95+
96+
- file-system repositories (`fs_storage_repository.rs`),
97+
98+
This separation ensures that **business logic remains pure** and testable while the infrastructure can evolve independently (e.g., changing from filesystem to S3 storage would only require a new repository implementing the same trait).
99+
100+
---
101+
102+
## 3. **Runtime Model**
103+
104+
FerrisShare uses a bounded Tokio mpsc channel (mpsc::channel::<TcpStream>(1)) to forward accepted TcpStream connections from the listener task to the network handler. This decouples socket acceptance from protocol processing, provides backpressure (buffer size = 1) so the listener will await when the handler is busy, and enforces sequential handling of active connections. Do not change the channel semantics or buffer size without review — consumers and tests rely on the current backpressure behavior.
105+
106+
### 3.1 Execution Flow
107+
108+
![FerrisShare protocol flow](./ferrisshare_logigramme_v2.png)
109+
110+
---
111+
112+
## 4. **Concurrency Model**
113+
114+
FerrisShare uses Tokio’s cooperative multitasking model:
115+
116+
| Component | Concurrency Mechanism | Description |
117+
| --------------- | ----------------------------- | ------------------------------------------------------------------ |
118+
| Listener | `tokio::spawn` task | Accepts TCP connections asynchronously. |
119+
| Handler | `mpsc::Receiver<TcpStream>` | Sequentially handles active connections (bounded by channel size). |
120+
| File IO | `tokio::fs` | Asynchronous file operations for write and rename. |
121+
| CPU-bound Tasks | `tokio::task::spawn_blocking` | Used for checksum validation or heavy file operations. |
122+
123+
> The bounded channel (`size = 1`) acts as a **backpressure control**, ensuring the runtime does not accept more concurrent transfers than it can safely process.
124+
125+
---
126+
127+
## 5. **Storage Design**
128+
129+
### 5.1 Storage Repository
130+
131+
The **FSStorageRepository** provides a file-based implementation of the `StorageRepository` trait defined in `core/domain/storage/ports.rs`.
132+
133+
Responsibilities:
134+
135+
- Validate and sanitize filenames to prevent directory traversal.
136+
- Create a temporary file with suffix `.ferrisshare`.
137+
- Write incoming blocks asynchronously.
138+
- Rename the file to its final name once all blocks are received.
139+
140+
Error handling is implemented using a domain-level `StorageError` enum, with variants such as:
141+
142+
- `InvalidPath`
143+
- `WriteError`
144+
- `FinalizeError`
145+
- `ChecksumMismatch`
146+
147+
---
148+
149+
## 6. **Error Management**
150+
151+
The architecture distinguishes between **domain errors** and **infrastructure errors**:
152+
153+
| Layer | Error Type | Description |
154+
| ----------- | ------------------------------------ | ------------------------------------------------------ |
155+
| Domain | `StorageError`, `ProtocolError` | Typed errors expressing semantic issues. |
156+
| Infra | `std::io::Error`, `tokio::io::Error` | Low-level I/O or network errors. |
157+
| Application | `anyhow::Error` | Aggregation or propagation wrapper for untyped errors. |
158+
159+
Each boundary maps its errors upward in a controlled way. For example:
160+
161+
```rust
162+
fn write_block(&self, block: YeetBlock) -> Result<(), StorageError>
163+
```
164+
165+
is converted to `anyhow::Error` only at the CLI or handler layer.
166+
167+
---
168+
169+
## 7. Testing Strategy
170+
171+
- Quick protocol smoke-test with netcat:
172+
```bash
173+
nc 127.0.0.1 9000
174+
HELLO test.txt 1024
175+
```
176+
- Recommended manual verification steps (use when iterating implementation-by-feature):
177+
1. Start with the listener and the bounded `mpsc` channel; confirm accepted `TcpStream`s are queued and backpressure occurs when full.
178+
2. Implement protocol command recognition (parser unit tests).
179+
3. Implement responder behavior and verify correct textual responses (`OK`, `NOPE`, `OK-HOUSTEN`).
180+
4. Enforce protocol rules and sequencing in the handler (reject invalid sequences).
181+
5. Execute command handling (dispatch commands to services and capture outcomes).
182+
6. Implement the `FSStorageRepository` and validate filename sanitization.
183+
7. Test reading binary payloads from the stream and async writing to the temp file.
184+
8. Add the CLI path to read a local file and stream its bytes over the connection; verify end-to-end transfer.
185+
9. Implement and test the loop over `YEET` blocks to ensure all bytes are written and blocks are acknowledged.
186+
- For each manual step, codify a corresponding unit or integration test to prevent regressions.
187+
188+
## 8. **Security Considerations**
189+
190+
- All filenames are sanitizedno absolute or relative (`..`) paths allowed.
191+
- Only local-network communication is assumed; for Internet usage, TLS must be added.
192+
- The server rejects transfers when disk space is insufficient or when the file already exists.
193+
- Protocol commands are ASCII-only to prevent injection or encoding ambiguities.
194+
195+
---
196+
197+
## 9. **Conclusion**
63198

64-
- **TCP guarantees delivery**, but `CONFIRM` adds **application-level integrity verification**.
65-
- **File is transferred in blocks (blobs)** to allow streaming of large files without memory overload.
66-
- **Bi-directional communication** is handled over the same TCP connection; no additional socket is needed.
67-
- Protocol is designed to be **minimal, readable, and extensible** for future features (resume, hash verification, multi-file support).
199+
FerrisShare combines Rusts async capabilities with a clean domain-driven design to deliver a lightweight, robust P2P file transfer CLI.
200+
Its modular architecture (domain/application/infra separation), use of Tokio primitives, and simple custom protocol make it easy to extend while ensuring predictable runtime behavior and strong safety guarantees.

docs/ferrisshare_logigramme_v1.png

199 KB
Loading

docs/ferrisshare_logigramme_v2.png

687 KB
Loading

src/core/domain/command/services.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ where
3838
filesize,
3939
} => {
4040
println!("Execute HELLO command.");
41-
let expected_blocks = (*filesize + 1023) / 1024;
41+
let expected_blocks = (*filesize + 1023).div_ceil(1024);
4242
let mut state_guard = state.lock().await;
4343
println!(
4444
"Setting state to Receiving with expected_blocks={}",

src/core/domain/network/entities.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ impl TryFrom<&str> for ProtocolMessage {
9595
Ok(ProtocolMessage::Error(reason))
9696
}
9797
Some("BYE-RIS") => Ok(ProtocolMessage::ByeRis),
98-
_ => return Err(ProtocolError::InvalidCommand),
98+
_ => Err(ProtocolError::InvalidCommand),
9999
}
100100
}
101101
}

src/core/domain/network/services.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ where
5858
) -> Result<(), NetworkError> {
5959
let listener = TcpListener::bind(addr)
6060
.await
61-
.map_err(|e| NetworkError::ListenerBindFailed(e))?;
61+
.map_err(NetworkError::ListenerBindFailed)?;
6262
println!("Listening on {}", addr);
6363

6464
loop {

src/infra/repositories/fs/fs_storage_repository.rs

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,22 +47,17 @@ impl StorageRepository for FSStorageRepository {
4747

4848
async move {
4949
// sanitize
50-
if let Err(e) = FSStorageRepository::sanitize_filename(&filename) {
51-
return Err(e);
52-
}
50+
FSStorageRepository::sanitize_filename(&filename)?;
5351

5452
let path = self.file_path_for(&filename);
5553
// Use a temporary extension during transfer
5654
let part_path = path.with_extension("ferrisshare");
5755

5856
// create parent dirs if needed
5957
if let Some(parent) = path.parent() {
60-
if let Err(e) = tokio::fs::create_dir_all(parent).await {
61-
return Err(StorageError::Unknown(format!(
62-
"Failed to create dir: {}",
63-
e
64-
)));
65-
}
58+
tokio::fs::create_dir_all(parent)
59+
.await
60+
.map_err(|e| StorageError::Unknown(format!("Failed to create dir: {}", e)))?;
6661
}
6762

6863
match tokio::fs::File::create(&part_path).await {
@@ -84,26 +79,22 @@ impl StorageRepository for FSStorageRepository {
8479

8580
async move {
8681
// sanitize
87-
if let Err(e) = FSStorageRepository::sanitize_filename(&filename) {
88-
return Err(e);
89-
}
82+
FSStorageRepository::sanitize_filename(&filename)?;
9083

9184
let path = self.file_path_for(&filename);
9285
// write into a .ferrisshare temporary file while transferring
9386
let part_path = path.with_extension("ferrisshare");
9487

9588
// ensure parent dir exists before open
9689
if let Some(parent) = path.parent() {
97-
if let Err(e) = tokio::fs::create_dir_all(parent).await {
98-
return Err(StorageError::Unknown(format!(
99-
"Failed to create dir: {}",
100-
e
101-
)));
102-
}
90+
tokio::fs::create_dir_all(parent)
91+
.await
92+
.map_err(|e| StorageError::Unknown(format!("Failed to create dir: {}", e)))?;
10393
}
10494

10595
match tokio::fs::OpenOptions::new()
10696
.create(true)
97+
.truncate(false)
10798
.write(true)
10899
.open(&part_path)
109100
.await
@@ -133,9 +124,7 @@ impl StorageRepository for FSStorageRepository {
133124

134125
async move {
135126
// sanitize
136-
if let Err(e) = FSStorageRepository::sanitize_filename(&filename) {
137-
return Err(e);
138-
}
127+
FSStorageRepository::sanitize_filename(&filename)?;
139128

140129
let path = PathBuf::from(&base).join(&filename);
141130
// Rename the .ferrisshare temp file to the final filename

0 commit comments

Comments
 (0)