use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_SPILL_THRESHOLD: u64 = 1 << 20;
enum Backing {
Mem(Vec<u8>),
File(File),
}
pub struct TempBlob {
inner: RwLock<Backing>,
len: AtomicU64,
threshold: u64,
dir: Option<PathBuf>,
pos: u64,
}
impl TempBlob {
pub fn new() -> Self {
TempBlob::with_threshold(DEFAULT_SPILL_THRESHOLD)
}
pub fn with_threshold(threshold: u64) -> Self {
TempBlob {
inner: RwLock::new(Backing::Mem(Vec::new())),
len: AtomicU64::new(0),
threshold,
dir: None,
pos: 0,
}
}
pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dir = Some(dir.into());
self
}
pub fn len(&self) -> u64 {
self.len.load(Ordering::Acquire)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn spill_threshold(&self) -> u64 {
self.threshold
}
pub fn is_in_memory(&self) -> bool {
matches!(&*self.read_guard(), Backing::Mem(_))
}
pub fn spill(&self) -> io::Result<()> {
let mut g = self.write_guard();
spill_locked(&mut g, self.dir.as_deref())
}
pub fn write_at(&self, at: u64, buf: &[u8]) -> io::Result<()> {
if buf.is_empty() {
return Ok(());
}
let end = at
.checked_add(buf.len() as u64)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "offset overflow"))?;
{
let g = self.read_guard();
if let Backing::File(f) = &*g {
write_all_at(f, at, buf)?;
drop(g);
self.grow_to(end);
return Ok(());
}
}
let mut g = self.write_guard();
if matches!(&*g, Backing::Mem(_)) && end > self.threshold {
spill_locked(&mut g, self.dir.as_deref())?;
}
match &mut *g {
Backing::File(f) => write_all_at(f, at, buf)?,
Backing::Mem(v) => {
if (v.len() as u64) < end {
v.resize(usize_len(end)?, 0);
}
let start = usize_len(at)?;
v[start..start + buf.len()].copy_from_slice(buf);
}
}
drop(g);
self.grow_to(end);
Ok(())
}
pub fn read_at(&self, buf: &mut [u8], at: u64) -> io::Result<usize> {
let len = self.len();
if buf.is_empty() || at >= len {
return Ok(0);
}
let cap = ((len - at) as usize).min(buf.len());
match &*self.read_guard() {
Backing::Mem(v) => {
let start = usize_len(at)?;
let n = cap.min(v.len().saturating_sub(start));
buf[..n].copy_from_slice(&v[start..start + n]);
Ok(n)
}
Backing::File(f) => read_at_file(f, &mut buf[..cap], at),
}
}
pub fn set_len(&self, n: u64) -> io::Result<()> {
let mut g = self.write_guard();
if matches!(&*g, Backing::Mem(_)) && n > self.threshold {
spill_locked(&mut g, self.dir.as_deref())?;
}
match &mut *g {
Backing::File(f) => f.set_len(n)?,
Backing::Mem(v) => v.resize(usize_len(n)?, 0),
}
drop(g);
self.len.store(n, Ordering::Release);
Ok(())
}
pub fn clear(&self) -> io::Result<()> {
self.set_len(0)
}
pub fn to_vec(&self) -> io::Result<Vec<u8>> {
let len = usize_len(self.len())?;
let mut out = vec![0u8; len];
let mut done = 0usize;
while done < len {
let n = self.read_at(&mut out[done..], done as u64)?;
if n == 0 {
out.truncate(done);
break;
}
done += n;
}
Ok(out)
}
pub fn close(self) -> io::Result<()> {
drop(self.inner.into_inner().unwrap_or_else(|e| e.into_inner()));
Ok(())
}
fn read_guard(&self) -> RwLockReadGuard<'_, Backing> {
self.inner.read().unwrap_or_else(|e| e.into_inner())
}
fn write_guard(&self) -> RwLockWriteGuard<'_, Backing> {
self.inner.write().unwrap_or_else(|e| e.into_inner())
}
fn grow_to(&self, end: u64) {
self.len.fetch_max(end, Ordering::AcqRel);
}
}
impl Default for TempBlob {
fn default() -> Self {
TempBlob::new()
}
}
impl fmt::Debug for TempBlob {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TempBlob")
.field("len", &self.len())
.field(
"backing",
&if self.is_in_memory() {
"memory"
} else {
"anonymous file"
},
)
.field("spill_threshold", &self.threshold)
.finish()
}
}
impl Read for TempBlob {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.read_at(buf, self.pos)?;
self.pos += n as u64;
Ok(n)
}
}
impl Seek for TempBlob {
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
let want = match from {
SeekFrom::Start(n) => Some(n),
SeekFrom::End(d) => offset(self.len(), d),
SeekFrom::Current(d) => offset(self.pos, d),
};
match want {
Some(p) => {
self.pos = p;
Ok(p)
}
None => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"seek to a negative or overflowing position",
)),
}
}
fn stream_position(&mut self) -> io::Result<u64> {
Ok(self.pos)
}
}
impl Write for TempBlob {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.write_at(self.len(), buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn offset(base: u64, delta: i64) -> Option<u64> {
if delta >= 0 {
base.checked_add(delta as u64)
} else {
base.checked_sub(delta.unsigned_abs())
}
}
fn usize_len(n: u64) -> io::Result<usize> {
usize::try_from(n).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"size exceeds this platform's address space",
)
})
}
fn spill_locked(g: &mut RwLockWriteGuard<'_, Backing>, dir: Option<&Path>) -> io::Result<()> {
let bytes = match &mut **g {
Backing::File(_) => return Ok(()),
Backing::Mem(v) => std::mem::take(v),
};
let file = anon_file(dir)?;
if !bytes.is_empty() {
write_all_at(&file, 0, &bytes)?;
}
**g = Backing::File(file);
Ok(())
}
fn anon_file(dir: Option<&Path>) -> io::Result<File> {
let owned;
let dir = match dir {
Some(d) => d,
None => {
owned = std::env::temp_dir();
&owned
}
};
#[cfg(any(target_os = "linux", target_os = "android"))]
if let Ok(f) = o_tmpfile(dir) {
return Ok(f);
}
unnamed_file(dir)
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn o_tmpfile(dir: &Path) -> io::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
const O_TMPFILE: i32 = 0o20_200_000;
OpenOptions::new()
.read(true)
.write(true)
.custom_flags(O_TMPFILE)
.mode(0o600)
.open(dir)
}
fn unnamed_file(dir: &Path) -> io::Result<File> {
let mut last = io::Error::other("no temp name attempted");
for attempt in 0..32u32 {
let path = dir.join(unique_name(attempt));
match create_exclusive(&path) {
Ok(f) => {
#[cfg(not(windows))]
std::fs::remove_file(&path)?;
return Ok(f);
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => last = e,
Err(e) => return Err(e),
}
}
Err(last)
}
#[cfg(unix)]
fn create_exclusive(path: &Path) -> io::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
#[cfg(windows)]
fn create_exclusive(path: &Path) -> io::Result<File> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_ATTRIBUTE_TEMPORARY: u32 = 0x0000_0100;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
const FILE_SHARE_ALL: u32 = 0x0000_0007;
OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.share_mode(FILE_SHARE_ALL)
.attributes(FILE_ATTRIBUTE_TEMPORARY)
.custom_flags(FILE_FLAG_DELETE_ON_CLOSE)
.open(path)
}
#[cfg(not(any(unix, windows)))]
fn create_exclusive(path: &Path) -> io::Result<File> {
OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(path)
}
fn unique_name(attempt: u32) -> String {
use std::sync::atomic::AtomicU64;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!(".rsurl-tmp-{}-{nanos}-{n}-{attempt}", std::process::id())
}
#[cfg(unix)]
pub(crate) fn write_all_at(f: &File, at: u64, buf: &[u8]) -> io::Result<()> {
use std::os::unix::fs::FileExt;
f.write_all_at(buf, at)
}
#[cfg(windows)]
pub(crate) fn write_all_at(f: &File, at: u64, buf: &[u8]) -> io::Result<()> {
use std::os::windows::fs::FileExt;
let mut done = 0usize;
while done < buf.len() {
match f.seek_write(&buf[done..], at + done as u64) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"failed to write whole buffer",
))
}
Ok(n) => done += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn write_all_at(f: &File, at: u64, buf: &[u8]) -> io::Result<()> {
let _g = POSITIONAL.lock().unwrap_or_else(|e| e.into_inner());
let mut f = f;
f.seek(SeekFrom::Start(at))?;
f.write_all(buf)
}
#[cfg(unix)]
fn read_at_file(f: &File, buf: &mut [u8], at: u64) -> io::Result<usize> {
use std::os::unix::fs::FileExt;
f.read_at(buf, at)
}
#[cfg(windows)]
fn read_at_file(f: &File, buf: &mut [u8], at: u64) -> io::Result<usize> {
use std::os::windows::fs::FileExt;
f.seek_read(buf, at)
}
#[cfg(not(any(unix, windows)))]
fn read_at_file(f: &File, buf: &mut [u8], at: u64) -> io::Result<usize> {
let _g = POSITIONAL.lock().unwrap_or_else(|e| e.into_inner());
let mut f = f;
f.seek(SeekFrom::Start(at))?;
f.read(buf)
}
#[cfg(not(any(unix, windows)))]
static POSITIONAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn small_payload_stays_in_memory() {
let mut b = TempBlob::new();
b.write_all(b"hello temp").unwrap();
assert!(b.is_in_memory());
assert_eq!(b.len(), 10);
assert_eq!(b.to_vec().unwrap(), b"hello temp");
let mut got = Vec::new();
b.read_to_end(&mut got).unwrap();
assert_eq!(got, b"hello temp");
}
#[test]
fn crossing_the_threshold_spills_and_keeps_the_bytes() {
let b = TempBlob::with_threshold(16);
b.write_at(0, b"0123456789").unwrap();
assert!(b.is_in_memory(), "under threshold");
b.write_at(10, b"abcdefghij").unwrap();
assert!(!b.is_in_memory(), "20 bytes > 16-byte threshold");
assert_eq!(b.len(), 20);
assert_eq!(b.to_vec().unwrap(), b"0123456789abcdefghij");
}
#[test]
fn read_at_and_seek_agree_across_backings() {
for threshold in [u64::MAX, 0] {
let mut b = TempBlob::with_threshold(threshold);
b.write_all(b"abcdefghij").unwrap();
assert_eq!(b.is_in_memory(), threshold == u64::MAX);
let mut buf = [0u8; 4];
assert_eq!(b.read_at(&mut buf, 3).unwrap(), 4);
assert_eq!(&buf, b"defg");
assert_eq!(b.stream_position().unwrap(), 0);
assert_eq!(b.seek(SeekFrom::End(-3)).unwrap(), 7);
let mut tail = Vec::new();
b.read_to_end(&mut tail).unwrap();
assert_eq!(tail, b"hij");
assert_eq!(b.read_at(&mut buf, 999).unwrap(), 0);
assert!(b.seek(SeekFrom::End(-99)).is_err());
}
}
#[test]
fn sparse_writes_zero_fill_and_set_len_truncates() {
let b = TempBlob::with_threshold(8);
b.write_at(4, b"XY").unwrap();
assert_eq!(b.len(), 6);
assert_eq!(b.to_vec().unwrap(), b"\0\0\0\0XY");
b.set_len(64).unwrap();
assert!(!b.is_in_memory());
assert_eq!(b.len(), 64);
b.set_len(6).unwrap();
assert_eq!(b.to_vec().unwrap(), b"\0\0\0\0XY");
b.clear().unwrap();
assert!(b.is_empty());
}
#[test]
fn concurrent_positional_writes_land_in_the_right_places() {
use std::sync::Arc;
let b = Arc::new(TempBlob::with_threshold(0)); b.set_len(4 * 4096).unwrap();
let mut hs = Vec::new();
for i in 0..4u64 {
let b = Arc::clone(&b);
hs.push(std::thread::spawn(move || {
b.write_at(i * 4096, &vec![b'a' + i as u8; 4096]).unwrap();
}));
}
for h in hs {
h.join().unwrap();
}
let all = b.to_vec().unwrap();
assert_eq!(all.len(), 4 * 4096);
for i in 0..4usize {
assert!(all[i * 4096..(i + 1) * 4096]
.iter()
.all(|&c| c == b'a' + i as u8));
}
}
#[cfg(unix)]
#[test]
fn spilled_file_has_no_name() {
let dir = std::env::temp_dir().join(format!("rsurl-anon-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
{
let b = TempBlob::with_threshold(0).in_dir(&dir);
b.write_at(0, b"invisible").unwrap();
assert!(!b.is_in_memory());
let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
assert!(
entries.is_empty(),
"anonymous file left a directory entry: {entries:?}"
);
assert_eq!(b.to_vec().unwrap(), b"invisible");
}
let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
assert!(entries.is_empty(), "temp dir not clean after drop");
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn close_reports_success_for_both_backings() {
TempBlob::new().close().unwrap();
let b = TempBlob::with_threshold(0);
b.write_at(0, b"x").unwrap();
b.close().unwrap();
}
}