use std::fs::{File, OpenOptions};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{ensure, Context, Result};
pub(crate) struct TextSegment {
file: File,
len: AtomicU64,
scrubbed: crate::core::sync::RwLock<crate::core::hash::FastSet<(u64, u32)>>,
#[cfg(feature = "security")]
cipher: Option<std::sync::Arc<crate::core::security::EncryptionManager>>,
}
const MAGIC: &[u8; 4] = b"HMST";
const VERSION: u8 = 2;
#[cfg(feature = "security")]
const FLAG_ENCRYPTED: u8 = 1;
const HEADER_LEN: u64 = 8;
impl TextSegment {
pub(crate) fn create(
path: &Path,
#[cfg(feature = "security")] cipher: Option<
std::sync::Arc<crate::core::security::EncryptionManager>,
>,
) -> Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)
.with_context(|| format!("creating text segment {}", path.display()))?;
#[cfg(feature = "security")]
let flags = if cipher.is_some() { FLAG_ENCRYPTED } else { 0 };
#[cfg(not(feature = "security"))]
let flags = 0;
let mut header = [0u8; HEADER_LEN as usize];
header[..4].copy_from_slice(MAGIC);
header[4] = VERSION;
header[5] = flags;
positioned_write(&file, &header, 0).context("writing text segment header")?;
Ok(Self {
file,
len: AtomicU64::new(HEADER_LEN),
scrubbed: Default::default(),
#[cfg(feature = "security")]
cipher,
})
}
pub(crate) fn recreate(&self, path: &Path) -> Result<Self> {
Self::create(
path,
#[cfg(feature = "security")]
self.cipher.clone(),
)
}
pub(crate) fn append(&self, text: &str) -> Result<(u64, u32)> {
let at = self.len.load(Ordering::Acquire);
#[cfg(feature = "security")]
let sealed;
let bytes = {
#[cfg(feature = "security")]
if let Some(cipher) = &self.cipher {
sealed = cipher.encrypt_with_aad(text.as_bytes(), &at.to_le_bytes())?;
sealed.as_slice()
} else {
text.as_bytes()
}
#[cfg(not(feature = "security"))]
text.as_bytes()
};
let len = u32::try_from(bytes.len()).context("chunk text over 4 GiB")?;
positioned_write(&self.file, bytes, at).context("appending chunk text")?;
self.len.store(at + u64::from(len), Ordering::Release);
Ok((at, len))
}
pub(crate) fn scrub(&self, at: u64, len: u32) -> Result<()> {
ensure!(
at >= HEADER_LEN
&& at
.checked_add(u64::from(len))
.is_some_and(|end| end <= self.len.load(Ordering::Acquire)),
"chunk text location outside the text segment"
);
positioned_write(&self.file, &vec![0u8; len as usize], at)
.context("scrubbing chunk text")?;
self.scrubbed.write().insert((at, len));
Ok(())
}
pub(crate) fn is_scrubbed(&self, at: u64, len: u32) -> bool {
self.scrubbed.read().contains(&(at, len))
}
pub(crate) fn sync(&self) -> Result<()> {
self.file.sync_data().context("syncing text segment")
}
pub(crate) fn read(&self, at: u64, len: u32) -> Result<String> {
ensure!(
at.checked_add(u64::from(len))
.is_some_and(|end| end <= self.len.load(Ordering::Acquire)),
"chunk text location outside the text segment"
);
ensure!(
at >= HEADER_LEN,
"chunk text location inside the segment header"
);
if self.is_scrubbed(at, len) {
return Ok(String::new());
}
let mut bytes = vec![0u8; len as usize];
positioned_read(&self.file, &mut bytes, at).context("reading chunk text")?;
#[cfg(feature = "security")]
if let Some(cipher) = &self.cipher {
bytes = cipher.decrypt_with_aad(&bytes, &at.to_le_bytes())?;
}
String::from_utf8(bytes).context("chunk text is not UTF-8")
}
#[cfg(test)]
pub(crate) fn len(&self) -> u64 {
self.len.load(Ordering::Acquire)
}
}
#[cfg(unix)]
fn positioned_read(file: &File, buf: &mut [u8], at: u64) -> std::io::Result<()> {
use std::os::unix::fs::FileExt;
file.read_exact_at(buf, at)
}
#[cfg(unix)]
fn positioned_write(file: &File, buf: &[u8], at: u64) -> std::io::Result<()> {
use std::os::unix::fs::FileExt;
file.write_all_at(buf, at)
}
#[cfg(windows)]
fn positioned_write(file: &File, buf: &[u8], at: u64) -> std::io::Result<()> {
use std::os::windows::fs::FileExt;
let mut done = 0;
while done < buf.len() {
let n = file.seek_write(&buf[done..], at + done as u64)?;
if n == 0 {
return Err(std::io::ErrorKind::WriteZero.into());
}
done += n;
}
Ok(())
}
#[cfg(windows)]
fn positioned_read(file: &File, buf: &mut [u8], at: u64) -> std::io::Result<()> {
use std::os::windows::fs::FileExt;
let mut done = 0;
while done < buf.len() {
let n = file.seek_read(&mut buf[done..], at + done as u64)?;
if n == 0 {
return Err(std::io::ErrorKind::UnexpectedEof.into());
}
done += n;
}
Ok(())
}
#[cfg(not(any(unix, windows)))]
fn positioned_read(_: &File, _: &mut [u8], _: u64) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
#[cfg(not(any(unix, windows)))]
fn positioned_write(_: &File, _: &[u8], _: u64) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nul_prefixed_live_text_is_not_scrubbed() {
let dir = tempfile::tempdir().unwrap();
let segment = create(&dir.path().join("text"));
for prefix in [15, 16, 17] {
let text = format!("{}live searchable text", "\0".repeat(prefix));
let (at, len) = segment.append(&text).unwrap();
assert_eq!(segment.read(at, len).unwrap(), text);
assert!(
!segment.is_scrubbed(at, len),
"live prefix of {prefix} NUL bytes triggers the deleted-record path"
);
}
}
fn create(path: &Path) -> TextSegment {
TextSegment::create(
path,
#[cfg(feature = "security")]
None,
)
.unwrap()
}
#[cfg(feature = "security")]
#[test]
fn encrypted_records_are_sealed_unique_and_bound_to_their_position() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t");
let manager = crate::core::security::EncryptionManager::new("pw", dir.path()).unwrap();
let segment = TextSegment::create(&path, Some(std::sync::Arc::new(manager))).unwrap();
let a = segment.append("same text").unwrap();
let b = segment.append("same text").unwrap();
assert_eq!(segment.read(a.0, a.1).unwrap(), "same text");
let raw = std::fs::read(&path).unwrap();
assert!(!raw.windows(9).any(|w| w == b"same text"));
let (ra, rb) = (a.0 as usize, b.0 as usize);
assert_ne!(raw[ra..ra + 12], raw[rb..rb + 12]);
let mut moved = raw.clone();
moved.copy_within(ra..ra + a.1 as usize, rb);
std::fs::write(&path, &moved).unwrap();
assert!(segment.read(b.0, b.1).is_err());
}
#[test]
fn scrubbed_records_are_gone_from_the_file_and_read_as_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("t");
let segment = create(&path);
let a = segment.append("deleted passage").unwrap();
let b = segment.append("kept passage").unwrap();
segment.scrub(a.0, a.1).unwrap();
segment.sync().unwrap();
let raw = std::fs::read(&path).unwrap();
assert!(!raw.windows(7).any(|w| w == b"deleted"));
assert_eq!(segment.read(a.0, a.1).unwrap(), "");
assert_eq!(segment.read(b.0, b.1).unwrap(), "kept passage");
assert!(segment.scrub(b.0, b.1 + 1).is_err());
assert!(segment.scrub(0, 4).is_err());
}
#[test]
fn locations_round_trip_and_stay_inside_the_segment() {
let dir = tempfile::tempdir().unwrap();
let segment = create(&dir.path().join("t"));
let a = segment.append("").unwrap();
let b = segment.append("héllo wörld").unwrap();
let c = segment.append(&"x".repeat(70_000)).unwrap();
assert_eq!(a, (HEADER_LEN, 0));
assert_eq!(b, (HEADER_LEN, "héllo wörld".len() as u32));
assert_eq!(c.0, HEADER_LEN + u64::from(b.1));
assert_eq!(segment.read(a.0, a.1).unwrap(), "");
assert_eq!(segment.read(b.0, b.1).unwrap(), "héllo wörld");
assert_eq!(segment.read(c.0, c.1).unwrap().len(), 70_000);
assert_eq!(segment.len(), HEADER_LEN + u64::from(b.1) + 70_000);
assert!(segment.read(c.0, c.1 + 1).is_err());
assert!(segment.read(segment.len(), 1).is_err());
assert!(segment.read(u64::MAX, 1).is_err());
assert!(segment.read(b.0 + 1, 1).is_err());
let reopened = create(&dir.path().join("t"));
assert_eq!(reopened.len(), HEADER_LEN);
}
}