Skip to content
Merged
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
96 changes: 95 additions & 1 deletion crates/librtbit/src/storage/filesystem/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
})
}

Expand All @@ -41,9 +52,17 @@ impl StorageFactory for FilesystemStorageFactory {
pub struct FilesystemStorage {
pub(super) output_folder: PathBuf,
pub(super) opened_files: Vec<OpenedFile>,
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<Self> {
Ok(Self {
opened_files: self
Expand All @@ -52,6 +71,7 @@ impl FilesystemStorage {
.map(|f| f.take_clone())
.collect::<anyhow::Result<Vec<_>>>()?,
output_folder: self.output_folder.clone(),
read_only: self.read_only,
})
}
}
Expand All @@ -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);
Expand All @@ -79,6 +100,7 @@ impl TorrentStorage for FilesystemStorage {
offset: u64,
bufs: [IoSlice<'_>; 2],
) -> anyhow::Result<usize> {
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);
Expand All @@ -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)?)
Expand All @@ -105,10 +137,12 @@ impl TorrentStorage for FilesystemStorage {
.map(|f| f.take_clone())
.collect::<anyhow::Result<Vec<_>>>()?,
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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -195,6 +244,7 @@ mod tests {
let storage = FilesystemStorage {
output_folder: dir.path().to_owned(),
opened_files,
read_only: false,
};
(storage, dir)
}
Expand Down Expand Up @@ -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());
}
}
1 change: 1 addition & 0 deletions crates/librtbit/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
115 changes: 115 additions & 0 deletions crates/librtbit/src/tests/read_only_storage.rs
Original file line number Diff line number Diff line change
@@ -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<std::sync::Arc<Session>> {
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<Vec<u8>> {
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(())
}
41 changes: 40 additions & 1 deletion crates/rtbit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down
Loading