use std::collections::HashSet;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, SystemTime};
use rand_core::{OsRng, RngCore};
use crate::protocol_state_storage::MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES;
pub(super) const RECORD_MAGIC: &[u8; 4] = b"OPS1";
pub(super) const MAX_COMPONENT_BYTES: usize = 4096;
pub(super) const MAX_VALUE_BYTES: usize = MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES;
pub(super) const MAX_HEADER_BYTES: usize = 8 + 2 * MAX_COMPONENT_BYTES;
pub(super) const MAX_FRAME_BYTES: usize = MAX_HEADER_BYTES + MAX_VALUE_BYTES;
pub(super) const MAX_LISTED_ENTRIES: usize = 65_536;
pub(super) const TEMP_PREFIX: &str = ".write-";
const MAX_SWEEP_TEMPORARIES: usize = 4_096;
const MAX_SWEEP_SCANNED: usize = MAX_LISTED_ENTRIES;
const STALE_TEMPORARY_AGE: Duration = Duration::from_secs(300);
#[cfg(unix)]
const DIRECTORY_MODE: u32 = 0o700;
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
pub(super) struct StoreLock(Mutex<Option<DirectoryLock>>);
pub(super) type StoreGuard<'a> = MutexGuard<'a, Option<DirectoryLock>>;
impl StoreLock {
pub(super) fn new(directory: DirectoryLock) -> Self {
Self(Mutex::new(Some(directory)))
}
pub(super) fn open(&self) -> Option<StoreGuard<'_>> {
let guard = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.is_some().then_some(guard)
}
pub(super) fn close(&self) {
let released = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
drop(released);
}
}
#[derive(Debug)]
pub(super) struct DirectoryLock {
_handle: File,
}
#[derive(Debug)]
pub(super) enum LockError {
Held,
Io(io::Error),
}
impl DirectoryLock {
pub(super) fn acquire(directory: &Path, name: &str) -> Result<Self, LockError> {
let path = directory.join(name);
let mut options = OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(FILE_MODE);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.share_mode(WINDOWS_FILE_SHARE_READ);
}
let handle = match options.open(&path) {
Ok(handle) => handle,
#[cfg(windows)]
Err(err) if err.raw_os_error() == Some(WINDOWS_SHARING_VIOLATION) => {
return Err(LockError::Held)
}
Err(err) => return Err(LockError::Io(err)),
};
#[cfg(unix)]
{
use rustix::fs::{flock, FlockOperation};
match flock(&handle, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {}
Err(errno) if errno == rustix::io::Errno::WOULDBLOCK => {
return Err(LockError::Held)
}
Err(errno) => tracing::warn!(
path = %path.display(),
error = %io::Error::from(errno),
"store directory cannot be locked on this filesystem; \
a second store over it will not be refused"
),
}
}
Ok(Self { _handle: handle })
}
}
#[cfg(windows)]
const WINDOWS_SHARING_VIOLATION: i32 = 32;
#[cfg(windows)]
const WINDOWS_FILE_SHARE_READ: u32 = 0x0000_0001;
#[derive(Debug)]
pub(super) enum FrameError {
ComponentTooLong,
ValueTooLarge(usize),
}
impl std::fmt::Display for FrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ComponentTooLong => {
write!(f, "key type or key id exceeds {MAX_COMPONENT_BYTES} bytes")
}
Self::ValueTooLarge(len) => write!(
f,
"record is {len} bytes, over the {MAX_VALUE_BYTES} byte limit"
),
}
}
}
pub(super) fn frame(key_type: &str, key_id: &str, value: &[u8]) -> Result<Vec<u8>, FrameError> {
let (type_len, id_len) = match (u16::try_from(key_type.len()), u16::try_from(key_id.len())) {
(Ok(t), Ok(i))
if usize::from(t) <= MAX_COMPONENT_BYTES && usize::from(i) <= MAX_COMPONENT_BYTES =>
{
(t, i)
}
_ => return Err(FrameError::ComponentTooLong),
};
if value.len() > MAX_VALUE_BYTES {
return Err(FrameError::ValueTooLarge(value.len()));
}
let mut out = Vec::with_capacity(8 + key_type.len() + key_id.len() + value.len());
out.extend_from_slice(RECORD_MAGIC);
out.extend_from_slice(&type_len.to_be_bytes());
out.extend_from_slice(&id_len.to_be_bytes());
out.extend_from_slice(key_type.as_bytes());
out.extend_from_slice(key_id.as_bytes());
out.extend_from_slice(value);
Ok(out)
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct Header {
pub(super) key_type: String,
pub(super) key_id: String,
pub(super) value_offset: usize,
}
pub(super) fn parse_header(raw: &[u8]) -> Option<Header> {
if raw.len() < 8 || &raw[..4] != RECORD_MAGIC {
return None;
}
let type_len = usize::from(u16::from_be_bytes([raw[4], raw[5]]));
let id_len = usize::from(u16::from_be_bytes([raw[6], raw[7]]));
if type_len > MAX_COMPONENT_BYTES || id_len > MAX_COMPONENT_BYTES {
return None;
}
let id_start = 8 + type_len;
let value_offset = id_start + id_len;
if raw.len() < value_offset {
return None;
}
let key_type = std::str::from_utf8(&raw[8..id_start]).ok()?.to_string();
let key_id = std::str::from_utf8(&raw[id_start..value_offset])
.ok()?
.to_string();
Some(Header {
key_type,
key_id,
value_offset,
})
}
pub(super) fn private_mkdir(directory: &Path) -> io::Result<()> {
let created = missing_ancestors(directory);
make_private_directory(directory)?;
for made in &created {
if let Some(parent) = flush_target(made) {
sync_directory(parent)?;
}
}
Ok(())
}
fn flush_target(made: &Path) -> Option<&Path> {
match made.parent()? {
parent if parent.as_os_str().is_empty() => Some(Path::new(".")),
parent => Some(parent),
}
}
fn missing_ancestors(directory: &Path) -> Vec<PathBuf> {
directory
.ancestors()
.take_while(|path| !path.as_os_str().is_empty() && !path.exists())
.map(Path::to_path_buf)
.collect()
}
fn make_private_directory(directory: &Path) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
fs::DirBuilder::new()
.recursive(true)
.mode(DIRECTORY_MODE)
.create(directory)?;
let _ = fs::set_permissions(directory, fs::Permissions::from_mode(DIRECTORY_MODE));
}
#[cfg(not(unix))]
fs::create_dir_all(directory)?;
Ok(())
}
pub(super) fn sync_directory(directory: &Path) -> io::Result<()> {
#[cfg(unix)]
{
match File::open(directory).and_then(|handle| handle.sync_all()) {
Ok(()) => Ok(()),
Err(err) if directory_flush_unsupported(&err) => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(not(unix))]
{
if let Ok(handle) = File::open(directory) {
let _ = handle.sync_all();
}
Ok(())
}
}
#[cfg(unix)]
fn directory_flush_unsupported(err: &io::Error) -> bool {
use rustix::io::Errno;
err.raw_os_error().is_some_and(|code| {
code == Errno::INVAL.raw_os_error() || code == Errno::NOTSUP.raw_os_error()
})
}
pub(super) fn write_atomic(directory: &Path, target: &Path, bytes: &[u8]) -> io::Result<()> {
let (temporary, mut handle) = create_temporary(directory)?;
let result = (|| {
handle.write_all(bytes)?;
handle.sync_all()?;
drop(handle);
fs::rename(&temporary, target)?;
sync_directory(directory)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn create_temporary(directory: &Path) -> io::Result<(PathBuf, File)> {
for _ in 0..8 {
let mut suffix = [0u8; 12];
OsRng.fill_bytes(&mut suffix);
let path = directory.join(format!("{TEMP_PREFIX}{}", hex::encode(suffix)));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(FILE_MODE);
}
match options.open(&path) {
Ok(handle) => return Ok((path, handle)),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not create a unique temporary file",
))
}
pub(super) enum BoundedRead {
Absent,
Oversized,
Bytes(Vec<u8>),
}
pub(super) fn read_bounded(path: &Path, max: usize) -> io::Result<BoundedRead> {
let handle = match File::open(path) {
Ok(handle) => handle,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(BoundedRead::Absent),
Err(err) => return Err(err),
};
let reported = handle.metadata()?.len();
if reported > max as u64 {
return Ok(BoundedRead::Oversized);
}
let mut raw = Vec::with_capacity(usize::try_from(reported).unwrap_or(max).min(max));
handle.take(max as u64 + 1).read_to_end(&mut raw)?;
if raw.len() > max {
return Ok(BoundedRead::Oversized);
}
Ok(BoundedRead::Bytes(raw))
}
pub(super) fn read_prefix(path: &Path, max: usize) -> Option<Vec<u8>> {
let handle = File::open(path).ok()?;
let mut raw = Vec::new();
handle.take(max as u64).read_to_end(&mut raw).ok()?;
Some(raw)
}
pub(super) fn remove(path: &Path) -> io::Result<bool> {
match fs::remove_file(path) {
Ok(()) => {
if let Some(parent) = path.parent() {
sync_directory(parent)?;
}
Ok(true)
}
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
pub(super) fn for_each_entry(
directory: &Path,
prefix: &str,
limit: usize,
mut visit: impl FnMut(&Path, &str),
) -> io::Result<()> {
let entries = match fs::read_dir(directory) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
let mut examined = 0usize;
for entry in entries {
if examined >= limit {
break;
}
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if !name.starts_with(prefix) {
continue;
}
examined += 1;
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
visit(&entry.path(), name);
}
Ok(())
}
pub(super) fn holds_entry(directory: &Path, prefix: &str) -> io::Result<bool> {
let entries = match fs::read_dir(directory) {
Ok(entries) => entries,
Err(err)
if matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
) =>
{
return Ok(false)
}
Err(err) => return Err(err),
};
for entry in entries {
let entry = entry?;
if entry.file_name().to_string_lossy().starts_with(prefix) && entry.file_type()?.is_file() {
return Ok(true);
}
}
Ok(false)
}
#[derive(Default)]
pub(super) struct SweepMemo(Mutex<HashSet<PathBuf>>);
impl SweepMemo {
pub(super) fn sweep_once(&self, directory: &Path) {
{
let mut swept = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !swept.insert(directory.to_path_buf()) {
return;
}
}
sweep_directory(directory, MAX_SWEEP_SCANNED, MAX_SWEEP_TEMPORARIES);
}
}
fn sweep_directory(directory: &Path, max_scanned: usize, max_temporaries: usize) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
let now = SystemTime::now();
let mut temporaries = 0usize;
for entry in entries.take(max_scanned).flatten() {
if temporaries >= max_temporaries {
break;
}
let name = entry.file_name();
if !name.to_str().is_some_and(|n| n.starts_with(TEMP_PREFIX)) {
continue;
}
temporaries += 1;
let stale = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age >= STALE_TEMPORARY_AGE);
if stale && fs::remove_file(entry.path()).is_ok() {
tracing::debug!(name = ?name, "removed stale store temporary");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::file_store::test_support::TempRoot;
#[test]
fn a_header_is_refused_unless_it_names_a_whole_key() {
let framed = frame("outbox", "m-1", b"\xaa").expect("frame");
assert!(parse_header(&framed[..framed.len() - 2]).is_none());
assert!(parse_header(b"OPS").is_none());
assert!(parse_header(b"OPS2\x00\x00\x00\x00").is_none());
assert!(parse_header(b"OPS1\xff\xff\x00\x00").is_none());
assert!(parse_header(b"OPS1\x00\x01\x00\x00\xff").is_none());
}
#[test]
fn a_component_or_value_over_the_ceiling_is_refused() {
let long = "x".repeat(MAX_COMPONENT_BYTES + 1);
assert!(matches!(
frame(&long, "id", b""),
Err(FrameError::ComponentTooLong)
));
assert!(matches!(
frame("t", &long, b""),
Err(FrameError::ComponentTooLong)
));
assert!(frame(&"x".repeat(MAX_COMPONENT_BYTES), "id", b"").is_ok());
let big = vec![0u8; MAX_VALUE_BYTES + 1];
assert!(matches!(
frame("t", "id", &big),
Err(FrameError::ValueTooLarge(_))
));
}
#[test]
fn a_fresh_temporary_survives_the_sweep_and_an_old_one_does_not() {
let root = TempRoot::new("sweep");
let fresh = root.path().join(format!("{TEMP_PREFIX}fresh"));
let old = root.path().join(format!("{TEMP_PREFIX}old"));
let entry = root.path().join("k_entry");
for path in [&fresh, &old, &entry] {
fs::write(path, b"x").expect("write");
}
let long_ago = SystemTime::now() - STALE_TEMPORARY_AGE - Duration::from_secs(60);
for path in [&old, &entry] {
File::options()
.write(true)
.open(path)
.and_then(|h| h.set_modified(long_ago))
.expect("backdate");
}
SweepMemo::default().sweep_once(root.path());
assert!(fresh.exists(), "an in-flight write must not be unlinked");
assert!(!old.exists(), "a crashed write's temporary must be removed");
assert!(entry.exists(), "the sweep must never touch a record");
}
#[test]
fn records_ahead_of_an_orphan_do_not_hide_it_from_the_sweep() {
let root = TempRoot::new("sweep-cap");
let long_ago = SystemTime::now() - STALE_TEMPORARY_AGE - Duration::from_secs(60);
for i in 0..32 {
fs::write(root.path().join(format!("k_{i:02}")), b"x").expect("write");
}
let old = root.path().join(format!("{TEMP_PREFIX}old"));
fs::write(&old, b"x").expect("write");
File::options()
.write(true)
.open(&old)
.and_then(|h| h.set_modified(long_ago))
.expect("backdate");
sweep_directory(root.path(), 1_000, 1);
assert!(!old.exists(), "an orphan behind the records was not swept");
}
#[test]
fn a_directory_lock_is_exclusive_until_dropped() {
let root = TempRoot::new("dir-lock");
let first = DirectoryLock::acquire(root.path(), "test.lock").expect("first lock");
assert!(matches!(
DirectoryLock::acquire(root.path(), "test.lock"),
Err(LockError::Held)
));
let _other = DirectoryLock::acquire(root.path(), "other.lock").expect("other lock");
drop(first);
DirectoryLock::acquire(root.path(), "test.lock").expect("lock after release");
}
#[test]
fn missing_ancestors_names_only_what_did_not_exist() {
let root = TempRoot::new("mkdir");
let deep = root.path().join("a").join("b");
assert_eq!(
missing_ancestors(&deep),
vec![deep.clone(), root.path().join("a")]
);
private_mkdir(&deep).expect("mkdir");
assert!(deep.is_dir());
assert!(missing_ancestors(&deep).is_empty());
}
#[test]
fn a_top_level_relative_directory_flushes_the_working_directory() {
assert_eq!(flush_target(Path::new("keys")), Some(Path::new(".")));
assert_eq!(
flush_target(Path::new("keys/account-x")),
Some(Path::new("keys"))
);
assert_eq!(flush_target(Path::new("./keys")), Some(Path::new(".")));
assert_eq!(flush_target(Path::new("/keys")), Some(Path::new("/")));
assert_eq!(flush_target(Path::new("/")), None);
assert!(sync_directory(Path::new(".")).is_ok());
}
#[test]
fn a_bounded_read_refuses_by_size_without_reading() {
let root = TempRoot::new("bounded");
let path = root.path().join("big");
let handle = File::create(&path).expect("create");
handle.set_len(16 * 1024).expect("set_len");
drop(handle);
assert!(matches!(
read_bounded(&path, 1024).expect("read"),
BoundedRead::Oversized
));
assert!(matches!(
read_bounded(&root.path().join("absent"), 1024).expect("read"),
BoundedRead::Absent
));
}
}