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:
178
crates/infrastructure/src/chat_attachments.rs
Normal file
178
crates/infrastructure/src/chat_attachments.rs
Normal file
@ -0,0 +1,178 @@
|
||||
//! Filesystem chat attachment store.
|
||||
//!
|
||||
//! Project-scoped agent/chat attachments live under
|
||||
//! `<root>/.ideai/attachments/agent-chat/<session-id>/`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError};
|
||||
use domain::{AgentId, ChatAttachment, LocalPath, ProjectId, ProjectPath, SessionId};
|
||||
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
const ATTACHMENTS_DIR: &str = "attachments";
|
||||
const AGENT_CHAT_DIR: &str = "agent-chat";
|
||||
const CHAT_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
|
||||
|
||||
/// Filesystem-backed chat attachment store.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FsChatAttachmentStore;
|
||||
|
||||
impl FsChatAttachmentStore {
|
||||
/// Builds a store.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
fn attachment_root(root: &ProjectPath) -> PathBuf {
|
||||
PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(ATTACHMENTS_DIR)
|
||||
}
|
||||
|
||||
fn session_dir(root: &ProjectPath, session_id: SessionId) -> PathBuf {
|
||||
attachment_root(root)
|
||||
.join(AGENT_CHAT_DIR)
|
||||
.join(session_id.to_string())
|
||||
}
|
||||
|
||||
fn metadata_path(root: &ProjectPath, session_id: SessionId, id: &str) -> PathBuf {
|
||||
session_dir(root, session_id).join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
fn storage_relative(session_id: SessionId, stored_name: &str) -> String {
|
||||
format!("{AGENT_CHAT_DIR}/{session_id}/{stored_name}")
|
||||
}
|
||||
|
||||
fn readable_path(root: &ProjectPath, relative: &str) -> String {
|
||||
attachment_root(root)
|
||||
.join(relative)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> ChatAttachmentStoreError {
|
||||
ChatAttachmentStoreError::Store(err.to_string())
|
||||
}
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), ChatAttachmentStoreError> {
|
||||
if filename.trim().is_empty()
|
||||
|| filename.contains('/')
|
||||
|| filename.contains('\\')
|
||||
|| filename == "."
|
||||
|| filename == ".."
|
||||
{
|
||||
return Err(ChatAttachmentStoreError::Invalid(
|
||||
"invalid chat attachment filename".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_metadata(
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
attachment: &ChatAttachment,
|
||||
) -> Result<(), ChatAttachmentStoreError> {
|
||||
let path = metadata_path(root, session_id, attachment.id.as_str());
|
||||
let bytes = serde_json::to_vec_pretty(attachment)
|
||||
.map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?;
|
||||
tokio::fs::write(path, bytes).await.map_err(io_error)
|
||||
}
|
||||
|
||||
async fn read_metadata(path: &Path) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
let bytes = tokio::fs::read(path).await.map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
ChatAttachmentStoreError::NotFound
|
||||
} else {
|
||||
io_error(err)
|
||||
}
|
||||
})?;
|
||||
let attachment: ChatAttachment = serde_json::from_slice(&bytes)
|
||||
.map_err(|err| ChatAttachmentStoreError::Store(err.to_string()))?;
|
||||
attachment
|
||||
.validate()
|
||||
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatAttachmentStore for FsChatAttachmentStore {
|
||||
async fn import_from_path(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
project_id: ProjectId,
|
||||
agent_id: AgentId,
|
||||
session_id: SessionId,
|
||||
source: &LocalPath,
|
||||
import: ChatAttachmentImport,
|
||||
) -> Result<ChatAttachment, ChatAttachmentStoreError> {
|
||||
validate_filename(&import.filename)?;
|
||||
let source_path = PathBuf::from(source.as_str());
|
||||
let meta = tokio::fs::metadata(&source_path).await.map_err(io_error)?;
|
||||
if !meta.is_file() {
|
||||
return Err(ChatAttachmentStoreError::Invalid(
|
||||
"chat attachment source must be a file".to_owned(),
|
||||
));
|
||||
}
|
||||
if meta.len() > CHAT_ATTACHMENT_MAX_BYTES {
|
||||
return Err(ChatAttachmentStoreError::Invalid(format!(
|
||||
"chat attachment exceeds {CHAT_ATTACHMENT_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
|
||||
let dir = session_dir(root, session_id);
|
||||
tokio::fs::create_dir_all(&dir).await.map_err(io_error)?;
|
||||
let stored_name = format!("{}-{}", import.id.as_str(), import.filename);
|
||||
let dest = dir.join(&stored_name);
|
||||
tokio::fs::copy(&source_path, &dest)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
|
||||
let storage_path = storage_relative(session_id, &stored_name);
|
||||
let attachment = ChatAttachment {
|
||||
id: import.id,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
filename: import.filename,
|
||||
mime: import.mime,
|
||||
size_bytes: meta.len(),
|
||||
source_kind: import.source_kind,
|
||||
readable_path: readable_path(root, &storage_path),
|
||||
storage_path,
|
||||
created_at: import.created_at,
|
||||
};
|
||||
attachment
|
||||
.validate()
|
||||
.map_err(|err| ChatAttachmentStoreError::Invalid(err.to_string()))?;
|
||||
write_metadata(root, session_id, &attachment).await?;
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
async fn list_for_session(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ChatAttachment>, ChatAttachmentStoreError> {
|
||||
let dir = session_dir(root, session_id);
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(io_error(err)),
|
||||
};
|
||||
let mut attachments = Vec::new();
|
||||
while let Some(entry) = entries.next_entry().await.map_err(io_error)? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
attachments.push(read_metadata(&path).await?);
|
||||
}
|
||||
attachments.sort_by_key(|attachment| attachment.created_at);
|
||||
Ok(attachments)
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
|
||||
pub mod assistant;
|
||||
pub mod background_task;
|
||||
pub mod chat_attachments;
|
||||
pub mod clock;
|
||||
pub mod conversation;
|
||||
pub mod conversation_log;
|
||||
@ -53,6 +54,7 @@ pub use background_task::{
|
||||
BackgroundReadyInboxBridgeHandle, BackgroundTaskReadyToDeliver, BoundedTail,
|
||||
CommandBackgroundRunner,
|
||||
};
|
||||
pub use chat_attachments::FsChatAttachmentStore;
|
||||
pub use clock::SystemClock;
|
||||
pub use conversation::InMemoryConversationRegistry;
|
||||
pub use conversation_log::{
|
||||
|
||||
105
crates/infrastructure/tests/chat_attachments.rs
Normal file
105
crates/infrastructure/tests/chat_attachments.rs
Normal file
@ -0,0 +1,105 @@
|
||||
use domain::ports::{
|
||||
ChatAttachmentImport, ChatAttachmentStore, ChatAttachmentStoreError, IdGenerator,
|
||||
};
|
||||
use domain::{
|
||||
AgentId, ChatAttachmentId, ChatAttachmentSourceKind, LocalPath, ProjectId, ProjectPath,
|
||||
SessionId,
|
||||
};
|
||||
use infrastructure::{FsChatAttachmentStore, UuidGenerator};
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"idea-chat-attachment-{tag}-{}",
|
||||
UuidGenerator::new().new_uuid()
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_imports_outside_file_to_project_managed_path() {
|
||||
let project_root = temp_dir("project");
|
||||
let outside_root = temp_dir("outside");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside_root).await.unwrap();
|
||||
let source = outside_root.join("picked.png");
|
||||
tokio::fs::write(&source, b"png bytes").await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let project_path = ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap();
|
||||
let project_id = ProjectId::new_random();
|
||||
let agent_id = AgentId::new_random();
|
||||
let session_id = SessionId::new_random();
|
||||
let attachment_id = ChatAttachmentId::new("attach-1").unwrap();
|
||||
|
||||
let attachment = store
|
||||
.import_from_path(
|
||||
&project_path,
|
||||
project_id,
|
||||
agent_id,
|
||||
session_id,
|
||||
&LocalPath::new(source.to_string_lossy().into_owned()),
|
||||
ChatAttachmentImport {
|
||||
id: attachment_id,
|
||||
filename: "picked.png".to_owned(),
|
||||
mime: "image/png".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
created_at: 42,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attachment.project_id, project_id);
|
||||
assert_eq!(attachment.agent_id, agent_id);
|
||||
assert_eq!(attachment.session_id, session_id);
|
||||
assert_eq!(attachment.filename, "picked.png");
|
||||
assert_eq!(attachment.size_bytes, 9);
|
||||
assert!(attachment
|
||||
.storage_path
|
||||
.starts_with(&format!("agent-chat/{session_id}/")));
|
||||
assert!(attachment
|
||||
.readable_path
|
||||
.starts_with(&format!("{}/.ideai/attachments/", project_root.display())));
|
||||
let stored_bytes = tokio::fs::read(&attachment.readable_path).await.unwrap();
|
||||
assert_eq!(stored_bytes, b"png bytes");
|
||||
|
||||
let listed = store
|
||||
.list_for_session(&project_path, session_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed, vec![attachment]);
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fs_chat_attachment_store_rejects_directories() {
|
||||
let project_root = temp_dir("project-dir");
|
||||
let outside_root = temp_dir("outside-dir");
|
||||
tokio::fs::create_dir_all(&project_root).await.unwrap();
|
||||
tokio::fs::create_dir_all(&outside_root).await.unwrap();
|
||||
|
||||
let store = FsChatAttachmentStore::new();
|
||||
let err = store
|
||||
.import_from_path(
|
||||
&ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
|
||||
ProjectId::new_random(),
|
||||
AgentId::new_random(),
|
||||
SessionId::new_random(),
|
||||
&LocalPath::new(outside_root.to_string_lossy().into_owned()),
|
||||
ChatAttachmentImport {
|
||||
id: ChatAttachmentId::new("attach-2").unwrap(),
|
||||
filename: "outside-dir".to_owned(),
|
||||
mime: "application/octet-stream".to_owned(),
|
||||
source_kind: ChatAttachmentSourceKind::LocalFile,
|
||||
created_at: 42,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, ChatAttachmentStoreError::Invalid(_)));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(project_root).await;
|
||||
let _ = tokio::fs::remove_dir_all(outside_root).await;
|
||||
}
|
||||
Reference in New Issue
Block a user