use std::collections::HashMap;
use std::sync::Arc;
use arcan_sandbox::{SandboxId, SandboxProvider};
use chrono::{DateTime, Utc};
use lago_core::BlobHash;
use lago_store::BlobStore;
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct FileManifestEntry {
pub id: Uuid,
pub sandbox_id: SandboxId,
pub session_id: String,
pub path: String,
pub size_bytes: u64,
pub sha256: String,
pub blob_hash: Option<BlobHash>,
pub mode: u32,
pub written_at: DateTime<Utc>,
pub deleted: bool,
pub provider_at_write: String,
}
#[derive(Debug, Default)]
pub struct SandboxManifest {
entries: HashMap<(String, String), FileManifestEntry>,
}
impl SandboxManifest {
pub fn new() -> Self {
Self::default()
}
pub fn upsert(&mut self, entry: FileManifestEntry) {
let key = (entry.sandbox_id.0.clone(), entry.path.clone());
self.entries.insert(key, entry);
}
pub fn get(&self, sandbox_id: &SandboxId, path: &str) -> Option<&FileManifestEntry> {
self.entries.get(&(sandbox_id.0.clone(), path.to_owned()))
}
pub fn list_sandbox(&self, sandbox_id: &SandboxId) -> Vec<&FileManifestEntry> {
let sid = sandbox_id.0.as_str();
let mut entries: Vec<_> = self
.entries
.iter()
.filter(|((s, _), _)| s == sid)
.map(|(_, v)| v)
.collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub struct FileWrittenParams<'a> {
pub sandbox_id: &'a SandboxId,
pub session_id: &'a str,
pub path: &'a str,
pub size_bytes: u64,
pub sha256: &'a str,
pub mode: u32,
pub provider: &'a Arc<dyn SandboxProvider>,
pub blob_store: &'a Arc<BlobStore>,
pub provider_name: &'a str,
}
pub async fn sync_file_written(p: FileWrittenParams<'_>) -> FileManifestEntry {
let blob_hash = match p.provider.read_file(p.sandbox_id, p.path).await {
Ok(content) => match p.blob_store.put(&content) {
Ok(hash) => {
debug!(
sandbox_id = %p.sandbox_id, path = p.path, ?hash,
"file synced to Lago blob store"
);
Some(hash)
}
Err(e) => {
warn!(sandbox_id = %p.sandbox_id, path = p.path, error = %e, "blob store put failed");
None
}
},
Err(e) => {
debug!(
sandbox_id = %p.sandbox_id, path = p.path, error = %e,
"read_file unavailable, manifest entry recorded without blob"
);
None
}
};
FileManifestEntry {
id: Uuid::new_v4(),
sandbox_id: p.sandbox_id.clone(),
session_id: p.session_id.to_owned(),
path: p.path.to_owned(),
size_bytes: p.size_bytes,
sha256: p.sha256.to_owned(),
blob_hash,
mode: p.mode,
written_at: Utc::now(),
deleted: false,
provider_at_write: p.provider_name.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entry(sandbox_id: &str, path: &str) -> FileManifestEntry {
FileManifestEntry {
id: Uuid::new_v4(),
sandbox_id: SandboxId(sandbox_id.to_owned()),
session_id: "sess-1".to_owned(),
path: path.to_owned(),
size_bytes: 100,
sha256: "abc123".to_owned(),
blob_hash: None,
mode: 0o644,
written_at: Utc::now(),
deleted: false,
provider_at_write: "stub".to_owned(),
}
}
#[test]
fn upsert_and_get() {
let mut m = SandboxManifest::new();
let entry = make_entry("box-1", "/workspace/main.py");
m.upsert(entry);
let got = m.get(&SandboxId("box-1".into()), "/workspace/main.py");
assert!(got.is_some());
assert_eq!(got.unwrap().size_bytes, 100);
}
#[test]
fn upsert_replaces_existing() {
let mut m = SandboxManifest::new();
let e1 = make_entry("box-1", "/file.txt");
let mut e2 = make_entry("box-1", "/file.txt");
e2.size_bytes = 999;
m.upsert(e1);
m.upsert(e2);
assert_eq!(
m.get(&SandboxId("box-1".into()), "/file.txt")
.unwrap()
.size_bytes,
999
);
assert_eq!(m.len(), 1);
}
#[test]
fn list_sandbox_filters_by_id() {
let mut m = SandboxManifest::new();
m.upsert(make_entry("box-A", "/a.py"));
m.upsert(make_entry("box-A", "/b.py"));
m.upsert(make_entry("box-B", "/c.py"));
let a_entries = m.list_sandbox(&SandboxId("box-A".into()));
assert_eq!(a_entries.len(), 2);
let b_entries = m.list_sandbox(&SandboxId("box-B".into()));
assert_eq!(b_entries.len(), 1);
}
#[test]
fn list_sandbox_sorted_by_path() {
let mut m = SandboxManifest::new();
m.upsert(make_entry("box-1", "/z.py"));
m.upsert(make_entry("box-1", "/a.py"));
m.upsert(make_entry("box-1", "/m.py"));
let entries = m.list_sandbox(&SandboxId("box-1".into()));
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(paths, ["/a.py", "/m.py", "/z.py"]);
}
#[tokio::test]
async fn sync_file_written_records_without_blob_when_read_file_unsupported() {
use arcan_sandbox::{
ExecRequest, ExecResult, SandboxCapabilitySet, SandboxHandle, SandboxId, SandboxInfo,
SandboxSpec, SnapshotId,
};
use async_trait::async_trait;
struct NoReadProvider;
#[async_trait]
impl SandboxProvider for NoReadProvider {
fn name(&self) -> &'static str {
"stub-no-read"
}
fn capabilities(&self) -> SandboxCapabilitySet {
SandboxCapabilitySet::FILESYSTEM_READ
}
async fn create(
&self,
_: SandboxSpec,
) -> Result<SandboxHandle, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn resume(
&self,
_: &SandboxId,
) -> Result<SandboxHandle, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn run(
&self,
_: &SandboxId,
_: ExecRequest,
) -> Result<ExecResult, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn snapshot(
&self,
_: &SandboxId,
) -> Result<SnapshotId, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn destroy(&self, _: &SandboxId) -> Result<(), arcan_sandbox::SandboxError> {
Ok(())
}
async fn list(&self) -> Result<Vec<SandboxInfo>, arcan_sandbox::SandboxError> {
Ok(vec![])
}
}
let dir = tempfile::tempdir().unwrap();
let blob_store = Arc::new(BlobStore::open(dir.path().join("blobs")).unwrap());
let provider: Arc<dyn SandboxProvider> = Arc::new(NoReadProvider);
let entry = sync_file_written(FileWrittenParams {
sandbox_id: &SandboxId("box-1".into()),
session_id: "sess-1",
path: "/workspace/main.py",
size_bytes: 14,
sha256: "abc123def456",
mode: 0o644,
provider: &provider,
blob_store: &blob_store,
provider_name: "stub-no-read",
})
.await;
assert_eq!(entry.path, "/workspace/main.py");
assert_eq!(entry.size_bytes, 14);
assert_eq!(entry.sha256, "abc123def456");
assert!(entry.blob_hash.is_none()); assert_eq!(entry.provider_at_write, "stub-no-read");
}
#[tokio::test]
async fn sync_file_written_stores_blob_when_read_file_succeeds() {
use arcan_sandbox::{
ExecRequest, ExecResult, SandboxCapabilitySet, SandboxHandle, SandboxId, SandboxInfo,
SandboxSpec, SnapshotId,
};
use async_trait::async_trait;
struct ReadProvider;
#[async_trait]
impl SandboxProvider for ReadProvider {
fn name(&self) -> &'static str {
"stub-read"
}
fn capabilities(&self) -> SandboxCapabilitySet {
SandboxCapabilitySet::FILESYSTEM_READ | SandboxCapabilitySet::FILESYSTEM_WRITE
}
async fn create(
&self,
_: SandboxSpec,
) -> Result<SandboxHandle, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn resume(
&self,
_: &SandboxId,
) -> Result<SandboxHandle, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn run(
&self,
_: &SandboxId,
_: ExecRequest,
) -> Result<ExecResult, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn snapshot(
&self,
_: &SandboxId,
) -> Result<SnapshotId, arcan_sandbox::SandboxError> {
unreachable!("not called in test")
}
async fn destroy(&self, _: &SandboxId) -> Result<(), arcan_sandbox::SandboxError> {
Ok(())
}
async fn list(&self) -> Result<Vec<SandboxInfo>, arcan_sandbox::SandboxError> {
Ok(vec![])
}
async fn read_file(
&self,
_: &SandboxId,
_: &str,
) -> Result<Vec<u8>, arcan_sandbox::SandboxError> {
Ok(b"print('hello')".to_vec())
}
}
let dir = tempfile::tempdir().unwrap();
let blob_store = Arc::new(BlobStore::open(dir.path().join("blobs")).unwrap());
let provider: Arc<dyn SandboxProvider> = Arc::new(ReadProvider);
let entry = sync_file_written(FileWrittenParams {
sandbox_id: &SandboxId("box-1".into()),
session_id: "sess-1",
path: "/workspace/main.py",
size_bytes: 14,
sha256: "irrelevant-sha",
mode: 0o644,
provider: &provider,
blob_store: &blob_store,
provider_name: "stub-read",
})
.await;
assert!(entry.blob_hash.is_some());
let content = blob_store.get(entry.blob_hash.as_ref().unwrap()).unwrap();
assert_eq!(content, b"print('hello')");
}
}