Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions topics/p2p-transfer-protocol/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/target
Cargo.lock
/shared
/testfiles
gamberge.md
7 changes: 7 additions & 0 deletions topics/p2p-transfer-protocol/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[package]
name = "peerile"
version = "0.1.0"
edition = "2024"

[dependencies]
clap = { version = "4.5.51", features = ["derive"] }
59 changes: 59 additions & 0 deletions topics/p2p-transfer-protocol/docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# P2P Transfer Protocol (Peerile)
## What's this?
This is a command-line tool for transferring files between two machines on the same network, without a central server. The tool can function as both sender and receiver.

## Architecture
### Project Structure
The project is organized into 4 modules:
- `protocol.rs`: Communication protocol definition, where all protocol commands are declared and defined for "peer-to-peer" communication.
- `listener.rs`: File reception management, it listens for incoming connections and handle them.
- `sender.rs`: File sending management, it tries to connect to a listener in order to send a file.
- `main.rs`: Entry point & command-line interface declaration

### Communication Protocol
The protocol uses four commands:
1. **HELLO filename size**: The sender proposes a file to the receiver
- `filename`: name of the file to transfer
- `size`: file size in bytes
2. **ACK**: The receiver accepts the proposed file
3. **NACK**: The receiver refuses the proposed file
4. **SEND size**: The sender starts sending the file
- `size`: must match the size announced in HELLO

All commands end with a newline character (`\n`) which serves as a delimiter for the program.

## How it works?
### Receiver Side (listener)
1. The receiver listens on a specified port (or by default 9000)
2. For each incoming connection, a new thread is created
3. The receiver waits for a HELLO command indicating a filename and its size
4. If the file doesn't already exist, it sends ACK, otherwise NACK
5. It waits for the SEND command with the corresponding size
6. It receives the file data and writes it to the output directory

### Sender Side
1. The sender connects to the receiver's IP address and port
2. It sends a HELLO command with the file name and size
3. It waits for the receiver's response (ACK or NACK)
4. If ACK is received, it sends the SEND command followed by the file data
5. If NACK is received, the transfer is cancelled

## Usage
### Receiving a File
> Command examples use `peerile`, but you can use `cargo run --` instead. (the `--` is important!)
```bash
peerile listen --port 9000 --output ./shared
```
- `--port | -p`: Port on which the program will listen for incoming file transfers
- `--output | -o`: destination for received files

### Sending a File

```bash
peerile send --file document.pdf --to 192.168.1.100 --port 9000
```

- `--file | -f`: Path to the file to send
- `--to | -t`: Receiver's IP address
- `--port | -p`: Port on which the receiver is listening

123 changes: 123 additions & 0 deletions topics/p2p-transfer-protocol/src/listener.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use crate::protocol::Command;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::thread;

pub fn listen(port: u16, output_dir: PathBuf) -> io::Result<()> {
// output directory exists?
if !output_dir.exists() {
fs::create_dir_all(&output_dir)?;
}
let addr = format!("0.0.0.0:{port}");
let listener = TcpListener::bind(&addr)?;
println!("Listening on port {port} for incoming transfers");
println!("Files will be saved to: {}", output_dir.display());
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let output_dir = output_dir.clone();
// spawn a new thread for each connection
thread::spawn(move || {
if let Err(e) = handle_connection(stream, output_dir) {
println!("Error handling connection: {e}");
}
});
}
Err(e) => {
println!("Error accepting connection: {e}");
}
}
}
Ok(())
}

fn handle_connection(mut stream: TcpStream, output_dir: PathBuf) -> io::Result<()> {
let peer_addr = stream.peer_addr()?;
println!("\nNew connection from {peer_addr} established");
// read HELLO command
let hello = Command::read_from(&mut stream)?;
let (filename, expected_size) = match hello {
Command::Hello { filename, size } => {
println!(
"Received HELLO: file='{filename}', size={size} bytes"
);
(filename, size)
}
_ => {
println!("Expected HELLO from {peer_addr}, got {hello:?}");
Command::Nack.write_to(&mut stream)?;
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Expected HELLO command",
));
}
};

// validate filename (prevent path traversal)
// (asked AI for this)
let safe_filename = Path::new(&filename).file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid filename"))?;
let output_path = output_dir.join(safe_filename);
// check if file already exists
if output_path.exists() {
println!(
"File already exists: {}. Rejecting file.",
output_path.display()
);
Command::Nack.write_to(&mut stream)?;
return Ok(());
}
// send ACK
println!("Accepting file transfer from {peer_addr}");
Command::Ack.write_to(&mut stream)?;
// dead SEND command
let send = Command::read_from(&mut stream)?;
let send_size = match send {
Command::Send { size } => {
size
}
_ => {
println!("Expected SEND from {peer_addr}, got {send:?}");
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Expected SEND command",
));
}
};
// verify size matches HELLO
if send_size != expected_size {
println!(
"Size mismatch for file from {peer_addr} : HELLO={expected_size}, SEND={send_size}"
);
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Size mismatch between HELLO and SEND",
));
}
// receive file
println!("Receiving file from {peer_addr}...");
let mut file = File::create(&output_path)?;
let mut remaining_bytes = send_size;
let mut buffer = [0u8; 8192];
while remaining_bytes > 0 {
let to_read = buffer.len();
let n = stream.read(&mut buffer[..to_read])?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"Connection closed before file transfer completed",
));
}
file.write_all(&buffer[..n])?;
file.flush()?;
remaining_bytes -= n as u64;
}
println!(
"File received successfully: {}",
output_path.display()
);
Ok(())
}

