From d6f7d951db02c6e46dcef47501c12c811525f02c Mon Sep 17 00:00:00 2001 From: TheDancingDeveloper Date: Wed, 29 Jul 2026 05:35:41 +0000 Subject: [PATCH] feat: add read-only payload storage --- crates/librtbit/src/storage/filesystem/fs.rs | 96 ++++++++++++++- crates/librtbit/src/tests/mod.rs | 1 + .../librtbit/src/tests/read_only_storage.rs | 115 ++++++++++++++++++ crates/rtbit/src/main.rs | 41 ++++++- 4 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 crates/librtbit/src/tests/read_only_storage.rs diff --git a/crates/librtbit/src/storage/filesystem/fs.rs b/crates/librtbit/src/storage/filesystem/fs.rs index a4f9e0d..7feea84 100644 --- a/crates/librtbit/src/storage/filesystem/fs.rs +++ b/crates/librtbit/src/storage/filesystem/fs.rs @@ -17,7 +17,17 @@ use crate::storage::{StorageFactory, TorrentStorage}; use super::opened_file::OpenedFile; #[derive(Default, Clone, Copy)] -pub struct FilesystemStorageFactory {} +pub struct FilesystemStorageFactory { + read_only: bool, +} + +impl FilesystemStorageFactory { + /// Create storage that can verify and seed existing payloads without + /// creating, resizing, writing, or deleting payload files. + pub const fn read_only() -> Self { + Self { read_only: true } + } +} impl StorageFactory for FilesystemStorageFactory { type Storage = FilesystemStorage; @@ -30,6 +40,7 @@ impl StorageFactory for FilesystemStorageFactory { Ok(FilesystemStorage { output_folder: shared.options.output_folder.clone(), opened_files: Default::default(), + read_only: self.read_only, }) } @@ -41,9 +52,17 @@ impl StorageFactory for FilesystemStorageFactory { pub struct FilesystemStorage { pub(super) output_folder: PathBuf, pub(super) opened_files: Vec, + read_only: bool, } impl FilesystemStorage { + fn require_writable(&self) -> anyhow::Result<()> { + if self.read_only { + anyhow::bail!("filesystem storage is read-only") + } + Ok(()) + } + pub(super) fn take_fs(&self) -> anyhow::Result { Ok(Self { opened_files: self @@ -52,6 +71,7 @@ impl FilesystemStorage { .map(|f| f.take_clone()) .collect::>>()?, output_folder: self.output_folder.clone(), + read_only: self.read_only, }) } } @@ -66,6 +86,7 @@ impl TorrentStorage for FilesystemStorage { } fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { + self.require_writable()?; let of = self.opened_files.get(file_id).context("no such file")?; #[cfg(windows)] return of.try_mark_sparse()?.pwrite_all(offset, buf); @@ -79,6 +100,7 @@ impl TorrentStorage for FilesystemStorage { offset: u64, bufs: [IoSlice<'_>; 2], ) -> anyhow::Result { + self.require_writable()?; let of = self.opened_files.get(file_id).context("no such file")?; #[cfg(windows)] return of.try_mark_sparse()?.pwrite_all_vectored(offset, bufs); @@ -87,11 +109,21 @@ impl TorrentStorage for FilesystemStorage { } fn remove_file(&self, _file_id: usize, filename: &Path) -> anyhow::Result<()> { + self.require_writable()?; Ok(std::fs::remove_file(self.output_folder.join(filename))?) } fn ensure_file_length(&self, file_id: usize, len: u64) -> anyhow::Result<()> { let f = &self.opened_files.get(file_id).context("no such file")?; + if self.read_only { + let actual = f.lock_read()?.metadata()?.len(); + if actual != len { + anyhow::bail!( + "filesystem storage is read-only and file length is {actual}, expected {len}" + ) + } + return Ok(()); + } #[cfg(windows)] f.try_mark_sparse()?; Ok(f.lock_read()?.set_len(len)?) @@ -105,10 +137,12 @@ impl TorrentStorage for FilesystemStorage { .map(|f| f.take_clone()) .collect::>>()?, output_folder: self.output_folder.clone(), + read_only: self.read_only, })) } fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { + self.require_writable()?; let path = self.output_folder.join(path); if !path.is_dir() { anyhow::bail!("cannot remove dir: {path:?} is not a directory") @@ -140,6 +174,21 @@ impl TorrentStorage for FilesystemStorage { files.push(OpenedFile::new_dummy()); continue; }; + if self.read_only { + match OpenOptions::new().read(true).open(&full_path) { + Ok(f) => files.push(OpenedFile::new(full_path, f)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + files.push(OpenedFile::new_dummy()); + } + Err(error) => { + return Err(error).with_context(|| { + format!("error opening {full_path:?} in read-only mode") + }); + } + } + continue; + } + std::fs::create_dir_all(full_path.parent().context("bug: no parent")?)?; let f = if shared.options.allow_overwrite { OpenOptions::new() @@ -195,6 +244,7 @@ mod tests { let storage = FilesystemStorage { output_folder: dir.path().to_owned(), opened_files, + read_only: false, }; (storage, dir) } @@ -395,4 +445,48 @@ mod tests { .unwrap(); assert!(sub.exists()); } + + #[test] + fn test_read_only_storage_reads_and_rejects_mutations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload.dat"); + let original = b"existing payload"; + std::fs::write(&path, original).unwrap(); + let file = OpenOptions::new().read(true).open(&path).unwrap(); + let storage = FilesystemStorage { + output_folder: dir.path().to_owned(), + opened_files: vec![OpenedFile::new(path.clone(), file)], + read_only: true, + }; + + let mut buf = vec![0; original.len()]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(buf, original); + storage + .ensure_file_length(0, original.len() as u64) + .unwrap(); + + assert!(storage.pwrite_all(0, 0, b"changed").is_err()); + assert!( + storage + .pwrite_all_vectored(0, 0, [IoSlice::new(b"changed"), IoSlice::new(b" again")],) + .is_err() + ); + assert!(storage.ensure_file_length(0, 1).is_err()); + assert!(storage.remove_file(0, Path::new("payload.dat")).is_err()); + + let empty_dir = dir.path().join("empty"); + std::fs::create_dir(&empty_dir).unwrap(); + assert!( + storage + .remove_directory_if_empty(Path::new("empty")) + .is_err() + ); + + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(empty_dir.exists()); + + let taken = storage.take().unwrap(); + assert!(taken.pwrite_all(0, 0, b"changed").is_err()); + } } diff --git a/crates/librtbit/src/tests/mod.rs b/crates/librtbit/src/tests/mod.rs index f5609ea..59cf180 100644 --- a/crates/librtbit/src/tests/mod.rs +++ b/crates/librtbit/src/tests/mod.rs @@ -3,4 +3,5 @@ mod e2e; mod e2e_another_local_client; mod e2e_stream; mod mock_tracker; +mod read_only_storage; pub mod test_util; diff --git a/crates/librtbit/src/tests/read_only_storage.rs b/crates/librtbit/src/tests/read_only_storage.rs new file mode 100644 index 0000000..a18d16b --- /dev/null +++ b/crates/librtbit/src/tests/read_only_storage.rs @@ -0,0 +1,115 @@ +use std::time::Duration; + +use anyhow::Context; +use tempfile::TempDir; +use tokio::time::timeout; + +use crate::{ + AddTorrent, AddTorrentOptions, CreateTorrentOptions, Session, SessionOptions, create_torrent, + spawn_utils::BlockingSpawner, + storage::{StorageFactoryExt, filesystem::FilesystemStorageFactory}, +}; + +async fn create_read_only_session( + output_dir: &std::path::Path, +) -> anyhow::Result> { + Session::new_with_opts( + output_dir.to_owned(), + SessionOptions { + disable_dht: true, + disable_trackers: true, + disable_local_service_discovery: true, + default_storage_factory: Some(FilesystemStorageFactory::read_only().boxed()), + ..Default::default() + }, + ) + .await +} + +async fn create_single_file_torrent(payload_dir: &std::path::Path) -> anyhow::Result> { + let torrent = create_torrent( + payload_dir, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await?; + Ok(torrent.as_bytes()?.to_vec()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_only_session_verifies_existing_payload_without_changing_it() -> anyhow::Result<()> { + let payload_dir = TempDir::with_prefix("rtbit_read_only_existing")?; + let payload_path = payload_dir.path().join("payload.bin"); + let payload = vec![0x5a; 128 * 1024]; + std::fs::write(&payload_path, &payload)?; + let torrent = create_single_file_torrent(payload_dir.path()).await?; + + let session_dir = TempDir::with_prefix("rtbit_read_only_session")?; + let session = create_read_only_session(session_dir.path()).await?; + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(payload_dir.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await? + .into_handle() + .context("expected a torrent handle")?; + + timeout(Duration::from_secs(10), handle.wait_until_initialized()) + .await + .context("timed out waiting for read-only initialization")??; + + let stats = handle.stats(); + assert!(stats.finished); + assert_eq!(stats.progress_bytes, stats.total_bytes); + assert_eq!(std::fs::read(&payload_path)?, payload); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_only_session_does_not_create_missing_payload() -> anyhow::Result<()> { + let source_dir = TempDir::with_prefix("rtbit_read_only_source")?; + let source_path = source_dir.path().join("payload.bin"); + std::fs::write(&source_path, vec![0xa5; 64 * 1024])?; + let torrent = create_single_file_torrent(source_dir.path()).await?; + + let destination_root = TempDir::with_prefix("rtbit_read_only_missing")?; + let output_dir = destination_root.path().join("missing-output"); + let payload_path = output_dir.join("payload.bin"); + assert!(!output_dir.exists()); + + let session_dir = TempDir::with_prefix("rtbit_read_only_session")?; + let session = create_read_only_session(session_dir.path()).await?; + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output_dir.to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await? + .into_handle() + .context("expected a torrent handle")?; + + timeout(Duration::from_secs(10), handle.wait_until_initialized()) + .await + .context("timed out waiting for read-only initialization")??; + + let stats = handle.stats(); + assert!(!stats.finished); + assert_eq!(stats.progress_bytes, 0); + assert!(!output_dir.exists()); + assert!(!payload_path.exists()); + Ok(()) +} diff --git a/crates/rtbit/src/main.rs b/crates/rtbit/src/main.rs index 887f50d..31a4f1a 100644 --- a/crates/rtbit/src/main.rs +++ b/crates/rtbit/src/main.rs @@ -229,6 +229,15 @@ struct Opts { #[arg(long)] experimental_mmap_storage: bool, + /// Only read existing payload files. This disables payload creation, + /// resizing, writes, and deletion while still allowing verification and seeding. + #[arg( + long = "storage-read-only", + env = "RTBIT_STORAGE_READ_ONLY", + conflicts_with = "experimental_mmap_storage" + )] + storage_read_only: bool, + /// If set will use socks5 proxy for all outgoing connections. /// The format is socks5://[username:password]@host:port /// @@ -669,7 +678,9 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result s } - if opts.experimental_mmap_storage { + if opts.storage_read_only { + wrap(FilesystemStorageFactory::read_only()).boxed() + } else if opts.experimental_mmap_storage { wrap(MmapFilesystemStorageFactory::default()).boxed() } else { wrap(FilesystemStorageFactory::default()).boxed() @@ -1212,6 +1223,34 @@ mod tests { assert_eq!(opts.announce_port, Some(51_234)); } + #[test] + fn read_only_storage_is_initialized_from_cli_flag() { + let opts = Opts::try_parse_from([ + "rtbit", + "--storage-read-only", + "server", + "start", + "/tmp/rtbit-startup-test", + ]) + .unwrap(); + + assert!(opts.storage_read_only); + } + + #[test] + fn read_only_storage_rejects_mmap_storage() { + let result = Opts::try_parse_from([ + "rtbit", + "--storage-read-only", + "--experimental-mmap-storage", + "server", + "start", + "/tmp/rtbit-startup-test", + ]); + + assert!(result.is_err()); + } + #[cfg(not(target_os = "windows"))] #[test] fn test_parse_umask() {