- 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.
210 lines
6.1 KiB
Rust
210 lines
6.1 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use domain::ports::{Clock, IdGenerator, LocalPath, ManagedTempStore};
|
|
use domain::{
|
|
ManagedTempAllocation, ManagedTempError, ManagedTempGcReport, ManagedTempHandle,
|
|
ManagedTempKind, ManagedTempLease, ManagedTempState, ProjectId,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
AllocateManagedTemp, AllocateManagedTempInput, AppError, ResolveManagedTemp,
|
|
ResolveManagedTempInput, MANAGED_TEMP_DEFAULT_TTL_MS, MANAGED_TEMP_PURPOSE_MAX_CHARS,
|
|
};
|
|
|
|
struct FixedClock;
|
|
|
|
impl Clock for FixedClock {
|
|
fn now_millis(&self) -> i64 {
|
|
10_000
|
|
}
|
|
}
|
|
|
|
struct FixedIds;
|
|
|
|
impl IdGenerator for FixedIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
Uuid::from_u128(0x1234)
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeManagedTempStore {
|
|
allocations: Mutex<Vec<ManagedTempAllocation>>,
|
|
leases: Mutex<HashMap<ManagedTempHandle, ManagedTempLease>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ManagedTempStore for FakeManagedTempStore {
|
|
async fn allocate(
|
|
&self,
|
|
allocation: ManagedTempAllocation,
|
|
) -> Result<ManagedTempLease, ManagedTempError> {
|
|
let lease = ManagedTempLease {
|
|
handle: allocation.handle.clone(),
|
|
kind: allocation.kind,
|
|
owner_project_id: allocation.owner_project_id,
|
|
purpose: allocation.purpose.clone(),
|
|
path: LocalPath::new(format!("/managed/{}", allocation.handle.as_str())),
|
|
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,
|
|
};
|
|
self.allocations.lock().unwrap().push(allocation);
|
|
self.leases
|
|
.lock()
|
|
.unwrap()
|
|
.insert(lease.handle.clone(), lease.clone());
|
|
Ok(lease)
|
|
}
|
|
|
|
async fn resolve(
|
|
&self,
|
|
handle: &ManagedTempHandle,
|
|
_now_ms: u64,
|
|
) -> Result<ManagedTempLease, ManagedTempError> {
|
|
self.leases
|
|
.lock()
|
|
.unwrap()
|
|
.get(handle)
|
|
.cloned()
|
|
.ok_or(ManagedTempError::NotFound)
|
|
}
|
|
|
|
async fn release(
|
|
&self,
|
|
handle: &ManagedTempHandle,
|
|
now_ms: u64,
|
|
) -> Result<ManagedTempLease, ManagedTempError> {
|
|
let mut leases = self.leases.lock().unwrap();
|
|
let lease = leases.get_mut(handle).ok_or(ManagedTempError::NotFound)?;
|
|
lease.state = ManagedTempState::Released;
|
|
lease.released_at_ms = Some(now_ms);
|
|
Ok(lease.clone())
|
|
}
|
|
|
|
async fn gc(&self, _now_ms: u64) -> Result<ManagedTempGcReport, ManagedTempError> {
|
|
Ok(ManagedTempGcReport { deleted: 0 })
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allocate_uses_generated_opaque_handle_and_default_ttl() {
|
|
let store = Arc::new(FakeManagedTempStore::default());
|
|
let use_case = AllocateManagedTemp::new(
|
|
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock),
|
|
);
|
|
let owner = ProjectId::from_uuid(Uuid::from_u128(7));
|
|
|
|
let output = use_case
|
|
.execute(AllocateManagedTempInput {
|
|
owner_project_id: Some(owner),
|
|
kind: ManagedTempKind::Directory,
|
|
purpose: " shared scratch ".to_owned(),
|
|
ttl_ms: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
output.lease.handle.as_str(),
|
|
"00000000-0000-0000-0000-000000001234"
|
|
);
|
|
assert_eq!(
|
|
output.lease.path.as_str(),
|
|
"/managed/00000000-0000-0000-0000-000000001234"
|
|
);
|
|
|
|
let allocations = store.allocations.lock().unwrap();
|
|
assert_eq!(allocations.len(), 1);
|
|
assert_eq!(allocations[0].owner_project_id, Some(owner));
|
|
assert_eq!(allocations[0].purpose, "shared scratch");
|
|
assert_eq!(allocations[0].created_at_ms, 10_000);
|
|
assert_eq!(
|
|
allocations[0].expires_at_ms,
|
|
Some(10_000 + MANAGED_TEMP_DEFAULT_TTL_MS)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allocate_rejects_empty_or_zero_ttl_and_bounds_purpose_metadata() {
|
|
let store = Arc::new(FakeManagedTempStore::default());
|
|
let use_case = AllocateManagedTemp::new(
|
|
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock),
|
|
);
|
|
|
|
let empty = use_case
|
|
.execute(AllocateManagedTempInput {
|
|
owner_project_id: None,
|
|
kind: ManagedTempKind::File,
|
|
purpose: " ".to_owned(),
|
|
ttl_ms: Some(1),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(empty, AppError::Invalid(_)));
|
|
|
|
let zero_ttl = use_case
|
|
.execute(AllocateManagedTempInput {
|
|
owner_project_id: None,
|
|
kind: ManagedTempKind::File,
|
|
purpose: "scratch".to_owned(),
|
|
ttl_ms: Some(0),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(zero_ttl, AppError::Invalid(_)));
|
|
|
|
let overflow_ttl = use_case
|
|
.execute(AllocateManagedTempInput {
|
|
owner_project_id: None,
|
|
kind: ManagedTempKind::File,
|
|
purpose: "scratch".to_owned(),
|
|
ttl_ms: Some(u64::MAX),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(overflow_ttl, AppError::Invalid(_)));
|
|
|
|
use_case
|
|
.execute(AllocateManagedTempInput {
|
|
owner_project_id: None,
|
|
kind: ManagedTempKind::File,
|
|
purpose: "x".repeat(MANAGED_TEMP_PURPOSE_MAX_CHARS + 10),
|
|
ttl_ms: Some(5),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let allocations = store.allocations.lock().unwrap();
|
|
assert_eq!(
|
|
allocations[0].purpose.chars().count(),
|
|
MANAGED_TEMP_PURPOSE_MAX_CHARS
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn resolve_rejects_path_like_handle_before_store_call() {
|
|
let store = Arc::new(FakeManagedTempStore::default());
|
|
let use_case = ResolveManagedTemp::new(
|
|
Arc::clone(&store) as Arc<dyn ManagedTempStore>,
|
|
Arc::new(FixedClock),
|
|
);
|
|
|
|
let err = use_case
|
|
.execute(ResolveManagedTempInput {
|
|
handle: "../escape".to_owned(),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(err, AppError::Invalid(_)));
|
|
}
|