// I don't know what I can test...
53 changes: 53 additions & 0 deletions topics/p2p-transfer-protocol/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
mod protocol;
mod listener;
mod sender;

use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::process;

#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: Commands,
}

#[derive(Subcommand)]
enum Commands {
Listen {
#[arg(short, long, default_value = "9000")]
port: u16,

#[arg(short, long, default_value = "./shared")]
output: PathBuf,
},

Send {
#[arg(short, long)]
file: PathBuf,

#[arg(short, long)]
to: String, // listener address

#[arg(short, long, default_value = "9000")]
port: u16,
},
}

fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Listen { port, output } => {
listener::listen(port, output)
}
Commands::Send { file, to, port } => {
sender::send(&file, &to, port)
}
};
if let Err(e) = result {
println!("oops, {e}");
process::exit(1);
}
}

//No test to make here
135 changes: 135 additions & 0 deletions topics/p2p-transfer-protocol/src/protocol.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use std::io::{self, Read, Write};

/// Protocol commands for P2P file transfer
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Hello { filename: String, size: u64 }, // had to add filename because output is a directory, and there can be multiple senders for the same listener
Ack,
Nack,
Send { size: u64 },
}

impl Command {
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Command::Hello { filename, size } => {
format!("HELLO {filename} {size}\n").into_bytes()
}
Command::Ack => {
"ACK\n".to_string().into_bytes()
}
Command::Nack => {
"NACK\n".to_string().into_bytes()
}
Command::Send { size } => {
format!("SEND {size}\n").into_bytes()
}
}
}

pub fn parse(command_str: &str) -> Result<Command, String> {
let s = command_str.trim();
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.is_empty() {
return Err("huh".to_string());
}
match parts[0] {
"HELLO" => {
if parts.len() != 3 {
return Err("HELLO requires filename and size".to_string());
}
let filename = parts[1].to_string();
let size = parts[2]
.parse::<u64>()
.map_err(|_| "Invalid size in HELLO".to_string())?;
Ok(Command::Hello { filename, size })
}
"ACK" => Ok(Command::Ack),
"NACK" => Ok(Command::Nack),
"SEND" => {
if parts.len() != 2 {
return Err("SEND requires size".to_string());
}
let size = parts[1]
.parse::<u64>()
.map_err(|_| "Invalid size in SEND".to_string())?;
Ok(Command::Send { size })
}
_ => Err(format!("Unknown command: {}", parts[0])),
}
}

// utils

pub fn write_to<W: Write>(&self, stream: &mut W) -> io::Result<()> {
stream.write_all(&self.to_bytes())?;
stream.flush()
}

pub fn read_from<R: Read>(stream: &mut R) -> io::Result<Command> {
let mut buffer = Vec::new();
let mut byte = [0u8; 1];
// read until newline
loop {
stream.read_exact(&mut byte)?;
if byte[0] == b'\n' {
// end of command
break;
}
buffer.push(byte[0]);
}
let command_str = String::from_utf8(buffer)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Command::parse(&command_str)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;

#[test]
fn test_parse_all_commands() {
assert_eq!(Command::parse("ACK").unwrap(), Command::Ack);
assert_eq!(Command::parse("NACK").unwrap(), Command::Nack);
assert_eq!(
Command::parse("HELLO test.txt 1024").unwrap(),
Command::Hello {
filename: "test.txt".to_string(),
size: 1024
}
);
assert_eq!(
Command::parse("SEND 2048").unwrap(),
Command::Send { size: 2048 }
);
}

#[test]
fn test_parse_errors() {
assert!(Command::parse("").is_err());
assert!(Command::parse("INVALID").is_err());
assert!(Command::parse("HELLO file.txt").is_err());
assert!(Command::parse("HELLO file.txt abc").is_err());
assert!(Command::parse("SEND").is_err());
assert!(Command::parse("SEND xyz").is_err());
}

#[test]
fn test_read_write() {
let cmd = Command::Hello {
filename: "test.bin".to_string(),
size: 512,
};

let mut buffer = Vec::new();
cmd.write_to(&mut buffer).unwrap();

let mut cursor = Cursor::new(buffer);
let read_cmd = Command::read_from(&mut cursor).unwrap();

assert_eq!(cmd, read_cmd);
}
}
Loading