use std::{
fs::File,
io::{self, Read},
path::Path,
};
pub(super) const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
#[cfg(test)]
pub(super) const SMALL_FILE_READ_BYTES: u64 = 128 * 1024;
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ReadKind {
Empty,
Small,
Buffered,
Prefix,
}
#[derive(Debug)]
pub(super) enum ReadOutcome {
Bytes {
bytes: Vec<u8>,
bytes_scanned: u64,
truncated: bool,
scan_byte_limit_reached: bool,
#[cfg(test)]
kind: ReadKind,
},
Skipped,
ReadErrorSkipped,
ScanByteLimitReached,
}
pub(super) fn read_file_bytes(path: &Path, remaining_bytes: u64) -> io::Result<ReadOutcome> {
if remaining_bytes == 0 {
return Ok(ReadOutcome::ScanByteLimitReached);
}
let metadata = match path.symlink_metadata() {
Ok(metadata) => metadata,
Err(error) if is_recoverable(&error) => return Ok(ReadOutcome::ReadErrorSkipped),
Err(error) => return Err(error),
};
if !metadata.file_type().is_file() {
return Ok(ReadOutcome::Skipped);
}
let size = metadata.len();
if size == 0 {
return Ok(ReadOutcome::Bytes {
bytes: Vec::new(),
bytes_scanned: 0,
truncated: false,
scan_byte_limit_reached: false,
#[cfg(test)]
kind: ReadKind::Empty,
});
}
let file = match File::open(path) {
Ok(file) => file,
Err(error) if is_recoverable(&error) => return Ok(ReadOutcome::ReadErrorSkipped),
Err(error) => return Err(error),
};
let read_limit = MAX_FILE_BYTES.min(remaining_bytes).min(size);
let probe = size <= MAX_FILE_BYTES && read_limit < remaining_bytes;
let read_request = read_limit.saturating_add(u64::from(probe));
let mut bytes = Vec::with_capacity(read_request as usize);
if let Err(error) = file.take(read_request).read_to_end(&mut bytes) {
if is_recoverable(&error) {
return Ok(ReadOutcome::ReadErrorSkipped);
}
return Err(error);
}
let grew = bytes.len() as u64 > read_limit;
bytes.truncate(read_limit as usize);
let file_prefix_truncated = size > MAX_FILE_BYTES || grew;
let scan_byte_limit_reached = size > remaining_bytes && remaining_bytes < MAX_FILE_BYTES;
#[cfg(test)]
let kind = if bytes.is_empty() {
ReadKind::Empty
} else if file_prefix_truncated || scan_byte_limit_reached {
ReadKind::Prefix
} else if bytes.len() as u64 <= SMALL_FILE_READ_BYTES {
ReadKind::Small
} else {
ReadKind::Buffered
};
Ok(ReadOutcome::Bytes {
bytes_scanned: bytes.len() as u64,
bytes,
truncated: file_prefix_truncated,
scan_byte_limit_reached,
#[cfg(test)]
kind,
})
}
fn is_recoverable(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
)
}
#[cfg(test)]
fn read_bounded<R: Read>(
reader: &mut R,
limit: u64,
capacity_hint: u64,
detect_growth: bool,
) -> io::Result<(Vec<u8>, bool)> {
let read_limit = limit.saturating_add(u64::from(detect_growth));
let mut bytes = Vec::with_capacity(capacity_hint.min(read_limit) as usize);
reader.take(read_limit).read_to_end(&mut bytes)?;
let grew = bytes.len() as u64 > limit;
bytes.truncate(limit as usize);
Ok((bytes, grew))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::sync::mpsc;
struct GatedReader<R> {
reader: R,
started: Option<mpsc::Sender<()>>,
continue_read: Option<mpsc::Receiver<()>>,
}
impl<R: Read> Read for GatedReader<R> {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
if let Some(started) = self.started.take() {
started.send(()).unwrap();
}
if let Some(continue_read) = self.continue_read.take() {
continue_read.recv().unwrap();
}
self.reader.read(buffer)
}
}
#[test]
fn empty_file_uses_empty_branch() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("empty.txt");
File::create(&path).unwrap();
let ReadOutcome::Bytes {
bytes,
bytes_scanned,
kind,
truncated,
scan_byte_limit_reached,
} = read_file_bytes(&path, MAX_FILE_BYTES).unwrap()
else {
panic!("expected bytes");
};
assert!(bytes.is_empty());
assert_eq!(bytes_scanned, 0);
assert_eq!(kind, ReadKind::Empty);
assert!(!truncated);
assert!(!scan_byte_limit_reached);
}
#[test]
fn small_file_uses_vec_branch() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("small.txt");
fs::write(&path, "needle").unwrap();
let ReadOutcome::Bytes {
bytes,
kind,
truncated,
scan_byte_limit_reached,
..
} = read_file_bytes(&path, MAX_FILE_BYTES).unwrap()
else {
panic!("expected bytes");
};
assert_eq!(bytes, b"needle");
assert_eq!(kind, ReadKind::Small);
assert!(!truncated);
assert!(!scan_byte_limit_reached);
}
#[test]
fn large_file_uses_buffered_policy_branch() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("large.txt");
fs::write(&path, vec![b'a'; SMALL_FILE_READ_BYTES as usize + 1]).unwrap();
let ReadOutcome::Bytes {
bytes,
kind,
truncated,
scan_byte_limit_reached,
..
} = read_file_bytes(&path, MAX_FILE_BYTES).unwrap()
else {
panic!("expected bytes");
};
assert_eq!(bytes.len(), SMALL_FILE_READ_BYTES as usize + 1);
assert_eq!(kind, ReadKind::Buffered);
assert!(!truncated);
assert!(!scan_byte_limit_reached);
}
#[test]
fn oversized_file_reads_prefix_only() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("huge.txt");
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.unwrap();
file.seek(SeekFrom::Start(MAX_FILE_BYTES + 9)).unwrap();
file.write_all(b"x").unwrap();
let ReadOutcome::Bytes {
bytes,
bytes_scanned,
kind,
truncated,
scan_byte_limit_reached,
} = read_file_bytes(&path, MAX_FILE_BYTES).unwrap()
else {
panic!("expected bytes");
};
assert_eq!(bytes.len(), MAX_FILE_BYTES as usize);
assert_eq!(bytes_scanned, bytes.len() as u64);
assert_eq!(kind, ReadKind::Prefix);
assert!(truncated);
assert!(!scan_byte_limit_reached);
}
#[test]
fn remaining_budget_caps_read_and_reports_scan_limit_separately() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("budget.txt");
fs::write(&path, b"abcdef").unwrap();
let ReadOutcome::Bytes {
bytes,
bytes_scanned,
truncated,
scan_byte_limit_reached,
..
} = read_file_bytes(&path, 3).unwrap()
else {
panic!("expected bytes");
};
assert_eq!(bytes, b"abc");
assert_eq!(bytes_scanned, 3);
assert!(!truncated);
assert!(scan_byte_limit_reached);
}
#[test]
fn exact_remaining_file_is_not_scan_limited() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("exact.txt");
fs::write(&path, b"abc").unwrap();
let ReadOutcome::Bytes {
bytes,
scan_byte_limit_reached,
..
} = read_file_bytes(&path, 3).unwrap()
else {
panic!("expected bytes");
};
assert_eq!(bytes, b"abc");
assert!(!scan_byte_limit_reached);
}
#[test]
fn stale_capacity_hint_reads_growth_below_cap() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("stale-hint.txt");
fs::write(&path, vec![b'a'; (SMALL_FILE_READ_BYTES + 1) as usize]).unwrap();
let mut file = File::open(&path).unwrap();
let (bytes, grew) = read_bounded(&mut file, MAX_FILE_BYTES, 5, true).unwrap();
assert_eq!(bytes.len(), (SMALL_FILE_READ_BYTES + 1) as usize);
assert!(!grew);
}
#[test]
fn stale_capacity_hint_probes_beyond_cap() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("stale-hint-probe.txt");
fs::write(&path, vec![b'a'; (MAX_FILE_BYTES + 1) as usize]).unwrap();
let mut file = File::open(&path).unwrap();
let (bytes, grew) = read_bounded(&mut file, MAX_FILE_BYTES, 5, true).unwrap();
assert_eq!(bytes.len(), MAX_FILE_BYTES as usize);
assert!(grew);
assert!(bytes.len() <= MAX_FILE_BYTES as usize);
}
#[test]
fn bounded_read_overlaps_mutation_deterministically() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("concurrent.txt");
fs::write(&path, b"initial").unwrap();
let mut file = File::open(&path).unwrap();
let (started_tx, started_rx) = mpsc::channel();
let (continue_tx, continue_rx) = mpsc::channel();
let (mutation_done_tx, mutation_done_rx) = mpsc::channel();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
started_rx.recv().unwrap();
OpenOptions::new()
.write(true)
.open(&writer_path)
.unwrap()
.set_len(MAX_FILE_BYTES + 1)
.unwrap();
mutation_done_tx.send(()).unwrap();
continue_tx.send(()).unwrap();
});
let mut reader = GatedReader {
reader: &mut file,
started: Some(started_tx),
continue_read: Some(continue_rx),
};
let (bytes, grew) = read_bounded(&mut reader, MAX_FILE_BYTES, 7, true).unwrap();
mutation_done_rx.recv().unwrap();
writer.join().unwrap();
assert_eq!(bytes.len(), MAX_FILE_BYTES as usize);
assert!(grew);
assert!(bytes.len() <= MAX_FILE_BYTES as usize);
}
#[test]
fn bounded_read_handles_truncation_after_read_starts() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("concurrent-truncate.txt");
fs::write(&path, vec![b'a'; (SMALL_FILE_READ_BYTES + 1) as usize]).unwrap();
let mut file = File::open(&path).unwrap();
let (started_tx, started_rx) = mpsc::channel();
let (continue_tx, continue_rx) = mpsc::channel();
let (mutation_done_tx, mutation_done_rx) = mpsc::channel();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
started_rx.recv().unwrap();
OpenOptions::new()
.write(true)
.open(&writer_path)
.unwrap()
.set_len(0)
.unwrap();
mutation_done_tx.send(()).unwrap();
continue_tx.send(()).unwrap();
});
let mut reader = GatedReader {
reader: &mut file,
started: Some(started_tx),
continue_read: Some(continue_rx),
};
let (bytes, grew) =
read_bounded(&mut reader, MAX_FILE_BYTES, SMALL_FILE_READ_BYTES + 1, true).unwrap();
mutation_done_rx.recv().unwrap();
writer.join().unwrap();
assert!(bytes.is_empty());
assert!(!grew);
assert!(bytes.len() <= MAX_FILE_BYTES as usize);
}
#[test]
fn opened_file_read_handles_truncation_without_exceeding_cap() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("truncated.txt");
let initial_size = SMALL_FILE_READ_BYTES + 1;
fs::write(&path, vec![b'a'; initial_size as usize]).unwrap();
let mut file = File::open(&path).unwrap();
OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_len(0)
.unwrap();
let (bytes, grew) = read_bounded(&mut file, MAX_FILE_BYTES, initial_size, true).unwrap();
assert_eq!(bytes.len(), 0);
assert!(!grew);
}
#[cfg(unix)]
#[test]
fn opened_file_read_is_stable_after_concurrent_path_replacement() {
use std::fs::rename;
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("replaced.txt");
let initial_size = SMALL_FILE_READ_BYTES + 1;
fs::write(&path, vec![b'a'; initial_size as usize]).unwrap();
let file = File::open(&path).unwrap();
let replacement = temp.path().join("replacement.txt");
fs::write(&replacement, b"replacement").unwrap();
let (started_tx, started_rx) = mpsc::channel();
let (continue_tx, continue_rx) = mpsc::channel();
let (mutation_done_tx, mutation_done_rx) = mpsc::channel();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
started_rx.recv().unwrap();
rename(&replacement, &writer_path).unwrap();
mutation_done_tx.send(()).unwrap();
});
let reader = std::thread::spawn(move || {
let mut reader = GatedReader {
reader: file,
started: Some(started_tx),
continue_read: Some(continue_rx),
};
read_bounded(&mut reader, MAX_FILE_BYTES, initial_size, true)
});
mutation_done_rx.recv().unwrap();
continue_tx.send(()).unwrap();
writer.join().unwrap();
let (bytes, grew) = reader.join().unwrap().unwrap();
assert!(bytes.len() <= MAX_FILE_BYTES as usize);
assert_eq!(bytes.len(), initial_size as usize);
assert_eq!(bytes[0], b'a');
assert!(!grew);
}
#[cfg(unix)]
#[test]
fn fifo_is_skipped_before_open() {
use std::process::Command;
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("pipe");
let status = Command::new("mkfifo").arg(&path).status().unwrap();
assert!(status.success());
assert!(matches!(
read_file_bytes(&path, MAX_FILE_BYTES).unwrap(),
ReadOutcome::Skipped
));
}
}