diff --git a/.gitignore b/.gitignore index e7adaa7..8a2b9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target /Cargo.lock /.idea/ -/tests/test_disk.qcow2 +/tests/*.qcow2 diff --git a/README.md b/README.md index 6867493..777c235 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,12 @@ In this example: ### Notes: +- Only Read Request (RRQ) is supported. - If a file exists in both the local directory and the NBD-based filesystem, the **local file takes precedence**. -- The NBD disk is connected lazily on the first read request. - Initial setup of the virtual NBD filesystem takes **1.5 to 3 seconds**, so the first request usually need to be retried automatically by the client. +- The NBD disk is either: + - Connected proactively when config is created to avoid the first read request delay. + - Connected lazily on the first read request. - An inactive NBD disk is automatically disconnected after a period of inactivity. This timeout is configurable via the `idle_timeout` daemon argument. - Supported TFTP options: - timeout diff --git a/src/_tests.rs b/src/_tests.rs deleted file mode 100644 index 67738a1..0000000 --- a/src/_tests.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::any::type_name; -use std::fs::{File, create_dir}; -use std::io::BufRead; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command}; -use std::{env, fs, io, thread, time}; - -const _DATA_PATTERN: &str = "ARBITRARY DATA"; - -pub(super) fn make_payload(size: usize) -> Vec { - let pattern = _DATA_PATTERN.as_bytes(); - pattern.iter().copied().cycle().take(size).collect() -} - -pub(super) fn get_test_data_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests") -} - -pub(super) fn get_test_qcow() -> PathBuf { - get_test_data_dir().join("test_disk.qcow2") -} - -pub(super) fn ensure_prerequisite_disk() { - let lock = _lock_tests_directory().unwrap(); - if !get_test_qcow().exists() { - _create_prerequisite_disk() - } - drop(lock); -} - -fn _ensure_prerequisite_disk() { - if !get_test_qcow().exists() { - let script = get_test_data_dir().join("build_test_qcow_disk.sh"); - let status = Command::new(&script) - .arg(get_test_qcow().as_path()) - .arg(_DATA_PATTERN) - .status() - .expect(format!("{:?} failed", script).as_str()); - if !status.success() { - panic!("{script:?} failed"); - } - } -} - -fn _create_prerequisite_disk() { - let script = get_test_data_dir().join("build_test_qcow_disk.sh"); - let status = Command::new(&script) - .arg(get_test_qcow().as_path()) - .arg(_DATA_PATTERN) - .status() - .expect(format!("{:?} failed", script).as_str()); - if !status.success() { - panic!("{script:?} failed"); - } -} - -fn _lock_tests_directory() -> io::Result { - let opened = File::open(get_test_data_dir())?; - opened.lock()?; - Ok(opened) -} - -pub(super) struct _NBDServerProcess { - process: Child, - url: String, -} - -impl _NBDServerProcess { - pub(super) fn get_url(&self) -> &str { - &self.url - } -} - -impl Drop for _NBDServerProcess { - fn drop(&mut self) { - self.process.kill().unwrap(); - self.process.wait().unwrap(); - } -} - -pub(super) fn run_nbd_server(listen_ip: &str) -> _NBDServerProcess { - let locked_tests_directory = _lock_tests_directory().unwrap(); - if !get_test_qcow().exists() { - _create_prerequisite_disk() - } - let export_name = "disk"; - let test_disk = get_test_qcow().to_string_lossy().to_string(); - let nbd_process = Command::new("qemu-nbd") - .arg(format!("--bind={listen_ip}")) - .arg("--port=0") - .arg(format!("--export-name={export_name}")) - .arg("--read-only") - .arg(test_disk) - .spawn() - .unwrap(); - let listen_port = _get_listen_tcp_port(nbd_process.id()) - .expect(format!("Could not get listener port for {nbd_process:?}").as_str()); - drop(locked_tests_directory); - let nbd_url = String::from(format!("nbd://{listen_ip}:{listen_port}/{export_name}")); - eprintln!("Started NBD server on {nbd_url}"); - _NBDServerProcess { - process: nbd_process, - url: nbd_url, - } -} - -fn _get_listen_tcp_port(pid: u32) -> io::Result { - let inode = _get_single_socket_inode(pid, time::Duration::new(5, 0)) - .expect(format!("Can't find an inode for PID {pid}").as_str()); - _get_tcp_port(inode) -} - -fn _get_tcp_port(socket_inode: u64) -> io::Result { - let path = Path::new("/proc/net/tcp"); - let file = fs::File::open(path)?; - let reader = io::BufReader::new(file); - for (index, line_res) in reader.lines().enumerate() { - let line = line_res?; - if index == 0 { - continue; - } - let fields: Vec<&str> = line.split_whitespace().collect(); - if fields.len() < 10 { - continue; - } - let inode_field = fields[9]; - if inode_field.parse::().ok() != Some(socket_inode) { - continue; - } - let port = match fields[1].split_once(':') { - Some((_hex_ip, hex_port)) => u16::from_str_radix(hex_port, 16).unwrap(), - None => continue, - }; - return Ok(port); - } - Err(io::Error::new( - io::ErrorKind::NotFound, - format!("Can't find TCP socket for inode {socket_inode}"), - )) -} - -fn _get_single_socket_inode(pid: u32, timeout: time::Duration) -> io::Result { - let deadline = time::Instant::now() + timeout; - loop { - let inodes = _get_socket_inodes(pid)?; - match inodes.len() { - 0 => { - if time::Instant::now() > deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - format!("Can't find a socket inode for pid {pid}"), - )); - } - thread::sleep(time::Duration::from_millis(100)); - } - 1 => return Ok(inodes[0]), - _ => { - eprintln!("Found unexpected multiple socket inodes: {:?}", inodes); - if time::Instant::now() > deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - format!("Found unexpected multiple socket inodes: {:?}", inodes), - )); - } - thread::sleep(time::Duration::from_millis(100)); - } - } - } -} - -fn _get_socket_inodes(pid: u32) -> io::Result> { - let mut result = Vec::new(); - for fd_name in _get_fd_symlink_names(pid)? { - if let Some(inode_str) = fd_name.strip_prefix("socket:[") { - if let Some(inode_str) = inode_str.strip_suffix(']') { - if let Ok(inode) = inode_str.parse::() { - result.push(inode); - } - } - } - } - Ok(result) -} - -fn _get_fd_symlink_names(pid: u32) -> io::Result> { - let fd_dir = PathBuf::from(format!("/proc/{pid}/fd")); - let entries = match fs::read_dir(&fd_dir) { - Ok(entries) => entries, - Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("PID {pid} does not exist or is not accessible"), - )); - } - Err(err) => return Err(err), - }; - let mut result = Vec::new(); - for entry in entries { - match fs::read_link(entry?.path()) { - Ok(target) => { - if let Some(name) = target.to_str() { - result.push(name.to_string()); - } - } - Err(err) if err.kind() == io::ErrorKind::NotFound => continue, - Err(err) => return Err(err), - } - } - Ok(result) -} - -fn get_fn_name(_: T) -> &'static str { - type_name::() -} - -pub(super) fn mk_tmp(test_func: T) -> PathBuf { - let test_dir_name = get_fn_name(test_func).replace("::", "_"); - let pid = std::process::id(); - let test_tmp_dir = env::temp_dir().join(format!("rtftp_{pid}_{test_dir_name}")); - create_dir(&test_tmp_dir).unwrap(); - test_tmp_dir -} diff --git a/src/cursor.rs b/src/cursor/mod.rs similarity index 63% rename from src/cursor.rs rename to src/cursor/mod.rs index 5ba2b2d..efc2805 100644 --- a/src/cursor.rs +++ b/src/cursor/mod.rs @@ -1,5 +1,8 @@ use std::fmt::{Display, Formatter}; +#[cfg(test)] +mod tests; + pub(super) struct ReadCursor<'a> { datagram: &'a [u8], index: usize, @@ -107,59 +110,3 @@ impl BufferError { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_ushort() { - let buffer: Vec = vec![0x00, 0x0A, 0x00, 0x00, 0x00, 0xab, 0xcd, 0xef]; - let mut cursor = ReadCursor::new(&buffer); - let result = cursor.extract_ushort(); - assert_eq!(result.unwrap(), 0x0A); - } - - #[test] - fn extract_ushort_not_enough_data() { - let buffer: Vec = vec![0x00, 0x0A, 0xFF]; - let mut cursor = ReadCursor::new(&buffer); - cursor.extract_ushort().unwrap(); - let result = cursor.extract_ushort(); - assert!(matches!(result.unwrap_err(), ParseError::NotEnoughData)); - } - - #[test] - fn extract_string() { - let buffer: Vec = b"Arbitrary_string\x00\x0A".to_vec(); - let mut cursor = ReadCursor::new(&buffer); - let result = cursor.extract_string(); - assert_eq!(result.unwrap(), "Arbitrary_string"); - } - - #[test] - fn extract_string_not_enough_data() { - let buffer: Vec = b"Arbitrary_string\x00".to_vec(); - let mut cursor = ReadCursor::new(&buffer); - let result = cursor.extract_string(); - assert_eq!(result.unwrap(), "Arbitrary_string"); - let error = cursor.extract_string(); - assert!(matches!(error.unwrap_err(), ParseError::NotEnoughData)); - } - - #[test] - fn extract_string_non_utf() { - let buffer: Vec = b"Arbitrary_\xFFstring\x00\x0A".to_vec(); - let mut cursor = ReadCursor::new(&buffer); - let result = cursor.extract_string(); - assert!(matches!(result.unwrap_err(), ParseError::Generic(_))); - } - - #[test] - fn extract_non_terminated_string() { - let buffer: Vec = b"Arbitrary_string".to_vec(); - let mut cursor = ReadCursor::new(&buffer); - let result = cursor.extract_string(); - assert!(matches!(result.unwrap_err(), ParseError::Generic(_))); - } -} diff --git a/src/cursor/tests.rs b/src/cursor/tests.rs new file mode 100644 index 0000000..13395b8 --- /dev/null +++ b/src/cursor/tests.rs @@ -0,0 +1,52 @@ +use super::*; + +#[test] +fn extract_ushort() { + let buffer: Vec = vec![0x00, 0x0A, 0x00, 0x00, 0x00, 0xab, 0xcd, 0xef]; + let mut cursor = ReadCursor::new(&buffer); + let result = cursor.extract_ushort(); + assert_eq!(result.unwrap(), 0x0A); +} + +#[test] +fn extract_ushort_not_enough_data() { + let buffer: Vec = vec![0x00, 0x0A, 0xFF]; + let mut cursor = ReadCursor::new(&buffer); + cursor.extract_ushort().unwrap(); + let result = cursor.extract_ushort(); + assert!(matches!(result.unwrap_err(), ParseError::NotEnoughData)); +} + +#[test] +fn extract_string() { + let buffer: Vec = b"Arbitrary_string\x00\x0A".to_vec(); + let mut cursor = ReadCursor::new(&buffer); + let result = cursor.extract_string(); + assert_eq!(result.unwrap(), "Arbitrary_string"); +} + +#[test] +fn extract_string_not_enough_data() { + let buffer: Vec = b"Arbitrary_string\x00".to_vec(); + let mut cursor = ReadCursor::new(&buffer); + let result = cursor.extract_string(); + assert_eq!(result.unwrap(), "Arbitrary_string"); + let error = cursor.extract_string(); + assert!(matches!(error.unwrap_err(), ParseError::NotEnoughData)); +} + +#[test] +fn extract_string_non_utf() { + let buffer: Vec = b"Arbitrary_\xFFstring\x00\x0A".to_vec(); + let mut cursor = ReadCursor::new(&buffer); + let result = cursor.extract_string(); + assert!(matches!(result.unwrap_err(), ParseError::Generic(_))); +} + +#[test] +fn extract_non_terminated_string() { + let buffer: Vec = b"Arbitrary_string".to_vec(); + let mut cursor = ReadCursor::new(&buffer); + let result = cursor.extract_string(); + assert!(matches!(result.unwrap_err(), ParseError::Generic(_))); +} diff --git a/src/guestfs/tests.rs b/src/guestfs/tests.rs index 6a82b0e..3e0fef1 100644 --- a/src/guestfs/tests.rs +++ b/src/guestfs/tests.rs @@ -1,8 +1,48 @@ use super::*; -use crate::_tests::{ensure_prerequisite_disk, get_test_qcow, make_payload}; -use std::time; +use std::fs::File; +use std::path::PathBuf; +use std::process::Command; +use std::{io, time}; -fn _read_file(guestfs: &GuestFS, path: &str) -> Vec { +const DATA_PATTERN: &str = "ARBITRARY DATA"; + +fn get_test_qcow() -> PathBuf { + get_test_data_dir().join("test_disk_guestfs.qcow2") +} + +fn ensure_prerequisite_disk() -> PathBuf { + let lock = lock_tests_directory().unwrap(); + let qcow_path = get_test_qcow(); + if !qcow_path.exists() { + create_prerequisite_disk() + } + drop(lock); + qcow_path +} + +fn create_prerequisite_disk() { + let script = get_test_data_dir().join("build_test_qcow_disk.sh"); + let status = Command::new(&script) + .arg(get_test_qcow().as_path()) + .arg(DATA_PATTERN) + .status() + .expect(format!("{:?} failed", script).as_str()); + if !status.success() { + panic!("{script:?} failed"); + } +} + +fn lock_tests_directory() -> io::Result { + let opened = File::open(get_test_data_dir())?; + opened.lock()?; + Ok(opened) +} + +fn get_test_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +fn read_file(guestfs: &GuestFS, path: &str) -> Vec { let mut result = vec![]; let mut offset = 0; loop { @@ -17,11 +57,16 @@ fn _read_file(guestfs: &GuestFS, path: &str) -> Vec { result } +fn make_payload(size: usize) -> Vec { + let pattern = DATA_PATTERN.as_bytes(); + pattern.iter().copied().cycle().take(size).collect() +} + #[test] fn test_add_existing_disk() { - ensure_prerequisite_disk(); + let test_disk = ensure_prerequisite_disk(); let guestfs = GuestFS::new(); - let result = guestfs.add_disk(get_test_qcow().to_str().unwrap(), true); + let result = guestfs.add_disk(test_disk.to_str().unwrap(), true); assert!( result.is_ok(), "Expected Ok, got Err: {:?}", @@ -31,7 +76,7 @@ fn test_add_existing_disk() { #[test] fn test_add_non_existing_disk() { - ensure_prerequisite_disk(); + _ = ensure_prerequisite_disk(); let guestfs = GuestFS::new(); let result = guestfs.add_disk("/nonexisting.qcow2", true); assert!(result.is_err(), "Unexpected success received"); @@ -43,9 +88,9 @@ fn test_add_non_existing_disk() { #[test] fn test_open_existing_disk() { - ensure_prerequisite_disk(); + let test_disk = ensure_prerequisite_disk(); let guestfs = GuestFS::new(); - let add_result = guestfs.add_disk(get_test_qcow().to_str().unwrap(), true); + let add_result = guestfs.add_disk(test_disk.to_str().unwrap(), true); assert!( add_result.is_ok(), "Expected Ok, got Err: {:?}", @@ -67,16 +112,14 @@ fn test_open_existing_disk() { #[test] fn test_read_aligned_file() { - ensure_prerequisite_disk(); + let test_disk = ensure_prerequisite_disk(); let guestfs = GuestFS::new(); - guestfs - .add_disk(get_test_qcow().to_str().unwrap(), true) - .unwrap(); + guestfs.add_disk(test_disk.to_str().unwrap(), true).unwrap(); guestfs.launch().unwrap(); guestfs.mount_ro("/dev/sda2", "/").unwrap(); guestfs.mount_ro("/dev/sda1", "/boot").unwrap(); let expected_data = make_payload(4194304); - let actual_data = _read_file(&guestfs, "/boot/aligned.file"); + let actual_data = read_file(&guestfs, "/boot/aligned.file"); assert_eq!( actual_data, expected_data, @@ -88,16 +131,14 @@ fn test_read_aligned_file() { #[test] fn test_read_nonaligned_file() { - ensure_prerequisite_disk(); + let test_disk = ensure_prerequisite_disk(); let guestfs = GuestFS::new(); - guestfs - .add_disk(get_test_qcow().to_str().unwrap(), true) - .unwrap(); + guestfs.add_disk(test_disk.to_str().unwrap(), true).unwrap(); guestfs.launch().unwrap(); guestfs.mount_ro("/dev/sda2", "/").unwrap(); guestfs.mount_ro("/dev/sda1", "/boot").unwrap(); let expected_data = make_payload(4194319); - let actual_data = _read_file(&guestfs, "/boot/nonaligned.file"); + let actual_data = read_file(&guestfs, "/boot/nonaligned.file"); assert_eq!( actual_data, expected_data, diff --git a/src/local_fs.rs b/src/local_fs/mod.rs similarity index 55% rename from src/local_fs.rs rename to src/local_fs/mod.rs index 0cc9453..610b7da 100644 --- a/src/local_fs.rs +++ b/src/local_fs/mod.rs @@ -5,7 +5,10 @@ use std::io; use std::io::{ErrorKind, Read, Seek, SeekFrom}; use std::path::PathBuf; -pub(super) struct LocalOpenedFile { +#[cfg(test)] +mod tests; + +struct LocalOpenedFile { rd: File, display: String, } @@ -86,66 +89,3 @@ impl Display for LocalRoot { write!(f, "", self.path) } } - -#[cfg(test)] -mod test { - use super::*; - use crate::_tests::mk_tmp; - use std::fs::{Permissions, set_permissions}; - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - - #[test] - fn open_non_existent() { - let local_root = LocalRoot { - path: PathBuf::from("/nonexistent"), - }; - let result = local_root.open("nonexistent.file"); - assert_eq!(result.err().unwrap(), FileError::FileNotFound); - } - - #[test] - fn open_access_denied() { - let unreadable_directory = mk_tmp(open_access_denied); - set_permissions(&unreadable_directory, Permissions::from_mode(0o055)).unwrap(); - let local_root = LocalRoot { - path: unreadable_directory, - }; - let result = local_root.open("nonexistent"); - assert_eq!(result.err().unwrap(), FileError::AccessViolation); - } - - #[test] - fn get_size() { - let local_root = LocalRoot { - path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), - }; - let mut result = local_root.open("Cargo.toml").unwrap(); - let size = result.get_size().unwrap(); - assert!(size > 0); - } - - #[test] - fn read() { - let mut buffer = [0u8; 1024]; - let local_root = LocalRoot { - path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), - }; - let mut result = local_root.open("Cargo.toml").unwrap(); - let read_size = result.read_to(&mut buffer).unwrap(); - let string = String::from_utf8(buffer[..read_size].to_vec()).unwrap(); - assert!(string.contains("libc")); - } - - #[test] - fn read_leading_slash() { - let mut buffer = [0u8; 1024]; - let local_root = LocalRoot { - path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), - }; - let mut result = local_root.open("/Cargo.toml").unwrap(); - let read_size = result.read_to(&mut buffer).unwrap(); - let string = String::from_utf8(buffer[..read_size].to_vec()).unwrap(); - assert!(string.contains("libc")); - } -} diff --git a/src/local_fs/tests.rs b/src/local_fs/tests.rs new file mode 100644 index 0000000..38b2ae2 --- /dev/null +++ b/src/local_fs/tests.rs @@ -0,0 +1,72 @@ +use super::*; +use std::any::type_name; +use std::env; +use std::fs::{Permissions, create_dir, set_permissions}; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +fn get_fn_name(_: T) -> &'static str { + type_name::() +} + +fn mk_tmp(test_func: T) -> PathBuf { + let test_dir_name = get_fn_name(test_func).replace("::", "_"); + let pid = std::process::id(); + let test_tmp_dir = env::temp_dir().join(format!("rtftp_{pid}_{test_dir_name}")); + create_dir(&test_tmp_dir).unwrap(); + test_tmp_dir +} + +#[test] +fn open_non_existent() { + let local_root = LocalRoot { + path: PathBuf::from("/nonexistent"), + }; + let result = local_root.open("nonexistent.file"); + assert_eq!(result.err().unwrap(), FileError::FileNotFound); +} + +#[test] +fn open_access_denied() { + let unreadable_directory = mk_tmp(open_access_denied); + set_permissions(&unreadable_directory, Permissions::from_mode(0o055)).unwrap(); + let local_root = LocalRoot { + path: unreadable_directory, + }; + let result = local_root.open("nonexistent"); + assert_eq!(result.err().unwrap(), FileError::AccessViolation); +} + +#[test] +fn get_size() { + let local_root = LocalRoot { + path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + }; + let mut result = local_root.open("Cargo.toml").unwrap(); + let size = result.get_size().unwrap(); + assert!(size > 0); +} + +#[test] +fn read() { + let mut buffer = [0u8; 1024]; + let local_root = LocalRoot { + path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + }; + let mut result = local_root.open("Cargo.toml").unwrap(); + let read_size = result.read_to(&mut buffer).unwrap(); + let string = String::from_utf8(buffer[..read_size].to_vec()).unwrap(); + assert!(string.contains("libc")); +} + +#[test] +fn read_leading_slash() { + let mut buffer = [0u8; 1024]; + let local_root = LocalRoot { + path: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + }; + let mut result = local_root.open("/Cargo.toml").unwrap(); + let read_size = result.read_to(&mut buffer).unwrap(); + let string = String::from_utf8(buffer[..read_size].to_vec()).unwrap(); + assert!(string.contains("libc")); +} diff --git a/src/main.rs b/src/main.rs index 374e5cb..e66a61b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#[cfg(test)] -mod _tests; mod cursor; mod fs; mod fs_watch; diff --git a/src/messages.rs b/src/messages/mod.rs similarity index 82% rename from src/messages.rs rename to src/messages/mod.rs index 01f4e18..22d6834 100644 --- a/src/messages.rs +++ b/src/messages/mod.rs @@ -4,6 +4,9 @@ use std::collections::HashMap; use std::fmt; use std::fmt::{Debug, Display}; +#[cfg(test)] +mod tests; + const RRQ: u16 = 0x01; const ERROR: u16 = 0x05; const OACK: u16 = 0x06; @@ -160,41 +163,3 @@ impl Display for OptionsAcknowledge { write!(f, "OACK: [{display}]") } } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_rrq() { - let filename = "irrelevant.file"; - let binding = vec![ - RRQ.to_be_bytes().to_vec(), - filename.as_bytes().to_vec(), - vec![0x00], - OCTET.as_bytes().to_vec(), - vec![0x00], - ]; - let raw: Vec = binding.iter().flatten().copied().collect(); - let rrq = ReadRequest::parse(&raw); - assert!(rrq.is_ok()); - } - #[test] - fn parse_incomplete_rrq() { - let filename = "irrelevant.file"; - let binding = vec![ - RRQ.to_be_bytes().to_vec(), - filename.as_bytes().to_vec(), - vec![0x00], - ]; - let raw: Vec = binding.iter().flatten().copied().collect(); - let error = ReadRequest::parse(&raw).err().unwrap(); - assert!(error.to_string().contains("Bad format")); - } - - #[test] - fn parse_empty_rrq() { - let error = ReadRequest::parse(&vec![]).err().unwrap(); - assert!(error.to_string().contains("Bad format")); - } -} diff --git a/src/messages/tests.rs b/src/messages/tests.rs new file mode 100644 index 0000000..a2b100f --- /dev/null +++ b/src/messages/tests.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn parse_rrq() { + let filename = "irrelevant.file"; + let binding = vec![ + RRQ.to_be_bytes().to_vec(), + filename.as_bytes().to_vec(), + vec![0x00], + OCTET.as_bytes().to_vec(), + vec![0x00], + ]; + let raw: Vec = binding.iter().flatten().copied().collect(); + let rrq = ReadRequest::parse(&raw); + assert!(rrq.is_ok()); +} +#[test] +fn parse_incomplete_rrq() { + let filename = "irrelevant.file"; + let binding = vec![ + RRQ.to_be_bytes().to_vec(), + filename.as_bytes().to_vec(), + vec![0x00], + ]; + let raw: Vec = binding.iter().flatten().copied().collect(); + let error = ReadRequest::parse(&raw).err().unwrap(); + assert!(error.to_string().contains("Bad format")); +} + +#[test] +fn parse_empty_rrq() { + let error = ReadRequest::parse(&vec![]).err().unwrap(); + assert!(error.to_string().contains("Bad format")); +} diff --git a/src/nbd_disk/tests.rs b/src/nbd_disk/tests.rs index 2ff0002..dc8ae92 100644 --- a/src/nbd_disk/tests.rs +++ b/src/nbd_disk/tests.rs @@ -1,8 +1,13 @@ use super::*; -use crate::_tests::{make_payload, run_nbd_server}; use crate::fs::{FileError, OpenedFile, Root}; use serde_json::json; -use std::time; +use std::fs::File; +use std::io::BufRead; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::{fs, io, thread, time}; + +const DATA_PATTERN: &str = "ARBITRARY DATA"; fn read_file(opened: &mut dyn OpenedFile) -> Vec { let mut buffer = vec![]; @@ -17,6 +22,187 @@ fn read_file(opened: &mut dyn OpenedFile) -> Vec { buffer } +fn make_payload(size: usize) -> Vec { + let pattern = DATA_PATTERN.as_bytes(); + pattern.iter().copied().cycle().take(size).collect() +} + +fn get_test_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +fn get_test_qcow() -> PathBuf { + get_test_data_dir().join("test_disk_nbd.qcow2") +} + +fn create_prerequisite_disk(path: &PathBuf) { + let script = get_test_data_dir().join("build_test_qcow_disk.sh"); + let status = Command::new(&script) + .arg(path.as_path()) + .arg(DATA_PATTERN) + .status() + .expect(format!("{:?} failed", script).as_str()); + if !status.success() { + panic!("{script:?} failed"); + } +} + +fn lock_tests_directory() -> io::Result { + let opened = File::open(get_test_data_dir())?; + opened.lock()?; + Ok(opened) +} + +struct NBDServerProcess { + process: Child, + url: String, +} + +impl NBDServerProcess { + pub(super) fn get_url(&self) -> &str { + &self.url + } +} + +impl Drop for NBDServerProcess { + fn drop(&mut self) { + self.process.kill().unwrap(); + self.process.wait().unwrap(); + } +} + +fn run_nbd_server(listen_ip: &str) -> NBDServerProcess { + let locked_tests_directory = lock_tests_directory().unwrap(); + let disk_path = get_test_qcow(); + if !disk_path.exists() { + create_prerequisite_disk(&disk_path) + } + let export_name = "disk"; + let test_disk = get_test_qcow().to_string_lossy().to_string(); + let nbd_process = Command::new("qemu-nbd") + .arg(format!("--bind={listen_ip}")) + .arg("--port=0") + .arg(format!("--export-name={export_name}")) + .arg("--read-only") + .arg(test_disk) + .spawn() + .unwrap(); + let listen_port = get_listen_tcp_port(nbd_process.id()) + .expect(format!("Could not get listener port for {nbd_process:?}").as_str()); + drop(locked_tests_directory); + let nbd_url = String::from(format!("nbd://{listen_ip}:{listen_port}/{export_name}")); + eprintln!("Started NBD server on {nbd_url}"); + NBDServerProcess { + process: nbd_process, + url: nbd_url, + } +} + +fn get_listen_tcp_port(pid: u32) -> io::Result { + let inode = get_single_socket_inode(pid, time::Duration::new(5, 0)) + .expect(format!("Can't find an inode for PID {pid}").as_str()); + get_tcp_port(inode) +} + +fn get_tcp_port(socket_inode: u64) -> io::Result { + let path = Path::new("/proc/net/tcp"); + let file = fs::File::open(path)?; + let reader = io::BufReader::new(file); + for (index, line_res) in reader.lines().enumerate() { + let line = line_res?; + if index == 0 { + continue; + } + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 10 { + continue; + } + let inode_field = fields[9]; + if inode_field.parse::().ok() != Some(socket_inode) { + continue; + } + let port = match fields[1].split_once(':') { + Some((_hex_ip, hex_port)) => u16::from_str_radix(hex_port, 16).unwrap(), + None => continue, + }; + return Ok(port); + } + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Can't find TCP socket for inode {socket_inode}"), + )) +} + +fn get_single_socket_inode(pid: u32, timeout: time::Duration) -> io::Result { + let deadline = time::Instant::now() + timeout; + loop { + let inodes = get_socket_inodes(pid)?; + match inodes.len() { + 0 => { + if time::Instant::now() > deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("Can't find a socket inode for pid {pid}"), + )); + } + thread::sleep(time::Duration::from_millis(100)); + } + 1 => return Ok(inodes[0]), + _ => { + eprintln!("Found unexpected multiple socket inodes: {:?}", inodes); + if time::Instant::now() > deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("Found unexpected multiple socket inodes: {:?}", inodes), + )); + } + thread::sleep(time::Duration::from_millis(100)); + } + } + } +} + +fn get_socket_inodes(pid: u32) -> io::Result> { + let mut result = Vec::new(); + for fd_name in get_fd_symlink_names(pid)? { + if let Some(inode_str) = fd_name.strip_prefix("socket:[") { + if let Some(inode_str) = inode_str.strip_suffix(']') { + if let Ok(inode) = inode_str.parse::() { + result.push(inode); + } + } + } + } + Ok(result) +} + +fn get_fd_symlink_names(pid: u32) -> io::Result> { + let fd_dir = PathBuf::from(format!("/proc/{pid}/fd")); + let entries = match fs::read_dir(&fd_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("PID {pid} does not exist or is not accessible"), + )); + } + Err(err) => return Err(err), + }; + let mut result = Vec::new(); + for entry in entries { + match fs::read_link(entry?.path()) { + Ok(target) => { + if let Some(name) = target.to_str() { + result.push(name.to_string()); + } + } + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => return Err(err), + } + } + Ok(result) +} + #[test] fn test_add_nbd_disk() { let nbd_process = run_nbd_server("127.0.0.2"); diff --git a/src/options.rs b/src/options/mod.rs similarity index 68% rename from src/options.rs rename to src/options/mod.rs index 5b2bd08..e994076 100644 --- a/src/options.rs +++ b/src/options/mod.rs @@ -5,6 +5,9 @@ use std::fmt::Display; use std::time::Duration; use tokio::time::timeout; +#[cfg(test)] +mod tests; + static TSIZE: &str = "tsize"; static TIMEOUT: &str = "timeout"; @@ -123,52 +126,3 @@ impl TSize { (String::from(TSIZE), self.file_size.to_string()) } } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn find_block_size() { - let mut options = HashMap::new(); - options.insert("blksize".to_string(), "1468".to_string()); - let blk_size = Blksize::find_in(&options).unwrap(); - assert_eq!(blk_size.block_size, 1468); - assert_eq!( - blk_size.as_key_pair(), - (BLKSIZE.to_string(), "1468".to_string()) - ); - } - - #[test] - fn find_tsize() { - let mut options = HashMap::new(); - options.insert("tsize".to_string(), "0".to_string()); - assert!(TSize::is_requested(&options)); - } - - #[test] - fn find_timeout() { - let mut options = HashMap::new(); - let timeout_value: usize = 10; - options.insert("timeout".to_string(), timeout_value.to_string()); - let timeout = AckTimeout::find_in(&options).unwrap(); - assert_eq!(timeout.timeout, timeout_value); - } - - #[test] - fn test_timeout_cap() { - let mut options = HashMap::new(); - options.insert("timeout".to_string(), (ACK_TIMEOUT_LIMIT + 1).to_string()); - let find_result = AckTimeout::find_in(&options); - assert!(find_result.is_none()); - } - - #[test] - fn test_block_size_cap() { - let mut options = HashMap::new(); - options.insert("blksize".to_string(), (BLOCK_SIZE_LIMIT + 1).to_string()); - let find_result = Blksize::find_in(&options); - assert!(find_result.is_none()); - } -} diff --git a/src/options/tests.rs b/src/options/tests.rs new file mode 100644 index 0000000..f5c27d1 --- /dev/null +++ b/src/options/tests.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn find_block_size() { + let mut options = HashMap::new(); + options.insert("blksize".to_string(), "1468".to_string()); + let blk_size = Blksize::find_in(&options).unwrap(); + assert_eq!(blk_size.block_size, 1468); + assert_eq!( + blk_size.as_key_pair(), + (BLKSIZE.to_string(), "1468".to_string()) + ); +} + +#[test] +fn find_tsize() { + let mut options = HashMap::new(); + options.insert("tsize".to_string(), "0".to_string()); + assert!(TSize::is_requested(&options)); +} + +#[test] +fn find_timeout() { + let mut options = HashMap::new(); + let timeout_value: usize = 10; + options.insert("timeout".to_string(), timeout_value.to_string()); + let timeout = AckTimeout::find_in(&options).unwrap(); + assert_eq!(timeout.timeout, timeout_value); +} + +#[test] +fn test_timeout_cap() { + let mut options = HashMap::new(); + options.insert("timeout".to_string(), (ACK_TIMEOUT_LIMIT + 1).to_string()); + let find_result = AckTimeout::find_in(&options); + assert!(find_result.is_none()); +} + +#[test] +fn test_block_size_cap() { + let mut options = HashMap::new(); + options.insert("blksize".to_string(), (BLOCK_SIZE_LIMIT + 1).to_string()); + let find_result = Blksize::find_in(&options); + assert!(find_result.is_none()); +}