use std::path::Path;
use serde::{Deserialize, Serialize};
pub const TURN_DRAFT_MAX_TEXT_BYTES: usize = 1024 * 1024;
pub const TURN_DRAFT_MAX_FILE_BYTES: u64 = 6 * TURN_DRAFT_MAX_TEXT_BYTES as u64 + 4096;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnDraft {
pub base_len: usize,
#[serde(default)]
pub partial_text: String,
}
fn artifact(id: &str) -> String {
format!("{id}.turn")
}
pub fn read_turn_draft(dir: &Path, id: &str) -> std::io::Result<Option<TurnDraft>> {
let Some(bytes) = read_artifact(dir, &artifact(id))? else {
return Ok(None);
};
if bytes.len() as u64 > TURN_DRAFT_MAX_FILE_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"turn draft exceeds its size limit",
));
}
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn write_turn_draft(dir: &Path, id: &str, draft: &TurnDraft) -> std::io::Result<()> {
let mut draft = draft.clone();
truncate_on_char_boundary(&mut draft.partial_text, TURN_DRAFT_MAX_TEXT_BYTES);
let bytes = serde_json::to_vec(&draft).map_err(std::io::Error::other)?;
write_artifact(dir, &artifact(id), &bytes)
}
pub fn remove_turn_draft(dir: &Path, id: &str) -> std::io::Result<()> {
match remove_artifact(dir, &artifact(id)) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
fn truncate_on_char_boundary(s: &mut String, max: usize) {
if s.len() > max {
let mut cut = max;
while !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
}
}
#[cfg(unix)]
fn read_artifact(dir: &Path, name: &str) -> std::io::Result<Option<Vec<u8>>> {
use std::io::Read;
let handle = match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
Ok(h) => h,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let file = match handle.open_file(&[name.to_string()]) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut bytes = Vec::new();
file.take(TURN_DRAFT_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
Ok(Some(bytes))
}
#[cfg(unix)]
fn write_artifact(dir: &Path, name: &str, data: &[u8]) -> std::io::Result<()> {
crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)?.write_atomic(name, data)
}
#[cfg(unix)]
fn remove_artifact(dir: &Path, name: &str) -> std::io::Result<()> {
match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
Ok(handle) => handle.remove_file(name),
Err(e) => Err(e),
}
}
#[cfg(not(unix))]
fn read_artifact(dir: &Path, name: &str) -> std::io::Result<Option<Vec<u8>>> {
let path = dir.join(name);
match std::fs::symlink_metadata(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Ok(meta) if meta.file_type().is_symlink() => Err(std::io::Error::other(format!(
"refusing symlinked session artifact {name:?}"
))),
Err(e) => Err(e),
Ok(_) => {
use std::io::Read;
let mut bytes = Vec::new();
std::fs::File::open(&path)?
.take(TURN_DRAFT_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
Ok(Some(bytes))
}
}
}
#[cfg(not(unix))]
fn write_artifact(dir: &Path, name: &str, data: &[u8]) -> std::io::Result<()> {
crate::core::private_fs::ensure_private_dir(dir).map_err(std::io::Error::other)?;
crate::core::private_fs::write_atomic_private(&dir.join(name), data)
.map_err(std::io::Error::other)
}
#[cfg(not(unix))]
fn remove_artifact(dir: &Path, name: &str) -> std::io::Result<()> {
std::fs::remove_file(dir.join(name))
}
#[cfg(test)]
mod tests {
use super::*;
fn draft(base_len: usize, text: &str) -> TurnDraft {
TurnDraft {
base_len,
partial_text: text.into(),
}
}
#[test]
fn roundtrip_absent_and_idempotent_remove() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
assert_eq!(read_turn_draft(&dir, "s1").unwrap(), None, "no dir yet");
write_turn_draft(&dir, "s1", &draft(3, "partial")).unwrap();
assert_eq!(
read_turn_draft(&dir, "s1").unwrap(),
Some(draft(3, "partial"))
);
assert!(dir.join("s1.turn").exists());
remove_turn_draft(&dir, "s1").unwrap();
remove_turn_draft(&dir, "s1").unwrap();
assert_eq!(read_turn_draft(&dir, "s1").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn draft_file_is_private() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
write_turn_draft(&dir, "s1", &draft(0, "x")).unwrap();
let mode = std::fs::metadata(dir.join("s1.turn"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn oversized_text_is_truncated_on_a_char_boundary() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let big = "é".repeat(TURN_DRAFT_MAX_TEXT_BYTES); write_turn_draft(&dir, "s1", &draft(1, &big)).unwrap();
let back = read_turn_draft(&dir, "s1").unwrap().unwrap();
assert!(back.partial_text.len() <= TURN_DRAFT_MAX_TEXT_BYTES);
assert!(back.partial_text.chars().all(|c| c == 'é'));
}
#[test]
fn corrupt_draft_is_an_error_not_a_panic() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
write_turn_draft(&dir, "s1", &draft(0, "")).unwrap();
std::fs::write(dir.join("s1.turn"), b"{not json").unwrap();
assert!(read_turn_draft(&dir, "s1").is_err());
}
#[test]
fn draft_reads_are_bounded() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let worst = "\u{1}".repeat(TURN_DRAFT_MAX_TEXT_BYTES);
write_turn_draft(&dir, "s1", &draft(0, &worst)).unwrap();
assert!(std::fs::metadata(dir.join("s1.turn")).unwrap().len() <= TURN_DRAFT_MAX_FILE_BYTES);
assert_eq!(read_turn_draft(&dir, "s1").unwrap().unwrap().partial_text, worst);
let huge = vec![b' '; TURN_DRAFT_MAX_FILE_BYTES as usize + 1];
std::fs::write(dir.join("s2.turn"), huge).unwrap();
let err = read_turn_draft(&dir, "s2").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
}