//! Filesystem-backed JSONL store. //! //! Layout: //! - Segment log: `{root}/{session_id}/{segment_id}.jsonl` //! - Event trace: `{root}/{session_id}/{segment_id}.trace.jsonl` //! //! The per-Session directory makes `list_segments(session_id)` an O(dir) //! scan and gives the fork tree a visible grouping in the filesystem. //! //! Migration: this layout is incompatible with the pre-`session-grouping` //! flat `{root}/{segment_id}.jsonl` form. Project policy is no //! backward compatibility — discard `~/.yoi/sessions/` (or whatever //! `root` resolved to) before running the new code. `list_sessions` //! ignores top-level files outside session directories, so leftover //! flat files do not corrupt new sessions, but they are no longer //! enumerable by the picker. use crate::event_trace::TraceEntry; use crate::paste_artifact::{read_from_dir, write_to_dir}; use crate::segment_log::LogEntry; use crate::store::{Store, StoreError}; use crate::uploaded_file::{ bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files, delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding, list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id, reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner, write_uploaded_file, }; use crate::{ PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext, }; use protocol::{PasteArtifactRef, UploadedFileRef}; use std::fs; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; /// Filesystem-backed JSONL store. /// /// Each segment is stored as a single `.jsonl` file with one [`LogEntry`] /// per line. A trailing line is committed only once its newline has been /// written; readers ignore an unterminated tail and the next append removes it. #[derive(Clone)] pub struct FsStore { root: PathBuf, /// Serialises append repair + write + rollback across clones. A failed /// `write_all` may have extended the file, so rollback is safe only while /// no sibling writer can append behind it. append_lock: Arc>, } impl FsStore { /// Create a new `FsStore` rooted at the given directory. /// Creates the directory if it does not exist. pub fn new(root: impl Into) -> Result { let root = root.into(); fs::create_dir_all(&root)?; Ok(Self { root, append_lock: Arc::new(Mutex::new(())), }) } /// Return the filesystem root used by this store. pub fn root_dir(&self) -> &Path { &self.root } /// Return the latest filesystem mtime under a Session directory. /// /// Missing Sessions return `Ok(None)`. This is intentionally Session-scoped /// so cleanup callers can apply age thresholds without reaching around the /// Session store's directory authority. pub fn session_modified_at( &self, session_id: SessionId, ) -> Result, StoreError> { let session_dir = self.session_dir(session_id); let dir_metadata = match fs::metadata(&session_dir) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error.into()), }; let mut latest = Some(dir_metadata.modified()?); for entry in fs::read_dir(&session_dir)? { let entry = entry?; let modified = entry.metadata()?.modified()?; if latest.map(|current| modified > current).unwrap_or(true) { latest = Some(modified); } } Ok(latest) } /// Delete an entire Session directory owned by this Session store. /// /// Returns `Ok(true)` when a Session directory was removed and `Ok(false)` /// when it was already absent. pub fn delete_session(&self, session_id: SessionId) -> Result { let session_dir = self.session_dir(session_id); match fs::remove_dir_all(&session_dir) { Ok(()) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(error.into()), } } fn session_dir(&self, session_id: SessionId) -> PathBuf { self.root.join(session_id.to_string()) } fn log_path(&self, session_id: SessionId, segment_id: SegmentId) -> PathBuf { self.session_dir(session_id) .join(format!("{segment_id}.jsonl")) } fn trace_path(&self, session_id: SessionId, segment_id: SegmentId) -> PathBuf { self.session_dir(session_id) .join(format!("{segment_id}.trace.jsonl")) } fn paste_artifact_dir(&self, session_id: SessionId) -> PathBuf { self.session_dir(session_id).join("artifacts").join("paste") } fn uploaded_file_is_referenced( &self, session_id: SessionId, artifact_id: &str, ) -> Result { fn segments_contain(segments: &[protocol::Segment], artifact_id: &str) -> bool { segments.iter().any(|segment| { matches!( segment, protocol::Segment::UploadedFile { file } if file.artifact_id == artifact_id ) }) } for segment_id in self.list_segments(session_id)? { for entry in self.read_all(session_id, segment_id)? { let referenced = match entry { LogEntry::AnnotatedUserInput { segments, .. } => { segments_contain(&segments, artifact_id) } LogEntry::InputSegmentsCheckpoint { user_segments, .. } => user_segments .iter() .any(|segments| segments_contain(segments, artifact_id)), _ => false, }; if referenced { return Ok(true); } } } Ok(false) } #[cfg(test)] fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf { self.paste_artifact_dir(session_id) .join(format!("{artifact_id}.json")) } fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut file = fs::OpenOptions::new() .create(true) .read(true) .write(true) .append(true) .open(path)?; let committed_len = Self::truncate_uncommitted_tail(&mut file)?; let mut record = Vec::with_capacity(line.len() + 1); record.extend_from_slice(line.as_bytes()); record.push(b'\n'); if let Err(write_error) = file.write_all(&record) { return match file.set_len(committed_len) { Ok(()) => Err(write_error.into()), Err(rollback_error) => Err(std::io::Error::new( rollback_error.kind(), format!( "session append failed ({write_error}) and partial-write rollback failed: {rollback_error}" ), ) .into()), }; } Ok(()) } /// Return only newline-terminated records. A process interruption or /// ENOSPC can leave the final UTF-8 code point / JSON object incomplete; /// without a newline that record never crossed the commit boundary. fn complete_jsonl_prefix(content: &[u8]) -> &[u8] { if content.last() == Some(&b'\n') { return content; } match content.iter().rposition(|byte| *byte == b'\n') { Some(index) => &content[..=index], None => &[], } } fn parse_jsonl(content: &[u8]) -> Result, StoreError> { let complete = Self::complete_jsonl_prefix(content); let content = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt { line: complete[..error.valid_up_to()] .iter() .filter(|byte| **byte == b'\n') .count() + 1, message: error.to_string(), })?; let mut entries = Vec::new(); for (i, line) in content.lines().enumerate() { if line.trim().is_empty() { continue; } let entry: T = serde_json::from_str(line).map_err(|e| StoreError::Corrupt { line: i + 1, message: e.to_string(), })?; entries.push(entry); } Ok(entries) } /// Remove a prior unterminated record and return the committed file size. /// Scans backwards in bounded chunks so repairing a large session does not /// require loading it into memory. fn truncate_uncommitted_tail(file: &mut fs::File) -> std::io::Result { const SCAN_BYTES: usize = 8 * 1024; let len = file.metadata()?.len(); if len == 0 { return Ok(0); } file.seek(SeekFrom::End(-1))?; let mut last = [0_u8; 1]; file.read_exact(&mut last)?; if last[0] == b'\n' { return Ok(len); } let mut end = len; let mut buffer = [0_u8; SCAN_BYTES]; while end > 0 { let start = end.saturating_sub(SCAN_BYTES as u64); let chunk_len = (end - start) as usize; file.seek(SeekFrom::Start(start))?; file.read_exact(&mut buffer[..chunk_len])?; if let Some(index) = buffer[..chunk_len].iter().rposition(|byte| *byte == b'\n') { let committed_len = start + index as u64 + 1; file.set_len(committed_len)?; return Ok(committed_len); } end = start; } file.set_len(0)?; Ok(0) } } impl Store for FsStore { fn append( &self, session_id: SessionId, segment_id: SegmentId, entry: &LogEntry, ) -> Result<(), StoreError> { let line = serde_json::to_string(entry)?; self.append_line(&self.log_path(session_id, segment_id), &line) } fn read_all( &self, session_id: SessionId, segment_id: SegmentId, ) -> Result, StoreError> { let path = self.log_path(session_id, segment_id); if !path.exists() { return Err(StoreError::NotFound(segment_id)); } let content = fs::read(&path)?; Self::parse_jsonl(&content) } fn list_sessions(&self) -> Result, StoreError> { let mut sessions = Vec::new(); if !self.root.exists() { return Ok(sessions); } for entry in fs::read_dir(&self.root)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } if let Some(name) = entry.file_name().to_str() { if let Ok(id) = name.parse::() { sessions.push(id); } } } sessions.sort_by(|a, b| b.cmp(a)); Ok(sessions) } fn list_segments(&self, session_id: SessionId) -> Result, StoreError> { let dir = self.session_dir(session_id); let mut segments = Vec::new(); if !dir.exists() { return Ok(segments); } for entry in fs::read_dir(&dir)? { let entry = entry?; let path = entry.path(); // Only match .jsonl files, not .trace.jsonl let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.ends_with(".jsonl") && !name.ends_with(".trace.jsonl") { let stem = name.trim_end_matches(".jsonl"); if let Ok(id) = stem.parse::() { segments.push(id); } } } // UUID v7: lexicographic sort = chronological sort, newest first segments.sort_by(|a, b| b.cmp(a)); Ok(segments) } fn lookup_session_of(&self, segment_id: SegmentId) -> Result, StoreError> { if !self.root.exists() { return Ok(None); } let needle = format!("{segment_id}.jsonl"); for entry in fs::read_dir(&self.root)? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } if entry.path().join(&needle).exists() && let Some(name) = entry.file_name().to_str() && let Ok(id) = name.parse::() { return Ok(Some(id)); } } Ok(None) } fn create_segment( &self, session_id: SessionId, segment_id: SegmentId, entries: &[LogEntry], ) -> Result<(), StoreError> { let path = self.log_path(session_id, segment_id); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let mut content = String::new(); for entry in entries { content.push_str(&serde_json::to_string(entry)?); content.push('\n'); } fs::write(&path, content.as_bytes())?; Ok(()) } fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result { Ok(self.log_path(session_id, segment_id).exists()) } fn read_entry_count( &self, session_id: SessionId, segment_id: SegmentId, ) -> Result { let path = self.log_path(session_id, segment_id); if !path.exists() { return Err(StoreError::NotFound(segment_id)); } let content = fs::read(&path)?; let complete = Self::complete_jsonl_prefix(&content); let complete = std::str::from_utf8(complete).map_err(|error| StoreError::Corrupt { line: complete[..error.valid_up_to()] .iter() .filter(|byte| **byte == b'\n') .count() + 1, message: error.to_string(), })?; Ok(complete.lines().filter(|l| !l.trim().is_empty()).count()) } fn write_paste_artifact( &self, session_id: SessionId, source_entry_id: &str, content: &str, limits: PasteArtifactLimits, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; write_to_dir( &self.paste_artifact_dir(session_id), source_entry_id, content, limits, ) } fn read_paste_artifact( &self, session_id: SessionId, artifact_id: &str, ) -> Result<(PasteArtifactRef, String), StoreError> { read_from_dir(&self.paste_artifact_dir(session_id), artifact_id) } fn write_uploaded_file( &self, session_id: SessionId, file_name: &str, media_type: &str, content: &[u8], limits: UploadedFileLimits, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; write_uploaded_file( &self.paste_artifact_dir(session_id), file_name, media_type, content, None, limits, ) } fn write_uploaded_file_with_context( &self, session_id: SessionId, file_name: &str, media_type: &str, content: &[u8], context: &UploadedFileUploadContext, limits: UploadedFileLimits, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; write_uploaded_file( &self.paste_artifact_dir(session_id), file_name, media_type, content, Some(context), limits, ) } fn read_uploaded_file( &self, session_id: SessionId, reference: &UploadedFileRef, ) -> Result, StoreError> { read_uploaded_file(&self.paste_artifact_dir(session_id), reference) } fn read_uploaded_file_by_id( &self, session_id: SessionId, artifact_id: &str, ) -> Result<(UploadedFileRef, Vec), StoreError> { read_uploaded_file_by_id(&self.paste_artifact_dir(session_id), artifact_id) } fn bind_uploaded_file( &self, session_id: SessionId, reference: &UploadedFileRef, source_entry_id: &str, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; let dir = self.paste_artifact_dir(session_id); match bind_uploaded_file(&dir, reference, source_entry_id) { Err(StoreError::ArtifactAlreadyCommitted) => { let (stored, _) = read_uploaded_file_by_id(&dir, &reference.artifact_id)?; let previous_source = stored .source_entry_id .ok_or(StoreError::ArtifactIntegrityMismatch)?; if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? { return Err(StoreError::ArtifactAlreadyCommitted); } clear_uploaded_file_binding(&dir, &reference.artifact_id, &previous_source)?; bind_uploaded_file(&dir, reference, source_entry_id) } result => result, } } fn pin_uploaded_file( &self, session_id: SessionId, reference: &UploadedFileRef, owner_id: &str, ) -> Result<(), StoreError> { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id) } fn release_uploaded_file_pin( &self, session_id: SessionId, artifact_id: &str, owner_id: &str, ) -> Result<(), StoreError> { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id) } fn finalize_uploaded_file_binding( &self, session_id: SessionId, artifact_id: &str, source_entry_id: &str, ) -> Result<(), StoreError> { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; finalize_uploaded_file_binding( &self.paste_artifact_dir(session_id), artifact_id, source_entry_id, ) } fn reconcile_uploaded_file_pins( &self, session_id: SessionId, live_owner_ids: &[String], ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids) } fn delete_uploaded_file( &self, session_id: SessionId, artifact_id: &str, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; delete_uploaded_file(&self.paste_artifact_dir(session_id), artifact_id) } fn delete_uncommitted_uploaded_files(&self, session_id: SessionId) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; let dir = self.paste_artifact_dir(session_id); let mut removed = delete_uncommitted_uploaded_files(&dir)?; for reference in list_uploaded_file_refs(&dir)? { let Some(source_entry_id) = reference.source_entry_id.as_deref() else { continue; }; if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? { finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?; continue; } if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? { continue; } clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?; if delete_uploaded_file(&dir, &reference.artifact_id)? { removed = removed .checked_add(1) .ok_or(StoreError::ArtifactQuotaExceeded)?; } } Ok(removed) } fn copy_committed_uploaded_files( &self, source_session_id: SessionId, target_session_id: SessionId, ) -> Result { let _guard = self .append_lock .lock() .map_err(|_| std::io::Error::other("session store append lock was poisoned"))?; copy_committed_uploaded_files( &self.paste_artifact_dir(source_session_id), &self.paste_artifact_dir(target_session_id), ) } fn append_trace( &self, session_id: SessionId, segment_id: SegmentId, entry: &TraceEntry, ) -> Result<(), StoreError> { let line = serde_json::to_string(entry)?; self.append_line(&self.trace_path(session_id, segment_id), &line) } } #[cfg(test)] mod tests { use super::*; use crate::{new_segment_id, new_session_id}; #[test] fn delete_session_removes_session_directory_only() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let keep_session = new_session_id(); let keep_segment = new_segment_id(); let delete_session = new_session_id(); let delete_segment = new_segment_id(); store .create_segment(keep_session, keep_segment, &[]) .unwrap(); store .create_segment(delete_session, delete_segment, &[]) .unwrap(); assert!(store.delete_session(delete_session).unwrap()); assert!(!store.exists(delete_session, delete_segment).unwrap()); assert!(store.exists(keep_session, keep_segment).unwrap()); assert!(!store.delete_session(delete_session).unwrap()); } #[test] fn session_modified_at_is_store_scoped() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let segment_id = new_segment_id(); assert!(store.session_modified_at(session_id).unwrap().is_none()); store.create_segment(session_id, segment_id, &[]).unwrap(); assert!(store.session_modified_at(session_id).unwrap().is_some()); } #[test] fn paste_artifacts_are_atomic_integrity_checked_and_session_scoped() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let owner = new_session_id(); let other = new_session_id(); let content = "αβγ\nsecond line\n"; let reference = store .write_paste_artifact(owner, "entry-1", content, PasteArtifactLimits::default()) .unwrap(); assert_eq!(reference.byte_len, content.len() as u64); assert!(reference.created_at_ms > 0); assert_eq!( reference.media_type, protocol::PasteArtifactMediaType::TextPlainUtf8 ); assert_eq!( reference.availability, protocol::PasteArtifactAvailability::Available ); assert_eq!(reference.char_count, content.chars().count() as u64); assert_eq!(reference.source_entry_id, "entry-1"); assert_eq!( store .read_paste_artifact(owner, &reference.artifact_id) .unwrap() .1, content ); assert!(matches!( store.read_paste_artifact(other, &reference.artifact_id), Err(StoreError::PasteArtifactNotFound(_)) )); assert!( self::fs::read_dir(store.paste_artifact_dir(owner)) .unwrap() .all(|entry| !entry .unwrap() .file_name() .to_string_lossy() .ends_with(".tmp")) ); let very_large = "z".repeat(1024 * 1024); let very_large_ref = store .write_paste_artifact( owner, "entry-2", &very_large, PasteArtifactLimits::default(), ) .unwrap(); assert_eq!( store .read_paste_artifact(owner, &very_large_ref.artifact_id) .unwrap() .1, very_large ); } #[test] fn concurrent_paste_writes_atomically_enforce_aggregate_caps() { let tmp = tempfile::TempDir::new().unwrap(); let session_id = new_session_id(); let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); let limits = PasteArtifactLimits { max_artifact_bytes: 4, max_session_bytes: 8, max_session_artifacts: 1, }; let mut handles = Vec::new(); for entry_id in ["entry-1", "entry-2"] { let root = tmp.path().to_path_buf(); let barrier = barrier.clone(); handles.push(std::thread::spawn(move || { let store = FsStore::new(root).unwrap(); barrier.wait(); store.write_paste_artifact(session_id, entry_id, "1234", limits) })); } barrier.wait(); let results = handles .into_iter() .map(|handle| handle.join().unwrap()) .collect::>(); assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!( results .iter() .filter(|result| matches!(result, Err(StoreError::PasteArtifactLimit(_)))) .count(), 1 ); assert_eq!( std::fs::read_dir( FsStore::new(tmp.path()) .unwrap() .paste_artifact_dir(session_id) ) .unwrap() .filter_map(Result::ok) .filter( |entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json") ) .count(), 1 ); } #[test] fn uploaded_file_persists_trusted_upload_context_without_projecting_it() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let context = UploadedFileUploadContext { upload_id: "upload-1".into(), principal_id: "account-1".into(), workspace_id: "workspace-1".into(), runtime_id: "runtime-1".into(), worker_id: "worker-1".into(), }; let reference = store .write_uploaded_file_with_context( session_id, "notes.txt", "text/plain", b"hello", &context, UploadedFileLimits::default(), ) .unwrap(); let raw = fs::read_to_string( store .paste_artifact_dir(session_id) .join(format!("{}.file.json", reference.artifact_id)), ) .unwrap(); assert!(raw.contains("account-1")); assert!(raw.contains("workspace-1")); assert!(raw.contains("runtime-1")); assert!(raw.contains("worker-1")); assert!( !serde_json::to_string(&reference) .unwrap() .contains("account-1") ); let replay = store .write_uploaded_file_with_context( session_id, "notes.txt", "text/plain", b"hello", &context, UploadedFileLimits::default(), ) .unwrap(); assert_eq!(replay.artifact_id, reference.artifact_id); assert!(matches!( store.write_uploaded_file_with_context( session_id, "renamed.txt", "text/plain", b"hello", &context, UploadedFileLimits::default(), ), Err(StoreError::InvalidUploadedFileName) )); } #[test] fn uploaded_file_exact_replay_succeeds_at_session_count_limit() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let limits = UploadedFileLimits { max_file_bytes: 1, max_session_bytes: crate::DEFAULT_MAX_SESSION_UPLOADED_FILES, }; let mut first = None; for index in 0..crate::DEFAULT_MAX_SESSION_UPLOADED_FILES { let reference = store .write_uploaded_file( session_id, &format!("file-{index}.txt"), "text/plain", b"x", limits, ) .unwrap(); first.get_or_insert(reference); } let replay = store .write_uploaded_file(session_id, "file-0.txt", "text/plain", b"x", limits) .unwrap(); assert_eq!(replay.artifact_id, first.unwrap().artifact_id); assert!(matches!( store.write_uploaded_file(session_id, "overflow.txt", "text/plain", b"x", limits), Err(StoreError::ArtifactQuotaExceeded) )); } #[test] fn uploaded_files_are_session_scoped_integrity_checked_and_removable() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let owner = new_session_id(); let other = new_session_id(); let limits = UploadedFileLimits { max_file_bytes: 16, max_session_bytes: 16, }; let reference = store .write_uploaded_file(owner, "notes.txt", "text/plain", b"hello", limits) .unwrap(); assert_eq!(reference.file_name, "notes.txt"); assert_eq!(reference.media_type, "text/plain"); assert_eq!(reference.byte_len, 5); assert_eq!(reference.source_entry_id, None); assert_eq!( store.read_uploaded_file(owner, &reference).unwrap(), b"hello" ); assert!(store.read_uploaded_file(other, &reference).is_err()); let mut forged = reference.clone(); forged.file_name = "other.txt".to_string(); assert!(matches!( store.read_uploaded_file(owner, &forged), Err(StoreError::ArtifactIntegrityMismatch) )); assert!( store .delete_uploaded_file(owner, &reference.artifact_id) .unwrap() ); assert!( !store .delete_uploaded_file(owner, &reference.artifact_id) .unwrap() ); assert!(store.read_uploaded_file(owner, &reference).is_err()); } #[test] fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let limits = UploadedFileLimits { max_file_bytes: 64, max_session_bytes: 128, }; let pending = store .write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits) .unwrap(); store .pin_uploaded_file(session_id, &pending, "submission-1") .unwrap(); assert!(matches!( store.pin_uploaded_file(session_id, &pending, "submission-other"), Err(StoreError::ArtifactAlreadyCommitted) )); drop(store); let store = FsStore::new(tmp.path()).unwrap(); assert_eq!( store.delete_uncommitted_uploaded_files(session_id).unwrap(), 0 ); assert_eq!( store .read_uploaded_file_by_id(session_id, &pending.artifact_id) .unwrap() .1, b"pending" ); let fork_session_id = new_session_id(); assert_eq!( store .copy_committed_uploaded_files(session_id, fork_session_id) .unwrap(), 0 ); assert!( store .read_uploaded_file_by_id(fork_session_id, &pending.artifact_id) .is_err() ); let committed = store .bind_uploaded_file(session_id, &pending, "entry-1") .unwrap(); assert_eq!( store.delete_uncommitted_uploaded_files(session_id).unwrap(), 0 ); assert!( store .read_uploaded_file_by_id(session_id, &pending.artifact_id) .is_ok() ); store .create_segment( session_id, new_segment_id(), &[LogEntry::InputSegmentsCheckpoint { ts: 1, user_segments: vec![vec![protocol::Segment::UploadedFile { file: committed.clone(), }]], }], ) .unwrap(); assert_eq!( store.delete_uncommitted_uploaded_files(session_id).unwrap(), 0 ); assert!( store .release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1") .is_err() ); let releasable = store .write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits) .unwrap(); store .pin_uploaded_file(session_id, &releasable, "submission-2") .unwrap(); store .release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2") .unwrap(); assert_eq!( store.delete_uncommitted_uploaded_files(session_id).unwrap(), 1 ); assert!( store .read_uploaded_file_by_id(session_id, &releasable.artifact_id) .is_err() ); } #[test] fn uploaded_file_validation_and_shared_quota_fail_closed() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let limits = UploadedFileLimits { max_file_bytes: 8, max_session_bytes: 8, }; assert!(matches!( store.write_uploaded_file(session_id, "../secret", "text/plain", b"x", limits), Err(StoreError::InvalidUploadedFileName) )); assert!(matches!( store.write_uploaded_file(session_id, "notes.txt", "not a type", b"x", limits), Err(StoreError::InvalidUploadedFileMediaType) )); assert!(matches!( store.write_uploaded_file( session_id, "safe\u{202e}txt.exe", "text/plain", b"x", limits ), Err(StoreError::InvalidUploadedFileName) )); assert!(matches!( store.write_uploaded_file(session_id, "image.png", "image/png", b"not a png", limits), Err(StoreError::ArtifactIntegrityMismatch) )); let pending = store .write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits) .unwrap(); let replay = store .write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits) .unwrap(); assert_eq!(replay.artifact_id, pending.artifact_id); assert!(matches!( store.write_uploaded_file(session_id, "README.txt", "text/plain", b"changed", limits), Err(StoreError::InvalidUploadedFileName) )); assert!(matches!( store.write_uploaded_file(session_id, "README.txt", "text/plain", b"y", limits), Err(StoreError::InvalidUploadedFileName) )); store .bind_uploaded_file(session_id, &pending, "entry-from-failed-submit") .unwrap(); let bound = store .bind_uploaded_file(session_id, &pending, "entry-upload") .unwrap(); store .create_segment( session_id, new_segment_id(), &[LogEntry::InputSegmentsCheckpoint { ts: 1, user_segments: vec![vec![protocol::Segment::UploadedFile { file: bound.clone(), }]], }], ) .unwrap(); let other = store .write_uploaded_file(session_id, "other.txt", "text/plain", b"z", limits) .unwrap(); let stale = store .write_uploaded_file(session_id, "stale.txt", "text/plain", b"s", limits) .unwrap(); store .bind_uploaded_file(session_id, &stale, "entry-never-committed") .unwrap(); assert_eq!( store.delete_uncommitted_uploaded_files(session_id).unwrap(), 2 ); assert!(store.read_uploaded_file(session_id, &other).is_err()); assert!(store.read_uploaded_file(session_id, &stale).is_err()); assert_eq!(store.read_uploaded_file(session_id, &bound).unwrap(), b"x"); let fork_session_id = new_session_id(); assert_eq!( store .copy_committed_uploaded_files(session_id, fork_session_id) .unwrap(), 1 ); assert_eq!( store.read_uploaded_file(fork_session_id, &bound).unwrap(), b"x" ); store .write_paste_artifact( session_id, "entry-1", "1234", PasteArtifactLimits { max_artifact_bytes: 8, max_session_bytes: 8, max_session_artifacts: 4, }, ) .unwrap(); assert!(matches!( store.write_uploaded_file(session_id, "notes.txt", "text/plain", b"56789", limits), Err(StoreError::ArtifactQuotaExceeded) )); } #[test] fn uploaded_file_names_reject_format_mixed_script_and_confusable_forms() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let limits = UploadedFileLimits::default(); for file_name in [ "safe\u{00ad}name.txt", "safe\u{061c}name.txt", "safe\u{180e}name.txt", "safe\u{e0001}name.txt", "p\u{0430}ypal.txt", "report.\u{03c1}df", "\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04cf}.txt", "\u{ff26}\u{ff49}\u{ff4c}\u{ff45}.txt", "re\u{0301}sume\u{0301}.txt", ] { assert!(matches!( store.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits), Err(StoreError::InvalidUploadedFileName) )); } for file_name in ["notes.txt", "résumé.txt", "日本語.txt", "📎.txt"] { store .write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits) .unwrap(); } } #[test] fn paste_artifact_limits_and_corruption_fail_closed() { let tmp = tempfile::TempDir::new().unwrap(); let store = FsStore::new(tmp.path()).unwrap(); let session_id = new_session_id(); let limits = PasteArtifactLimits { max_artifact_bytes: 5, max_session_bytes: 8, max_session_artifacts: 2, }; let first = store .write_paste_artifact(session_id, "entry-1", "1234", limits) .unwrap(); assert!(matches!( store.write_paste_artifact(session_id, "entry-2", "56789", limits), Err(StoreError::PasteArtifactLimit(_)) )); assert!(matches!( store.write_paste_artifact(session_id, "entry-2", "5678", limits), Ok(_) )); std::fs::write( store.paste_artifact_path(session_id, &first.artifact_id), b"{}", ) .unwrap(); assert!(matches!( store.read_paste_artifact(session_id, &first.artifact_id), Err(StoreError::Serde(_)) | Err(StoreError::PasteArtifactIntegrity(_)) )); } }