Skip to main content

agent_workspace_adapter_local/
snapshot_object_store.rs

1use agent_workspace_contract::{
2    SnapshotObjectReader, SnapshotObjectRef, SnapshotObjectStore, SnapshotObjectUpload,
3    WORKSPACE_UPLOAD_CHUNK_BYTES,
4};
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
11
12fn validated_key(root: &Path, key: &str) -> Result<PathBuf> {
13    anyhow::ensure!(
14        !key.is_empty()
15            && key.len() <= 512
16            && !key.starts_with('/')
17            && key.split('/').all(|part| {
18                !part.is_empty()
19                    && part != "."
20                    && part != ".."
21                    && part.bytes().all(|byte| {
22                        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
23                    })
24            }),
25        "invalid snapshot object key"
26    );
27    Ok(root.join(key))
28}
29
30#[derive(Debug, Clone)]
31pub struct FilesystemSnapshotObjectStore {
32    root: Arc<PathBuf>,
33}
34
35impl FilesystemSnapshotObjectStore {
36    pub fn for_local_development(root: PathBuf) -> Result<Self> {
37        std::fs::create_dir_all(&root)?;
38        Ok(Self {
39            root: Arc::new(std::fs::canonicalize(root)?),
40        })
41    }
42}
43
44#[derive(Debug)]
45struct FilesystemUpload {
46    key: String,
47    destination: PathBuf,
48    temporary: PathBuf,
49    file: tokio::fs::File,
50    digest: Sha256,
51    written: u64,
52}
53
54#[async_trait]
55impl SnapshotObjectUpload for FilesystemUpload {
56    async fn write_chunk(&mut self, bytes: &[u8]) -> Result<()> {
57        anyhow::ensure!(
58            bytes.len() <= WORKSPACE_UPLOAD_CHUNK_BYTES,
59            "snapshot object chunk exceeds policy"
60        );
61        self.file.write_all(bytes).await?;
62        self.digest.update(bytes);
63        self.written = self.written.saturating_add(bytes.len() as u64);
64        Ok(())
65    }
66
67    async fn commit(
68        mut self: Box<Self>,
69        digest: &str,
70        size_bytes: u64,
71    ) -> Result<SnapshotObjectRef> {
72        self.file.flush().await?;
73        self.file.sync_all().await?;
74        let actual = format!("sha256:{:x}", self.digest.finalize_reset());
75        anyhow::ensure!(actual == digest, "snapshot object digest mismatch");
76        anyhow::ensure!(self.written == size_bytes, "snapshot object size mismatch");
77        if let Some(parent) = self.destination.parent() {
78            tokio::fs::create_dir_all(parent).await?;
79        }
80        match tokio::fs::rename(&self.temporary, &self.destination).await {
81            Ok(()) => {}
82            Err(_error) if self.destination.exists() => {
83                tokio::fs::remove_file(&self.temporary).await.ok();
84                let existing = FilesystemReader {
85                    path: self.destination.clone(),
86                    digest: digest.to_owned(),
87                    len: size_bytes,
88                };
89                verify_reader(&existing).await?;
90            }
91            Err(error) => return Err(error.into()),
92        }
93        Ok(SnapshotObjectRef {
94            key: self.key.clone(),
95            digest: digest.to_owned(),
96            size_bytes,
97        })
98    }
99
100    async fn abort(self: Box<Self>) -> Result<()> {
101        match tokio::fs::remove_file(&self.temporary).await {
102            Ok(()) => Ok(()),
103            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
104            Err(error) => Err(error.into()),
105        }
106    }
107}
108
109#[derive(Debug)]
110struct FilesystemReader {
111    path: PathBuf,
112    digest: String,
113    len: u64,
114}
115
116#[async_trait]
117impl SnapshotObjectReader for FilesystemReader {
118    fn len(&self) -> u64 {
119        self.len
120    }
121
122    fn digest(&self) -> &str {
123        &self.digest
124    }
125
126    async fn read_chunk(&self, offset: u64, max_bytes: usize) -> Result<Vec<u8>> {
127        anyhow::ensure!(
128            max_bytes <= WORKSPACE_UPLOAD_CHUNK_BYTES,
129            "snapshot read chunk exceeds policy"
130        );
131        if offset >= self.len {
132            return Ok(Vec::new());
133        }
134        let remaining = usize::try_from((self.len - offset).min(max_bytes as u64))?;
135        let mut file = tokio::fs::File::open(&self.path).await?;
136        file.seek(std::io::SeekFrom::Start(offset)).await?;
137        let mut bytes = vec![0; remaining];
138        file.read_exact(&mut bytes).await?;
139        Ok(bytes)
140    }
141}
142
143async fn verify_reader(reader: &FilesystemReader) -> Result<()> {
144    let mut hasher = Sha256::new();
145    let mut offset = 0;
146    while offset < reader.len {
147        let bytes = reader
148            .read_chunk(offset, WORKSPACE_UPLOAD_CHUNK_BYTES)
149            .await?;
150        anyhow::ensure!(!bytes.is_empty(), "truncated snapshot object");
151        hasher.update(&bytes);
152        offset = offset.saturating_add(bytes.len() as u64);
153    }
154    anyhow::ensure!(
155        format!("sha256:{:x}", hasher.finalize()) == reader.digest,
156        "existing snapshot object digest mismatch"
157    );
158    Ok(())
159}
160
161#[async_trait]
162impl SnapshotObjectStore for FilesystemSnapshotObjectStore {
163    async fn begin_upload(&self, key: &str) -> Result<Box<dyn SnapshotObjectUpload>> {
164        let destination = validated_key(&self.root, key)?;
165        let parent = destination
166            .parent()
167            .context("snapshot object parent missing")?;
168        tokio::fs::create_dir_all(parent).await?;
169        let temporary = parent.join(format!(".upload-{}", uuid::Uuid::new_v4().simple()));
170        let file = tokio::fs::OpenOptions::new()
171            .create_new(true)
172            .write(true)
173            .open(&temporary)
174            .await?;
175        Ok(Box::new(FilesystemUpload {
176            key: key.to_owned(),
177            destination,
178            temporary,
179            file,
180            digest: Sha256::new(),
181            written: 0,
182        }))
183    }
184
185    async fn open(&self, object: &SnapshotObjectRef) -> Result<Arc<dyn SnapshotObjectReader>> {
186        let path = validated_key(&self.root, &object.key)?;
187        let len = tokio::fs::metadata(&path).await?.len();
188        anyhow::ensure!(len == object.size_bytes, "snapshot object size mismatch");
189        Ok(Arc::new(FilesystemReader {
190            path,
191            digest: object.digest.clone(),
192            len,
193        }))
194    }
195
196    async fn delete(&self, object: &SnapshotObjectRef) -> Result<()> {
197        let path = validated_key(&self.root, &object.key)?;
198        match tokio::fs::remove_file(path).await {
199            Ok(()) => Ok(()),
200            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
201            Err(error) => Err(error.into()),
202        }
203    }
204}