feat(attachments): fondation pipeline durable d'attachments agent/chat + sandbox-safe (#154, QA verte)
Socle #154 : entité attachment, store, contrat DTO et routage session. Modules : domain/chat_attachment, application/chat_attachments, infrastructure/chat_attachments, app-tauri (commands/lib), backend dto.
This commit is contained in:
173
crates/application/src/chat_attachments.rs
Normal file
173
crates/application/src/chat_attachments.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! 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<ImportChatAttachmentItem>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Origin kind.
|
||||
pub source_kind: ChatAttachmentSourceKind,
|
||||
}
|
||||
|
||||
/// Output of [`ImportChatAttachments::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportChatAttachmentsOutput {
|
||||
/// Imported durable attachments.
|
||||
pub attachments: Vec<ChatAttachment>,
|
||||
}
|
||||
|
||||
/// Imports user-supplied files into durable, sandbox-readable chat attachment storage.
|
||||
pub struct ImportChatAttachments {
|
||||
store: Arc<dyn ChatAttachmentStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
impl ImportChatAttachments {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
store: Arc<dyn ChatAttachmentStore>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { store, ids, clock }
|
||||
}
|
||||
|
||||
/// Executes chat attachment imports.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ImportChatAttachmentsInput,
|
||||
) -> Result<ImportChatAttachmentsOutput, AppError> {
|
||||
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<dyn Clock>) -> u64 {
|
||||
u64::try_from(clock.now_millis()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn filename_from_path(path: &str) -> Result<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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",
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,9 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
AgentSessionError, EmbedderError, FsError, GitError, MemoryError, ModelServerError,
|
||||
ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError, StoreError,
|
||||
AgentSessionError, ChatAttachmentStoreError, EmbedderError, FsError, GitError, MemoryError,
|
||||
ModelServerError, ProcessError, PtyError, RemoteError, RuntimeError, SecretStoreError,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{AgentId, NodeId};
|
||||
use domain::{IssueStoreError, SprintStoreError};
|
||||
@ -168,6 +169,16 @@ impl From<SprintStoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChatAttachmentStoreError> for AppError {
|
||||
fn from(e: ChatAttachmentStoreError) -> Self {
|
||||
match e {
|
||||
ChatAttachmentStoreError::NotFound => Self::NotFound("chat attachment".to_owned()),
|
||||
ChatAttachmentStoreError::Invalid(message) => Self::Invalid(message),
|
||||
ChatAttachmentStoreError::Store(message) => Self::Store(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod background;
|
||||
pub mod chat_attachments;
|
||||
pub mod conversation;
|
||||
pub mod device;
|
||||
pub mod diag;
|
||||
@ -70,6 +71,10 @@ pub use background::{
|
||||
RetryBackgroundTask, SpawnBackgroundCommand, SpawnBackgroundCommandInput,
|
||||
SpawnBackgroundCommandOutput,
|
||||
};
|
||||
pub use chat_attachments::{
|
||||
ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput,
|
||||
ImportChatAttachmentsOutput, CHAT_ATTACHMENT_MAX_BYTES,
|
||||
};
|
||||
pub use conversation::{
|
||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||
|
||||
167
crates/application/tests/chat_attachments.rs
Normal file
167
crates/application/tests/chat_attachments.rs
Normal file
@ -0,0 +1,167 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{
|
||||
ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, Clock, IdGenerator,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, ChatAttachment, ChatAttachmentSourceKind, LocalPath, Project, ProjectId, ProjectPath,
|
||||
RemoteRef, SessionId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{ImportChatAttachmentItem, ImportChatAttachments, ImportChatAttachmentsInput};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeChatAttachments {
|
||||
calls: Mutex<Vec<(LocalPath, ChatAttachmentImport)>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatAttachmentStore for FakeChatAttachments {
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((source.clone(), import.clone()));
|
||||
let storage_path = format!(
|
||||
"agent-chat/{session_id}/{}-{}",
|
||||
import.id.as_str(),
|
||||
import.filename
|
||||
);
|
||||
let readable_path = format!(
|
||||
"{}/.ideai/attachments/agent-chat/{session_id}/{}-{}",
|
||||
root.as_str(),
|
||||
import.id.as_str(),
|
||||
import.filename
|
||||
);
|
||||
Ok(ChatAttachment {
|
||||
id: import.id,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
filename: import.filename.clone(),
|
||||
mime: import.mime.clone(),
|
||||
size_bytes: 12,
|
||||
source_kind: import.source_kind,
|
||||
storage_path,
|
||||
readable_path,
|
||||
created_at: import.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
1_234
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedIds;
|
||||
|
||||
impl IdGenerator for FixedIds {
|
||||
fn new_uuid(&self) -> Uuid {
|
||||
Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()),
|
||||
"P",
|
||||
ProjectPath::new("/tmp/idea-chat-attachments-test").unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_chat_attachments_allocates_metadata_and_uses_store_port() {
|
||||
let store = Arc::new(FakeChatAttachments::default());
|
||||
let usecase = ImportChatAttachments::new(
|
||||
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let agent_id =
|
||||
AgentId::from_uuid(Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap());
|
||||
let session_id =
|
||||
SessionId::from_uuid(Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap());
|
||||
|
||||
let output = usecase
|
||||
.execute(ImportChatAttachmentsInput {
|
||||
project: project(),
|
||||
agent_id,
|
||||
session_id,
|
||||
attachments: vec![ImportChatAttachmentItem {
|
||||
path: "/outside/photo.png".to_owned(),
|
||||
mime: None,
|
||||
source_kind: ChatAttachmentSourceKind::Clipboard,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.attachments.len(), 1);
|
||||
let attachment = &output.attachments[0];
|
||||
assert_eq!(attachment.filename, "photo.png");
|
||||
assert_eq!(attachment.mime, "image/png");
|
||||
assert_eq!(attachment.source_kind, ChatAttachmentSourceKind::Clipboard);
|
||||
assert_eq!(attachment.created_at, 1_234);
|
||||
assert!(attachment
|
||||
.storage_path
|
||||
.starts_with("agent-chat/cccccccc-cccc-cccc-cccc-cccccccccccc/"));
|
||||
assert!(attachment
|
||||
.readable_path
|
||||
.contains("/.ideai/attachments/agent-chat/"));
|
||||
|
||||
let calls = store.calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0.as_str(), "/outside/photo.png");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_chat_attachments_rejects_executable_sources_before_store_call() {
|
||||
let store = Arc::new(FakeChatAttachments::default());
|
||||
let usecase = ImportChatAttachments::new(
|
||||
Arc::clone(&store) as Arc<dyn ChatAttachmentStore>,
|
||||
Arc::new(FixedIds),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
|
||||
let err = usecase
|
||||
.execute(ImportChatAttachmentsInput {
|
||||
project: project(),
|
||||
agent_id: AgentId::new_random(),
|
||||
session_id: SessionId::new_random(),
|
||||
attachments: vec![ImportChatAttachmentItem {
|
||||
path: "/outside/run.sh".to_owned(),
|
||||
mime: None,
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert!(store.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user