use parking_lot::Mutex;
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use irontide_core::Lengths;
use serde::{Deserialize, Serialize};
use crate::Result;
use crate::error::Error;
use crate::file_map::FileMap;
use crate::storage::TorrentStorage;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum PreallocateMode {
#[default]
None,
Sparse,
Full,
}
impl From<bool> for PreallocateMode {
fn from(preallocate: bool) -> Self {
if preallocate { Self::Full } else { Self::None }
}
}
pub struct FilesystemStorage {
base_dir: PathBuf,
file_paths: Vec<PathBuf>,
files: Vec<Mutex<Option<File>>>,
file_map: FileMap,
lengths: Lengths,
direct_io: bool,
}
impl FilesystemStorage {
#[allow(clippy::needless_pass_by_value, reason = "pub API stability")]
pub fn new(
base_dir: &Path,
file_paths: Vec<PathBuf>,
file_lengths: Vec<u64>,
lengths: Lengths,
file_priorities: Option<&[irontide_core::FilePriority]>,
mode: PreallocateMode,
direct_io: bool,
) -> Result<Self> {
let file_map = FileMap::new(file_lengths.clone(), lengths.clone());
for (i, path) in file_paths.iter().enumerate() {
if let Some(priorities) = file_priorities
&& priorities.get(i).copied() == Some(irontide_core::FilePriority::Skip)
{
continue;
}
let full = base_dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
let f = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&full)?;
Self::preallocate_file(&f, file_lengths[i], mode)?;
}
let files = (0..file_paths.len()).map(|_| Mutex::new(None)).collect();
Ok(Self {
base_dir: base_dir.to_owned(),
file_paths,
files,
file_map,
lengths,
direct_io,
})
}
fn preallocate_file(f: &File, length: u64, mode: PreallocateMode) -> Result<()> {
match mode {
PreallocateMode::None => {
f.set_len(length)?;
}
PreallocateMode::Sparse => {
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
{
use std::os::unix::io::AsRawFd;
#[allow(
clippy::cast_possible_wrap,
reason = "off_t == i64 on 64-bit; length capped by realistic filesystem limits"
)]
let ret = unsafe {
libc::fallocate(
f.as_raw_fd(),
libc::FALLOC_FL_KEEP_SIZE,
0,
length as libc::off_t,
)
};
if ret == 0 {
f.set_len(length)?;
return Ok(());
}
}
f.set_len(length)?;
}
PreallocateMode::Full => {
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
{
use std::os::unix::io::AsRawFd;
#[allow(
clippy::cast_possible_wrap,
reason = "off_t == i64 on 64-bit; length capped by realistic filesystem limits"
)]
let ret =
unsafe { libc::fallocate(f.as_raw_fd(), 0, 0, length as libc::off_t) };
if ret == 0 {
return Ok(());
}
}
f.set_len(length)?;
if length > 0 {
#[allow(
clippy::cast_possible_truncation,
reason = "chunk_size = min(65536, length) ≤ 65536, fits usize on every supported target"
)]
let chunk_size = u64::min(65536, length) as usize;
let zeros = vec![0u8; chunk_size];
let mut writer = std::io::BufWriter::new(f);
let mut remaining = length;
while remaining > 0 {
#[allow(
clippy::cast_possible_truncation,
reason = "step ≤ chunk_size ≤ 65536, fits usize on every supported target"
)]
let n = u64::min(remaining, chunk_size as u64) as usize;
writer.write_all(&zeros[..n])?;
remaining -= n as u64;
}
}
}
}
Ok(())
}
fn open_file(&self, index: usize) -> Result<parking_lot::MutexGuard<'_, Option<File>>> {
let mut guard = self.files[index].lock();
if guard.is_none() {
let full = self.base_dir.join(&self.file_paths[index]);
let mut opts = File::options();
opts.read(true).write(true);
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
if self.direct_io {
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_DIRECT);
}
let f = opts.open(&full)?;
#[cfg(target_os = "macos")]
if self.direct_io {
use std::os::unix::io::AsRawFd;
unsafe {
libc::fcntl(f.as_raw_fd(), libc::F_NOCACHE, 1);
}
}
*guard = Some(f);
}
Ok(guard)
}
}
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
fn pwritev_all(
fd: std::os::unix::io::RawFd,
bufs: &[std::io::IoSlice<'_>],
offset: u64,
) -> std::io::Result<()> {
let total: usize = bufs.iter().map(|b| b.len()).sum();
if total == 0 {
return Ok(());
}
let mut written = 0usize;
loop {
let mut iov: smallvec::SmallVec<[std::io::IoSlice<'_>; 2]> = smallvec::SmallVec::new();
let mut to_skip = written;
for buf in bufs {
let slice: &[u8] = buf;
if to_skip >= slice.len() {
to_skip -= slice.len();
continue;
}
iov.push(std::io::IoSlice::new(&slice[to_skip..]));
to_skip = 0;
}
let ret = unsafe {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "iov.len() ≤ 2 (SmallVec<[_; 2]> at call site); off_t == i64 on 64-bit"
)]
libc::pwritev(
fd,
iov.as_ptr().cast::<libc::iovec>(),
iov.len() as libc::c_int,
(offset + written as u64) as libc::off_t,
)
};
if ret < 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(err);
}
#[allow(
clippy::cast_sign_loss,
reason = "ret < 0 short-circuits above; ret here is non-negative"
)]
let advance = ret as usize;
written += advance;
if written >= total {
return Ok(());
}
}
}
impl TorrentStorage for FilesystemStorage {
fn write_chunk(&self, piece: u32, begin: u32, data: &[u8]) -> Result<()> {
#[allow(
clippy::cast_possible_truncation,
reason = "data.len() ≤ piece_length ≤ 4 GiB (bounded by Lengths::piece_length: u32 by construction)"
)]
let segments = self
.file_map
.chunk_segments(piece, begin, data.len() as u32);
let mut written = 0usize;
for seg in &segments {
let mut guard = self.open_file(seg.file_index)?;
let f = guard.as_mut().unwrap();
f.seek(SeekFrom::Start(seg.file_offset))?;
f.write_all(&data[written..written + seg.len as usize])?;
written += seg.len as usize;
}
Ok(())
}
fn write_chunk_vectored(&self, piece: u32, begin: u32, s0: &[u8], s1: &[u8]) -> Result<()> {
let total_len = s0.len() + s1.len();
#[allow(
clippy::cast_possible_truncation,
reason = "total_len ≤ piece_length ≤ 4 GiB (bounded by Lengths::piece_length: u32 by construction)"
)]
let segments = self.file_map.chunk_segments(piece, begin, total_len as u32);
let mut pos = 0usize;
for seg in &segments {
let seg_len = seg.len as usize;
let seg_end = pos + seg_len;
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
{
use std::io::IoSlice;
use std::os::unix::io::AsRawFd;
let guard = self.open_file(seg.file_index)?;
let f = guard.as_ref().unwrap();
let fd = f.as_raw_fd();
if seg_end <= s0.len() {
let bufs = [IoSlice::new(&s0[pos..seg_end])];
pwritev_all(fd, &bufs, seg.file_offset)?;
} else if pos >= s0.len() {
let s1_start = pos - s0.len();
let s1_end = seg_end - s0.len();
let bufs = [IoSlice::new(&s1[s1_start..s1_end])];
pwritev_all(fd, &bufs, seg.file_offset)?;
} else {
let from_s0 = &s0[pos..];
let s1_need = seg_len - from_s0.len();
let bufs = [IoSlice::new(from_s0), IoSlice::new(&s1[..s1_need])];
pwritev_all(fd, &bufs, seg.file_offset)?;
}
}
#[cfg(not(all(target_os = "linux", target_pointer_width = "64")))]
{
let mut guard = self.open_file(seg.file_index)?;
let f = guard.as_mut().unwrap();
f.seek(SeekFrom::Start(seg.file_offset))?;
if seg_end <= s0.len() {
f.write_all(&s0[pos..seg_end])?;
} else if pos >= s0.len() {
let s1_start = pos - s0.len();
let s1_end = seg_end - s0.len();
f.write_all(&s1[s1_start..s1_end])?;
} else {
let from_s0 = &s0[pos..];
let s1_need = seg_len - from_s0.len();
f.write_all(from_s0)?;
f.write_all(&s1[..s1_need])?;
}
}
pos = seg_end;
}
Ok(())
}
fn read_chunk(&self, piece: u32, begin: u32, length: u32) -> Result<Vec<u8>> {
let segments = self.file_map.chunk_segments(piece, begin, length);
let mut buf = vec![0u8; length as usize];
let mut offset = 0usize;
for seg in &segments {
let mut guard = self.open_file(seg.file_index)?;
let f = guard.as_mut().unwrap();
f.seek(SeekFrom::Start(seg.file_offset))?;
f.read_exact(&mut buf[offset..offset + seg.len as usize])?;
offset += seg.len as usize;
}
Ok(buf)
}
fn read_piece(&self, piece: u32) -> Result<Vec<u8>> {
let piece_size = self.lengths.piece_size(piece);
if piece_size == 0 {
return Err(Error::PieceOutOfRange {
index: piece,
num_pieces: self.lengths.num_pieces(),
});
}
self.read_chunk(piece, 0, piece_size)
}
fn verify_piece(&self, piece: u32, expected: &irontide_core::Id20) -> Result<bool> {
let piece_size = self.lengths.piece_size(piece);
if piece_size == 0 {
return Err(Error::PieceOutOfRange {
index: piece,
num_pieces: self.lengths.num_pieces(),
});
}
let segments = self.file_map.piece_segments(piece);
let buf_size = (piece_size as usize).min(65536);
let mut buf = vec![0u8; buf_size];
let mut hasher = irontide_core::Sha1Hasher::new();
for seg in &segments {
let mut guard = self.open_file(seg.file_index)?;
let f = guard.as_mut().unwrap();
f.seek(SeekFrom::Start(seg.file_offset))?;
let mut remaining = seg.len as usize;
while remaining > 0 {
let n = remaining.min(buf_size);
f.read_exact(&mut buf[..n])?;
hasher.update(&buf[..n]);
remaining -= n;
}
}
let actual = hasher.finish();
Ok(actual == *expected)
}
fn filesystem_info(
&self,
) -> Option<(
&std::path::Path,
&[std::path::PathBuf],
&crate::file_map::FileMap,
)> {
Some((&self.base_dir, &self.file_paths, &self.file_map))
}
}
#[allow(clippy::needless_pass_by_value)] pub fn delete_torrent_files_sync(download_dir: PathBuf, file_paths: Vec<PathBuf>) {
use tracing::{error, warn};
for rel in &file_paths {
let full = download_dir.join(rel);
match std::fs::remove_file(&full) {
Ok(()) => {
prune_empty_parents(&download_dir, &full);
}
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => {
}
std::io::ErrorKind::PermissionDenied => {
warn!(
path = %full.display(),
error = %e,
"permission denied removing torrent file — continuing"
);
}
_ => {
let raw = e.raw_os_error();
if raw == Some(libc_ebusy()) {
warn!(
path = %full.display(),
error = %e,
"torrent file is busy — skipping"
);
} else {
error!(
path = %full.display(),
error = %e,
"failed to remove torrent file"
);
}
}
},
}
}
}
fn prune_empty_parents(download_dir: &Path, file_path: &Path) {
let Some(mut cursor) = file_path.parent() else {
return;
};
loop {
if cursor == download_dir {
break;
}
if !cursor.starts_with(download_dir) {
break;
}
match std::fs::remove_dir(cursor) {
Ok(()) => {
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
Err(_) => break,
}
}
}
#[inline]
fn libc_ebusy() -> i32 {
#[cfg(unix)]
{
libc::EBUSY
}
#[cfg(not(unix))]
{
-1
}
}
#[cfg(test)]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
reason = "test code — fixtures use small bounded sizes that fit narrower types"
)]
mod tests {
use std::sync::Arc;
use std::thread;
use irontide_core::{Id20, Lengths};
use super::*;
use crate::filesystem::PreallocateMode;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("torrent-test-{}", std::process::id()))
.join(name);
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn single_file_write_read() {
let dir = temp_dir("single");
let lengths = Lengths::new(100, 50, 25);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![42u8; 25];
s.write_chunk(0, 0, &data).unwrap();
let read = s.read_chunk(0, 0, 25).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn multi_file_write_read() {
let dir = temp_dir("multi");
let lengths = Lengths::new(200, 150, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("a.bin"), PathBuf::from("b.bin")],
vec![100, 100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data: Vec<u8> = (0..100).collect();
s.write_chunk(0, 50, &data).unwrap();
let read = s.read_chunk(0, 50, 100).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_piece() {
let dir = temp_dir("readpiece");
let lengths = Lengths::new(100, 50, 25);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![7u8; 50];
s.write_chunk(0, 0, &data).unwrap();
let piece = s.read_piece(0).unwrap();
assert_eq!(piece, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn verify_piece() {
let dir = temp_dir("verify");
let lengths = Lengths::new(100, 50, 25);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![9u8; 50];
s.write_chunk(0, 0, &data).unwrap();
let expected = irontide_core::sha1(&data);
assert!(s.verify_piece(0, &expected).unwrap());
assert!(!s.verify_piece(0, &Id20::ZERO).unwrap());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn creates_directories() {
let dir = temp_dir("dirs");
let lengths = Lengths::new(100, 100, 16384);
let _s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("sub/dir/test.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
assert!(dir.join("sub/dir/test.bin").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn creates_sparse_files() {
let dir = temp_dir("sparse");
let lengths = Lengths::new(1_000_000, 500_000, 16384);
let _s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("big.bin")],
vec![1_000_000],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let meta = fs::metadata(dir.join("big.bin")).unwrap();
assert_eq!(meta.len(), 1_000_000);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn last_piece_shorter() {
let dir = temp_dir("lastpiece");
let lengths = Lengths::new(75, 50, 25);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![75],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![3u8; 25];
s.write_chunk(1, 0, &data).unwrap();
let piece = s.read_piece(1).unwrap();
assert_eq!(piece, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn concurrent_different_pieces() {
let dir = temp_dir("concurrent");
let lengths = Lengths::new(200, 100, 50);
let s = Arc::new(
FilesystemStorage::new(
&dir,
vec![PathBuf::from("a.bin"), PathBuf::from("b.bin")],
vec![100, 100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap(),
);
let s0 = Arc::clone(&s);
let t0 = thread::spawn(move || {
let data = vec![1u8; 100];
s0.write_chunk(0, 0, &data).unwrap();
});
let s1 = Arc::clone(&s);
let t1 = thread::spawn(move || {
let data = vec![2u8; 100];
s1.write_chunk(1, 0, &data).unwrap();
});
t0.join().unwrap();
t1.join().unwrap();
let p0 = s.read_piece(0).unwrap();
let p1 = s.read_piece(1).unwrap();
assert_eq!(p0, vec![1u8; 100]);
assert_eq!(p1, vec![2u8; 100]);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn skip_priority_file_not_created() {
use irontide_core::FilePriority;
let dir = temp_dir("skip_alloc");
let lengths = Lengths::new(200, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("wanted.bin"), PathBuf::from("skipped.bin")],
vec![100, 100],
lengths,
Some(&[FilePriority::Normal, FilePriority::Skip]),
PreallocateMode::None,
false,
)
.unwrap();
assert!(dir.join("wanted.bin").exists());
assert!(!dir.join("skipped.bin").exists());
let data = vec![42u8; 50];
s.write_chunk(0, 0, &data).unwrap();
let read = s.read_chunk(0, 0, 50).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn streaming_verify_matches_full_read() {
let dir = temp_dir("streaming_verify");
let total = 262_144_u64;
let lengths = Lengths::new(total, total, 16384);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![total],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data: Vec<u8> = (0..total as usize).map(|i| (i % 251) as u8).collect();
s.write_chunk(0, 0, &data).unwrap();
let full_piece = s.read_piece(0).unwrap();
let expected = irontide_core::sha1(&full_piece);
assert!(s.verify_piece(0, &expected).unwrap());
assert!(!s.verify_piece(0, &Id20::ZERO).unwrap());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn streaming_verify_small_piece() {
let dir = temp_dir("streaming_small");
let lengths = Lengths::new(100, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("small.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![0xABu8; 100];
s.write_chunk(0, 0, &data).unwrap();
let expected = irontide_core::sha1(&data);
assert!(s.verify_piece(0, &expected).unwrap());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn full_preallocation() {
let dir = temp_dir("prealloc");
let lengths = Lengths::new(100_000, 50_000, 16384);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100_000],
lengths,
None,
PreallocateMode::Full,
false,
)
.unwrap();
let meta = fs::metadata(dir.join("test.bin")).unwrap();
assert_eq!(meta.len(), 100_000);
#[cfg(target_os = "linux")]
{
use std::os::linux::fs::MetadataExt;
assert!(meta.st_blocks() * 512 >= 100_000);
}
let data = vec![42u8; 16384];
s.write_chunk(0, 0, &data).unwrap();
let read = s.read_chunk(0, 0, 16384).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn full_preallocation_chunk_step_invariant() {
fn step(remaining: u64, chunk_size: u64) -> u64 {
u64::min(remaining, chunk_size)
}
let chunk = 65536u64;
assert_eq!(step(chunk, chunk), chunk);
assert_eq!(step(chunk - 1, chunk), chunk - 1);
assert_eq!(step(1, chunk), 1);
let four_gib_plus_64 = (1u64 << 32) + 64;
assert_eq!(
step(four_gib_plus_64, chunk),
chunk,
"must clamp to chunk_size, not the truncated remainder"
);
assert_eq!(step(u64::MAX, chunk), chunk);
assert_eq!(step(7, 7), 7);
assert_eq!(step(3, 7), 3);
}
#[test]
fn write_chunk_vectored_single_file() {
let dir = temp_dir("vec_single");
let lengths = Lengths::new(200, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![200],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let s0: Vec<u8> = (0..30).collect();
let s1: Vec<u8> = (30..50).collect();
s.write_chunk_vectored(0, 10, &s0, &s1).unwrap();
let read = s.read_chunk(0, 10, 50).unwrap();
let expected: Vec<u8> = (0..50).collect();
assert_eq!(read, expected);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_chunk_vectored_file_boundary() {
let dir = temp_dir("vec_boundary");
let lengths = Lengths::new(200, 150, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("a.bin"), PathBuf::from("b.bin")],
vec![100, 100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let s0: Vec<u8> = (0..60).collect();
let s1: Vec<u8> = (60..100).collect();
s.write_chunk_vectored(0, 50, &s0, &s1).unwrap();
let read = s.read_chunk(0, 50, 100).unwrap();
let expected: Vec<u8> = (0..100).collect();
assert_eq!(read, expected);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn write_chunk_vectored_empty_s1() {
let dir = temp_dir("vec_empty_s1");
let lengths = Lengths::new(100, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![0xABu8; 50];
s.write_chunk_vectored(0, 0, &data, &[]).unwrap();
let read = s.read_chunk(0, 0, 50).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn pwritev_single_file_contiguous() {
let dir = temp_dir("pwritev_contig");
let lengths = Lengths::new(200, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![200],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data: Vec<u8> = (0..50).collect();
s.write_chunk_vectored(0, 25, &data, &[]).unwrap();
let read = s.read_chunk(0, 25, 50).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn pwritev_single_file_split() {
let dir = temp_dir("pwritev_split");
let lengths = Lengths::new(200, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![200],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let s0: Vec<u8> = (0..35).collect();
let s1: Vec<u8> = (35..50).collect();
s.write_chunk_vectored(0, 0, &s0, &s1).unwrap();
let read = s.read_chunk(0, 0, 50).unwrap();
let expected: Vec<u8> = (0..50).collect();
assert_eq!(read, expected);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn pwritev_multi_file_boundary() {
let dir = temp_dir("pwritev_multi");
let lengths = Lengths::new(160, 100, 50);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("a.bin"), PathBuf::from("b.bin")],
vec![80, 80],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let s0: Vec<u8> = (0..40).collect();
let s1: Vec<u8> = (40..60).collect();
s.write_chunk_vectored(0, 50, &s0, &s1).unwrap();
let read = s.read_chunk(0, 50, 60).unwrap();
let expected: Vec<u8> = (0..60).collect();
assert_eq!(read, expected);
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn sparse_fallocate_reserves_blocks() {
use std::os::linux::fs::MetadataExt;
let dir = temp_dir("sparse_falloc");
let lengths = Lengths::new(100_000, 100_000, 16384);
let _s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100_000],
lengths,
None,
PreallocateMode::Sparse,
false,
)
.unwrap();
let meta = fs::metadata(dir.join("test.bin")).unwrap();
assert_eq!(meta.len(), 100_000);
let _ = meta.st_blocks();
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sparse_fallocate_degradation() {
let dir = temp_dir("sparse_degrade");
let lengths = Lengths::new(1000, 1000, 500);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![1000],
lengths,
None,
PreallocateMode::Sparse,
false,
)
.unwrap();
let meta = fs::metadata(dir.join("test.bin")).unwrap();
assert_eq!(meta.len(), 1000);
let data = vec![0xCDu8; 500];
s.write_chunk(0, 0, &data).unwrap();
let read = s.read_chunk(0, 0, 500).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn prealloc_mode_from_bool_backward_compat() {
assert_eq!(PreallocateMode::from(false), PreallocateMode::None);
assert_eq!(PreallocateMode::from(true), PreallocateMode::Full);
}
#[test]
fn direct_io_field_stored() {
let dir = temp_dir("dio_field");
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![100],
Lengths::new(100, 100, 50),
None,
PreallocateMode::None,
true,
)
.unwrap();
assert!(s.direct_io, "direct_io field should be stored as true");
let s2 = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test2.bin")],
vec![100],
Lengths::new(100, 100, 50),
None,
PreallocateMode::None,
false,
)
.unwrap();
assert!(!s2.direct_io, "direct_io field should be stored as false");
fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn direct_io_write_read_aligned() {
let dir = tempfile::tempdir().expect("tempdir");
let lengths = Lengths::new(16384, 16384, 16384);
let result = FilesystemStorage::new(
dir.path(),
vec![PathBuf::from("test.bin")],
vec![16384],
lengths,
None,
PreallocateMode::None,
true,
);
let storage = match result {
Ok(s) => s,
Err(e) => {
eprintln!("Skipping direct_io test: storage creation failed: {e}");
return;
}
};
let data = vec![0xAB_u8; 16384];
match storage.write_chunk(0, 0, &data) {
Ok(()) => {
let read = storage.read_chunk(0, 0, 16384).expect("aligned read");
assert_eq!(read, data);
}
Err(e) => {
eprintln!("Skipping: O_DIRECT write failed (expected on tmpfs): {e}");
}
}
}
#[test]
fn non_direct_io_write_read() {
let dir = temp_dir("dio_off");
let lengths = Lengths::new(16384, 16384, 16384);
let s = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![16384],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let data = vec![0xCD_u8; 16384];
s.write_chunk(0, 0, &data).unwrap();
let read = s.read_chunk(0, 0, 16384).unwrap();
assert_eq!(read, data);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn delete_removes_single_file_and_prunes_empty_parent() {
let dir = temp_dir("del_single");
fs::create_dir_all(dir.join("Show").join("S01")).unwrap();
let rel = PathBuf::from("Show/S01/ep01.mkv");
let full = dir.join(&rel);
fs::write(&full, b"data").unwrap();
delete_torrent_files_sync(dir.clone(), vec![rel]);
assert!(!full.exists(), "file should be gone");
assert!(!dir.join("Show/S01").exists(), "S01 should be pruned");
assert!(!dir.join("Show").exists(), "Show should be pruned");
assert!(dir.exists(), "root must NEVER be removed");
}
#[test]
fn delete_preserves_non_empty_parent() {
let dir = temp_dir("del_siblings");
fs::create_dir_all(dir.join("Show/S01")).unwrap();
let rel = PathBuf::from("Show/S01/ep01.mkv");
fs::write(dir.join(&rel), b"data").unwrap();
fs::write(dir.join("Show/S01/ep02.mkv"), b"keep me").unwrap();
delete_torrent_files_sync(dir.clone(), vec![rel.clone()]);
assert!(!dir.join(&rel).exists(), "file should be gone");
assert!(
dir.join("Show/S01/ep02.mkv").exists(),
"sibling should survive"
);
assert!(
dir.join("Show/S01").exists(),
"non-empty dir must NOT be pruned"
);
}
#[test]
fn delete_tolerates_missing_file() {
let dir = temp_dir("del_missing");
fs::create_dir_all(&dir).unwrap();
let rel = PathBuf::from("does-not-exist.bin");
delete_torrent_files_sync(dir.clone(), vec![rel]);
assert!(dir.exists(), "root must remain");
}
#[test]
fn delete_never_removes_download_dir_itself() {
let dir = temp_dir("del_root_safe");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.bin"), b"a").unwrap();
fs::write(dir.join("b.bin"), b"b").unwrap();
delete_torrent_files_sync(
dir.clone(),
vec![PathBuf::from("a.bin"), PathBuf::from("b.bin")],
);
assert!(!dir.join("a.bin").exists());
assert!(!dir.join("b.bin").exists());
assert!(
dir.exists(),
"download_dir root must NEVER be removed even if empty"
);
}
#[test]
fn delete_prunes_deeply_nested_chain() {
let dir = temp_dir("del_deep");
fs::create_dir_all(dir.join("a/b/c/d")).unwrap();
let rel = PathBuf::from("a/b/c/d/file.bin");
fs::write(dir.join(&rel), b"x").unwrap();
delete_torrent_files_sync(dir.clone(), vec![rel]);
assert!(!dir.join("a/b/c/d").exists());
assert!(!dir.join("a/b/c").exists());
assert!(!dir.join("a/b").exists());
assert!(!dir.join("a").exists());
assert!(dir.exists());
}
#[test]
#[cfg(unix)]
fn delete_tolerates_readonly_parent() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("del_readonly");
let sub = dir.join("protected");
fs::create_dir_all(&sub).unwrap();
let file = sub.join("data.bin");
fs::write(&file, b"x").unwrap();
let mut perms = fs::metadata(&sub).unwrap().permissions();
perms.set_mode(0o555);
fs::set_permissions(&sub, perms).unwrap();
delete_torrent_files_sync(dir.clone(), vec![PathBuf::from("protected/data.bin")]);
if let Ok(meta) = fs::metadata(&sub) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&sub, perms);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn delete_partial_failure_still_processes_remaining_files() {
let dir = temp_dir("del_partial");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("a.bin"), b"a").unwrap();
fs::write(dir.join("c.bin"), b"c").unwrap();
delete_torrent_files_sync(
dir.clone(),
vec![
PathBuf::from("a.bin"),
PathBuf::from("b.bin"), PathBuf::from("c.bin"),
],
);
assert!(!dir.join("a.bin").exists(), "a should have been deleted");
assert!(!dir.join("c.bin").exists(), "c should have been deleted");
assert!(dir.exists(), "root must remain");
}
#[test]
fn pre_existing_file_survives_storage_new() {
let dir = temp_dir("trunc_regression");
fs::create_dir_all(&dir).unwrap();
let payload = b"ABCDEFGHIJKLMNOP";
fs::write(dir.join("test.bin"), payload).unwrap();
let lengths = Lengths::new(
payload.len() as u64,
payload.len() as u64,
payload.len() as u32,
);
let _storage = FilesystemStorage::new(
&dir,
vec![PathBuf::from("test.bin")],
vec![payload.len() as u64],
lengths,
None,
PreallocateMode::None,
false,
)
.unwrap();
let on_disk = fs::read(dir.join("test.bin")).unwrap();
assert_eq!(
&on_disk, payload,
"pre-existing content must survive storage init"
);
let _ = fs::remove_dir_all(&dir);
}
}