//! Agent/chat attachment use cases. use std::path::Path; use std::sync::Arc; use domain::ports::{ChatAttachmentImport, ChatAttachmentStore}; use domain::{ AgentId, ChatAttachment, ChatAttachmentId, ChatAttachmentSourceKind, Clock, IdGenerator, LocalPath, Project, SessionId, }; use crate::error::AppError; /// Maximum raw chat attachment size. pub const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; /// Input for [`ImportChatAttachments::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportChatAttachmentsInput { /// Project owning the chat session. pub project: Project, /// Target agent. pub agent_id: AgentId, /// Structured/chat session id. pub session_id: SessionId, /// Attachments to import. pub attachments: Vec, } /// One source attachment import request. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportChatAttachmentItem { /// Local source path supplied by the driving adapter. pub path: String, /// Optional MIME type supplied by the driving adapter. pub mime: Option, /// Origin kind. pub source_kind: ChatAttachmentSourceKind, } /// Output of [`ImportChatAttachments::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImportChatAttachmentsOutput { /// Imported durable attachments. pub attachments: Vec, } /// Imports user-supplied files into durable, sandbox-readable chat attachment storage. pub struct ImportChatAttachments { store: Arc, ids: Arc, clock: Arc, } impl ImportChatAttachments { /// Builds the use case. #[must_use] pub fn new( store: Arc, ids: Arc, clock: Arc, ) -> Self { Self { store, ids, clock } } /// Executes chat attachment imports. pub async fn execute( &self, input: ImportChatAttachmentsInput, ) -> Result { let mut imported = Vec::with_capacity(input.attachments.len()); for item in input.attachments { let filename = filename_from_path(&item.path)?; validate_attachment_filename(&filename)?; let mime = sanitize_mime(item.mime.as_deref(), &filename)?; let id = ChatAttachmentId::new(self.ids.new_uuid().to_string()) .map_err(|err| AppError::Invalid(err.to_string()))?; let attachment = self .store .import_from_path( &input.project.root, input.project.id, input.agent_id, input.session_id, &LocalPath::new(item.path), ChatAttachmentImport { id, filename, mime, source_kind: item.source_kind, created_at: now(&self.clock), }, ) .await?; imported.push(attachment); } Ok(ImportChatAttachmentsOutput { attachments: imported, }) } } fn now(clock: &Arc) -> u64 { u64::try_from(clock.now_millis()).unwrap_or(0) } fn filename_from_path(path: &str) -> Result { Path::new(path) .file_name() .and_then(|name| name.to_str()) .map(str::to_owned) .ok_or_else(|| AppError::Invalid("invalid attachment filename".to_owned())) } fn validate_attachment_filename(filename: &str) -> Result<(), AppError> { let lowered = filename.to_ascii_lowercase(); let blocked = [ "exe", "bat", "cmd", "com", "scr", "msi", "dll", "so", "dylib", "sh", "ps1", "jar", "app", "deb", "rpm", ]; if lowered.trim().is_empty() || lowered.contains('/') || lowered.contains('\\') || lowered == "." || lowered == ".." { return Err(AppError::Invalid("invalid attachment filename".to_owned())); } if lowered .rsplit_once('.') .is_some_and(|(_, ext)| blocked.contains(&ext)) { return Err(AppError::Invalid( "executable attachments are not allowed".to_owned(), )); } Ok(()) } fn sanitize_mime(raw: Option<&str>, filename: &str) -> Result { let mime = raw .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) .unwrap_or_else(|| infer_mime(filename).to_owned()); if mime.eq_ignore_ascii_case("application/x-msdownload") || mime.eq_ignore_ascii_case("application/x-sh") || mime.eq_ignore_ascii_case("application/x-executable") { return Err(AppError::Invalid( "executable attachments are not allowed".to_owned(), )); } Ok(mime) } fn infer_mime(filename: &str) -> &'static str { match filename .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) .as_deref() { Some("txt" | "md" | "log") => "text/plain", Some("json") => "application/json", Some("xml") => "application/xml", Some("jpg" | "jpeg") => "image/jpeg", Some("png") => "image/png", Some("gif") => "image/gif", Some("webp") => "image/webp", Some("pdf") => "application/pdf", _ => "application/octet-stream", } }