//! Filesystem-backed managed temporary store. //! //! Allocations live under `/managed-tmp//`. use std::path::{Path, PathBuf}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use domain::ports::{LocalPath, ManagedTempStore}; use domain::{ ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle, ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId, }; const MANAGED_TMP_DIR: &str = "managed-tmp"; const METADATA_FILE: &str = "metadata.json"; const DATA_NAME: &str = "data"; /// Filesystem implementation for global IdeA-managed temporary allocations. #[derive(Debug, Clone)] pub struct FsManagedTempStore { root: PathBuf, } impl FsManagedTempStore { /// Builds the store under `/managed-tmp`. #[must_use] pub fn new(app_data_dir: impl Into) -> Self { Self { root: app_data_dir.into().join(MANAGED_TMP_DIR), } } /// Returns the global managed temp root. #[must_use] pub fn root(&self) -> &Path { &self.root } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct StoredLease { handle: ManagedTempHandle, kind: ManagedTempKind, owner_project_id: Option, purpose: String, state: ManagedTempState, created_at_ms: u64, last_access_at_ms: u64, expires_at_ms: Option, released_at_ms: Option, } impl StoredLease { fn from_allocation(allocation: ManagedTempAllocation) -> Self { Self { handle: allocation.handle, kind: allocation.kind, owner_project_id: allocation.owner_project_id, purpose: allocation.purpose, state: ManagedTempState::Active, created_at_ms: allocation.created_at_ms, last_access_at_ms: allocation.created_at_ms, expires_at_ms: allocation.expires_at_ms, released_at_ms: None, } } fn is_expired(&self, now_ms: u64) -> bool { self.expires_at_ms.is_some_and(|expires| expires <= now_ms) } fn to_lease(&self, path: PathBuf) -> ManagedTempLease { ManagedTempLease { handle: self.handle.clone(), kind: self.kind, owner_project_id: self.owner_project_id, purpose: self.purpose.clone(), path: LocalPath::new(path.to_string_lossy().into_owned()), state: self.state, created_at_ms: self.created_at_ms, last_access_at_ms: self.last_access_at_ms, expires_at_ms: self.expires_at_ms, released_at_ms: self.released_at_ms, } } } fn io_error(err: std::io::Error) -> ManagedTempError { ManagedTempError::Store(err.to_string()) } fn serde_error(err: serde_json::Error) -> ManagedTempError { ManagedTempError::Store(err.to_string()) } fn allocation_dir(root: &Path, handle: &ManagedTempHandle) -> PathBuf { root.join(handle.as_str()) } fn metadata_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf { allocation_dir(root, handle).join(METADATA_FILE) } fn data_path(root: &Path, handle: &ManagedTempHandle) -> PathBuf { allocation_dir(root, handle).join(DATA_NAME) } fn ensure_under_root(root: &Path, candidate: &Path) -> Result<(), ManagedTempError> { if candidate.starts_with(root) { Ok(()) } else { Err(ManagedTempError::Invalid( "managed temp path escaped root".to_owned(), )) } } async fn read_stored( root: &Path, handle: &ManagedTempHandle, ) -> Result { let path = metadata_path(root, handle); let bytes = tokio::fs::read(&path).await.map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { ManagedTempError::NotFound } else { io_error(err) } })?; let stored: StoredLease = serde_json::from_slice(&bytes).map_err(serde_error)?; if stored.handle != *handle { return Err(ManagedTempError::Invalid( "managed temp metadata handle mismatch".to_owned(), )); } Ok(stored) } async fn write_stored(root: &Path, stored: &StoredLease) -> Result<(), ManagedTempError> { let path = metadata_path(root, &stored.handle); let bytes = serde_json::to_vec_pretty(stored).map_err(serde_error)?; tokio::fs::write(path, bytes).await.map_err(io_error) } #[async_trait] impl ManagedTempStore for FsManagedTempStore { async fn allocate( &self, allocation: ManagedTempAllocation, ) -> Result { let handle = allocation.handle.clone(); let dir = allocation_dir(&self.root, &handle); let data = data_path(&self.root, &handle); ensure_under_root(&self.root, &dir)?; ensure_under_root(&self.root, &data)?; tokio::fs::create_dir_all(&self.root) .await .map_err(io_error)?; tokio::fs::create_dir(&dir).await.map_err(io_error)?; match allocation.kind { ManagedTempKind::Directory => tokio::fs::create_dir(&data).await.map_err(io_error)?, ManagedTempKind::File => { tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(&data) .await .map_err(io_error)?; } } let stored = StoredLease::from_allocation(allocation); write_stored(&self.root, &stored).await?; Ok(stored.to_lease(data)) } async fn resolve( &self, handle: &ManagedTempHandle, now_ms: u64, ) -> Result { let mut stored = read_stored(&self.root, handle).await?; if stored.state != ManagedTempState::Active || stored.is_expired(now_ms) { return Err(ManagedTempError::NotFound); } let data = data_path(&self.root, handle); ensure_under_root(&self.root, &data)?; if tokio::fs::metadata(&data).await.is_err() { return Err(ManagedTempError::NotFound); } stored.last_access_at_ms = now_ms; write_stored(&self.root, &stored).await?; Ok(stored.to_lease(data)) } async fn release( &self, handle: &ManagedTempHandle, now_ms: u64, ) -> Result { let mut stored = read_stored(&self.root, handle).await?; stored.state = ManagedTempState::Released; stored.released_at_ms = Some(now_ms); stored.last_access_at_ms = now_ms; write_stored(&self.root, &stored).await?; Ok(stored.to_lease(data_path(&self.root, handle))) } async fn gc(&self, now_ms: u64) -> Result { let mut deleted = 0usize; let mut entries = match tokio::fs::read_dir(&self.root).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(ManagedTempGcReport { deleted: 0 }) } Err(err) => return Err(io_error(err)), }; while let Some(entry) = entries.next_entry().await.map_err(io_error)? { let file_type = entry.file_type().await.map_err(io_error)?; if !file_type.is_dir() { continue; } let raw_handle = entry.file_name().to_string_lossy().into_owned(); let Ok(handle) = ManagedTempHandle::new(raw_handle) else { continue; }; let Ok(stored) = read_stored(&self.root, &handle).await else { continue; }; if stored.state == ManagedTempState::Released || stored.is_expired(now_ms) { tokio::fs::remove_dir_all(entry.path()) .await .map_err(io_error)?; deleted += 1; } } Ok(ManagedTempGcReport { deleted }) } async fn purge_all(&self) -> Result { let mut deleted = 0usize; let mut entries = match tokio::fs::read_dir(&self.root).await { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { return Ok(ManagedTempGcReport { deleted: 0 }); } Err(err) => return Err(io_error(err)), }; while let Some(entry) = entries.next_entry().await.map_err(io_error)? { let path = entry.path(); let removed = match entry.file_type().await { Ok(file_type) if file_type.is_dir() => tokio::fs::remove_dir_all(&path).await, Ok(_) => tokio::fs::remove_file(&path).await, Err(err) => Err(err), }; if removed.is_ok() { deleted += 1; } } Ok(ManagedTempGcReport { deleted }) } }