- Domaine: port ManagedTempStore + modèle (handle opaque, kind Directory/File, état Active/Released, allocation/lease, expiry). - Application: use cases Allocate/Resolve/Release/GcManagedTemp, horodatés via Clock. - Infrastructure: impl FS FsManagedTempStore sous app_data_dir/managed-tmp (root global inter-projets, métadonnées par handle, GC des leases expirés/libérés). - Exposition MCP contrôlée (ManagedTempToolProvider, catalogue) + câblage sandbox: les agents résolvent un handle au lieu d'inventer un path libre. - Tests: application/infrastructure managed_temp.
176 lines
5.0 KiB
Rust
176 lines
5.0 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use domain::ports::ManagedTempStore;
|
|
use domain::{
|
|
ManagedTempAllocation, ManagedTempError, ManagedTempHandle, ManagedTempKind, ManagedTempState,
|
|
ProjectId,
|
|
};
|
|
use infrastructure::FsManagedTempStore;
|
|
use uuid::Uuid;
|
|
|
|
fn temp_app_data(tag: &str) -> PathBuf {
|
|
let unique = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!(
|
|
"idea-managed-temp-{tag}-{}-{unique}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
fn handle(value: &str) -> ManagedTempHandle {
|
|
ManagedTempHandle::new(value).unwrap()
|
|
}
|
|
|
|
fn allocation(
|
|
handle: ManagedTempHandle,
|
|
kind: ManagedTempKind,
|
|
purpose: &str,
|
|
created_at_ms: u64,
|
|
expires_at_ms: Option<u64>,
|
|
) -> ManagedTempAllocation {
|
|
ManagedTempAllocation {
|
|
handle,
|
|
kind,
|
|
owner_project_id: Some(ProjectId::from_uuid(Uuid::from_u128(42))),
|
|
purpose: purpose.to_owned(),
|
|
created_at_ms,
|
|
expires_at_ms,
|
|
}
|
|
}
|
|
|
|
fn assert_under(path: &Path, root: &Path) {
|
|
assert!(
|
|
path.starts_with(root),
|
|
"{} should be under {}",
|
|
path.display(),
|
|
root.display()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allocate_resolve_release_and_gc_directory() {
|
|
let app_data = temp_app_data("dir");
|
|
let store = FsManagedTempStore::new(&app_data);
|
|
let temp_root = std::env::temp_dir();
|
|
assert_eq!(store.root(), app_data.join("managed-tmp"));
|
|
assert_ne!(store.root(), temp_root.as_path());
|
|
|
|
let lease = store
|
|
.allocate(allocation(
|
|
handle("alloc_dir_1"),
|
|
ManagedTempKind::Directory,
|
|
"scratch",
|
|
1_000,
|
|
Some(2_000),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
let path = PathBuf::from(lease.path.as_str());
|
|
assert_eq!(lease.state, ManagedTempState::Active);
|
|
assert_eq!(lease.last_access_at_ms, 1_000);
|
|
assert_under(&path, store.root());
|
|
assert!(tokio::fs::metadata(&path).await.unwrap().is_dir());
|
|
assert!(
|
|
tokio::fs::metadata(store.root().join("alloc_dir_1").join("metadata.json"))
|
|
.await
|
|
.is_ok()
|
|
);
|
|
|
|
let resolved = store.resolve(&handle("alloc_dir_1"), 1_500).await.unwrap();
|
|
assert_eq!(resolved.state, ManagedTempState::Active);
|
|
assert_eq!(resolved.last_access_at_ms, 1_500);
|
|
|
|
let released = store.release(&handle("alloc_dir_1"), 1_600).await.unwrap();
|
|
assert_eq!(released.state, ManagedTempState::Released);
|
|
assert_eq!(released.released_at_ms, Some(1_600));
|
|
assert!(matches!(
|
|
store.resolve(&handle("alloc_dir_1"), 1_700).await,
|
|
Err(ManagedTempError::NotFound)
|
|
));
|
|
|
|
let report = store.gc(1_700).await.unwrap();
|
|
assert_eq!(report.deleted, 1);
|
|
assert!(tokio::fs::metadata(store.root().join("alloc_dir_1"))
|
|
.await
|
|
.is_err());
|
|
|
|
let _ = tokio::fs::remove_dir_all(app_data).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn expired_file_is_not_resolved_and_is_gc_eligible() {
|
|
let app_data = temp_app_data("file");
|
|
let store = FsManagedTempStore::new(&app_data);
|
|
|
|
let lease = store
|
|
.allocate(allocation(
|
|
handle("alloc_file_1"),
|
|
ManagedTempKind::File,
|
|
"../../must-not-affect-path",
|
|
1_000,
|
|
Some(1_100),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let path = PathBuf::from(lease.path.as_str());
|
|
assert_under(&path, store.root());
|
|
assert_eq!(path.file_name().unwrap(), "data");
|
|
assert!(tokio::fs::metadata(&path).await.unwrap().is_file());
|
|
|
|
assert!(matches!(
|
|
store.resolve(&handle("alloc_file_1"), 1_200).await,
|
|
Err(ManagedTempError::NotFound)
|
|
));
|
|
assert_eq!(store.gc(1_200).await.unwrap().deleted, 1);
|
|
assert!(tokio::fs::metadata(path).await.is_err());
|
|
|
|
let _ = tokio::fs::remove_dir_all(app_data).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_path_like_handles_are_rejected_before_filesystem_resolution() {
|
|
assert!(ManagedTempHandle::new("../escape").is_err());
|
|
assert!(ManagedTempHandle::new("a/b").is_err());
|
|
assert!(ManagedTempHandle::new("..\\escape").is_err());
|
|
assert!(ManagedTempHandle::new("").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn handle_collision_does_not_reuse_existing_allocation() {
|
|
let app_data = temp_app_data("collision");
|
|
let store = FsManagedTempStore::new(&app_data);
|
|
|
|
store
|
|
.allocate(allocation(
|
|
handle("same_handle"),
|
|
ManagedTempKind::Directory,
|
|
"first",
|
|
1_000,
|
|
None,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(matches!(
|
|
store
|
|
.allocate(allocation(
|
|
handle("same_handle"),
|
|
ManagedTempKind::Directory,
|
|
"second",
|
|
1_100,
|
|
None,
|
|
))
|
|
.await,
|
|
Err(ManagedTempError::Store(_))
|
|
));
|
|
|
|
let resolved = store.resolve(&handle("same_handle"), 1_200).await.unwrap();
|
|
assert_eq!(resolved.purpose, "first");
|
|
|
|
let _ = tokio::fs::remove_dir_all(app_data).await;
|
|
}
|