diff --git a/topics/p2p-transfer-protocol/.gitignore b/topics/p2p-transfer-protocol/.gitignore new file mode 100644 index 0000000..385a1f4 --- /dev/null +++ b/topics/p2p-transfer-protocol/.gitignore @@ -0,0 +1,5 @@ +/target +Cargo.lock +/shared +/testfiles +gamberge.md \ No newline at end of file diff --git a/topics/p2p-transfer-protocol/Cargo.toml b/topics/p2p-transfer-protocol/Cargo.toml new file mode 100644 index 0000000..069cc7e --- /dev/null +++ b/topics/p2p-transfer-protocol/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "peerile" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.51", features = ["derive"] } diff --git a/topics/p2p-transfer-protocol/docs/README.md b/topics/p2p-transfer-protocol/docs/README.md new file mode 100644 index 0000000..c91f839 --- /dev/null +++ b/topics/p2p-transfer-protocol/docs/README.md @@ -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 + diff --git a/topics/p2p-transfer-protocol/src/listener.rs b/topics/p2p-transfer-protocol/src/listener.rs new file mode 100644 index 0000000..f0ea43c --- /dev/null +++ b/topics/p2p-transfer-protocol/src/listener.rs @@ -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... \ No newline at end of file diff --git a/topics/p2p-transfer-protocol/src/main.rs b/topics/p2p-transfer-protocol/src/main.rs new file mode 100644 index 0000000..630ff48 --- /dev/null +++ b/topics/p2p-transfer-protocol/src/main.rs @@ -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 diff --git a/topics/p2p-transfer-protocol/src/protocol.rs b/topics/p2p-transfer-protocol/src/protocol.rs new file mode 100644 index 0000000..dd4b767 --- /dev/null +++ b/topics/p2p-transfer-protocol/src/protocol.rs @@ -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 { + 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 { + 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::() + .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::() + .map_err(|_| "Invalid size in SEND".to_string())?; + Ok(Command::Send { size }) + } + _ => Err(format!("Unknown command: {}", parts[0])), + } + } + + // utils + + pub fn write_to(&self, stream: &mut W) -> io::Result<()> { + stream.write_all(&self.to_bytes())?; + stream.flush() + } + + pub fn read_from(stream: &mut R) -> io::Result { + 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); + } +} \ No newline at end of file diff --git a/topics/p2p-transfer-protocol/src/sender.rs b/topics/p2p-transfer-protocol/src/sender.rs new file mode 100644 index 0000000..569d082 --- /dev/null +++ b/topics/p2p-transfer-protocol/src/sender.rs @@ -0,0 +1,64 @@ +use crate::protocol::Command; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::net::TcpStream; +use std::path::Path; + +pub fn send(file_path: &Path, to_addr: &str, port: u16) -> io::Result<()> { + // verify file exists and get metadata + let file = File::open(file_path)?; + let metadata = file.metadata()?; + let file_size = metadata.len(); + let filename = file_path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file path"))? + .to_string_lossy() + .to_string(); + println!("Connecting to {to_addr}:{port}..."); + let addr = format!("{to_addr}:{port}"); + let mut stream = TcpStream::connect(&addr)?; + println!("Connected!"); + // send HELLO + println!("Proposing file..."); + let hello = Command::Hello { + filename: filename.clone(), + size: file_size, + }; + hello.write_to(&mut stream)?; + // wait for ACK or NACK + let response = Command::read_from(&mut stream)?; + match response { + Command::Ack => { + println!("Peer accepted the file!"); + } + Command::Nack => { + println!("Peer rejected the file transfer..."); + return Ok(()); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Expected ACK or NACK, got {response:?}"), + )); + } + } + // send SEND command + let send = Command::Send { size: file_size }; + println!("Sending file..."); + send.write_to(&mut stream)?; + // send file data + let mut file = File::open(file_path)?; + let mut buffer = [0u8; 8192]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + stream.write_all(&buffer[..n])?; + stream.flush()?; // useless? or can cause problem? idk + } + println!("File sent successfully!"); + Ok(()) +} + +// I don't know what to test here... \ No newline at end of file