use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::base::canonical::canonical_json;
use crate::base::time::Time;
use crate::record::record::Record;
use crate::record::refs::RecordId;
use crate::LogError;
pub(crate) struct FileLog {
pub(crate) dir: PathBuf,
pub(crate) file: std::fs::File,
pub(crate) records: Vec<Record>,
pub(crate) index: HashMap<RecordId, usize>,
pub(crate) max_file_bytes: u64,
}
impl FileLog {
pub(crate) fn open(dir: &Path, max_file_bytes: u64) -> Result<Self, LogError> {
std::fs::create_dir_all(dir)?;
let file_path = dir.join("records.log");
let mut file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&file_path)?;
let metadata_len = file.metadata()?.len();
if metadata_len > max_file_bytes {
return Err(LogError::LogSizeLimitExceeded {
bytes: metadata_len,
max_bytes: max_file_bytes,
});
}
let (records, valid_len, bytes_read) = Self::read_all_records(&mut file, max_file_bytes)?;
if valid_len < bytes_read {
file.set_len(valid_len)?;
file.sync_data()?;
}
file.seek(SeekFrom::End(0))?;
let mut index = HashMap::new();
for (pos, record) in records.iter().enumerate() {
index.insert(record.id, pos);
}
Ok(Self {
dir: dir.to_path_buf(),
file,
records,
index,
max_file_bytes,
})
}
pub(crate) fn ensure_capacity_for(&self, records: &[&Record]) -> Result<(), LogError> {
let mut projected_bytes = self.file.metadata()?.len();
for record in records {
let (_, len) = encode_record(record)?;
projected_bytes = projected_bytes.checked_add(4u64 + u64::from(len)).ok_or(
LogError::LogSizeLimitExceeded {
bytes: u64::MAX,
max_bytes: self.max_file_bytes,
},
)?;
}
if projected_bytes > self.max_file_bytes {
return Err(LogError::LogSizeLimitExceeded {
bytes: projected_bytes,
max_bytes: self.max_file_bytes,
});
}
Ok(())
}
pub(crate) fn append(&mut self, record: Record) -> Result<(), LogError> {
let (bytes, len) = encode_record(&record)?;
self.ensure_capacity_for(&[&record])?;
self.file.seek(SeekFrom::End(0))?;
self.file.write_all(&len.to_be_bytes())?;
self.file.write_all(&bytes)?;
self.file.sync_data()?;
let pos = self.records.len();
self.index.insert(record.id, pos);
self.records.push(record);
Ok(())
}
pub fn get(&self, id: RecordId) -> Option<&Record> {
self.index.get(&id).map(|&pos| &self.records[pos])
}
pub fn scan(&self, from: Time, to: Time) -> &[Record] {
let start = self.records.partition_point(|r| r.time < from);
let end = self.records.partition_point(|r| r.time <= to);
&self.records[start..end]
}
pub fn records(&self) -> &[Record] {
&self.records
}
pub fn last_time(&self) -> Option<Time> {
self.records.last().map(|r| r.time)
}
fn read_all_records(
file: &mut std::fs::File,
max_file_bytes: u64,
) -> Result<(Vec<Record>, u64, u64), LogError> {
let read_limit = max_file_bytes.saturating_add(1);
let mut data = Vec::new();
file.take(read_limit).read_to_end(&mut data)?;
let bytes_read = data.len() as u64;
if bytes_read > max_file_bytes {
return Err(LogError::LogSizeLimitExceeded {
bytes: bytes_read,
max_bytes: max_file_bytes,
});
}
let mut records = Vec::new();
let mut cursor = 0;
while data.len().saturating_sub(cursor) >= 4 {
let body_start = cursor + 4;
let len = u32::from_be_bytes([
data[cursor],
data[cursor + 1],
data[cursor + 2],
data[cursor + 3],
]) as usize;
let Some(frame_end) = complete_frame_end(body_start, len, data.len()) else {
break;
};
let record: Record =
serde_json::from_slice(&data[body_start..frame_end]).map_err(|e| {
LogError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Failed to deserialize record: {}", e),
))
})?;
records.push(record);
cursor = frame_end;
}
Ok((records, cursor as u64, bytes_read))
}
}
fn encode_record(record: &Record) -> Result<(Vec<u8>, u32), LogError> {
let bytes = canonical_json(record).map_err(|e| {
LogError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
))
})?;
let len =
u32::try_from(bytes.len()).map_err(|_| LogError::RecordTooLarge { bytes: bytes.len() })?;
Ok((bytes, len))
}
fn complete_frame_end(body_start: usize, body_len: usize, total_len: usize) -> Option<usize> {
body_start
.checked_add(body_len)
.filter(|&frame_end| frame_end <= total_len)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::author::Author;
use crate::record::evidence::Evidence;
use crate::record::kind::*;
fn test_record(time: Time) -> Record {
Record {
id: [time as u8; 32],
space: [1u8; 32],
thread: [2u8; 32],
time,
author: Author {
id: "test".into(),
type_: AuthorType::User,
signature: None,
},
kind: Kind::Request,
schema: [3u8; 32],
data: b"{}".to_vec(),
refs: vec![],
evidence: Evidence::Reported,
}
}
#[test]
fn test_frame_length_overflow_is_incomplete_not_a_panic() {
assert_eq!(complete_frame_end(8, usize::MAX, usize::MAX), None);
assert_eq!(complete_frame_end(4, 3, 7), Some(7));
assert_eq!(complete_frame_end(4, 4, 7), None);
}
#[test]
fn test_open_rejects_oversized_file_before_reading() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("records.log");
let file = std::fs::File::create(&path).unwrap();
file.set_len(9).unwrap();
assert!(matches!(
FileLog::open(dir.path(), 8),
Err(LogError::LogSizeLimitExceeded {
bytes: 9,
max_bytes: 8
})
));
}
#[test]
fn test_bounded_handle_read_detects_growth_after_metadata_check() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("records.log");
let mut file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.unwrap();
file.set_len(9).unwrap();
assert!(matches!(
FileLog::read_all_records(&mut file, 8),
Err(LogError::LogSizeLimitExceeded {
bytes: 9,
max_bytes: 8
})
));
}
#[test]
fn test_append_respects_log_size_limit_before_writing() {
let dir = tempfile::tempdir().unwrap();
let mut log = FileLog::open(dir.path(), 1).unwrap();
let result = log.append(test_record(1));
assert!(matches!(
result,
Err(LogError::LogSizeLimitExceeded { max_bytes: 1, .. })
));
assert_eq!(
std::fs::metadata(dir.path().join("records.log"))
.unwrap()
.len(),
0
);
}
#[test]
fn test_open_creates_dir_and_file() {
let dir = tempfile::tempdir().unwrap();
let log_dir = dir.path().join("test_log");
let log = FileLog::open(&log_dir, u64::MAX).unwrap();
assert!(log_dir.join("records.log").exists());
assert_eq!(log.records.len(), 0);
}
#[test]
fn test_append_and_get() {
let dir = tempfile::tempdir().unwrap();
let mut log = FileLog::open(dir.path(), u64::MAX).unwrap();
let r = test_record(1);
let id = r.id;
log.append(r).unwrap();
let retrieved = log.get(id).unwrap();
assert_eq!(retrieved.time, 1);
}
#[test]
fn test_scan() {
let dir = tempfile::tempdir().unwrap();
let mut log = FileLog::open(dir.path(), u64::MAX).unwrap();
for t in 1..=5 {
log.append(test_record(t)).unwrap();
}
let slice = log.scan(2, 4);
assert_eq!(slice.len(), 3);
assert_eq!(slice[0].time, 2);
assert_eq!(slice[2].time, 4);
}
#[test]
fn test_persistence_roundtrip() {
let dir = tempfile::tempdir().unwrap();
{
let mut log = FileLog::open(dir.path(), u64::MAX).unwrap();
log.append(test_record(1)).unwrap();
log.append(test_record(2)).unwrap();
}
let log = FileLog::open(dir.path(), u64::MAX).unwrap();
assert_eq!(log.records.len(), 2);
assert_eq!(log.records[0].time, 1);
assert_eq!(log.records[1].time, 2);
}
#[test]
fn test_last_time() {
let dir = tempfile::tempdir().unwrap();
let mut log = FileLog::open(dir.path(), u64::MAX).unwrap();
assert_eq!(log.last_time(), None);
log.append(test_record(5)).unwrap();
assert_eq!(log.last_time(), Some(5));
}
}