agent_workspace_adapter_local/
snapshot_object_store.rs1use 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}