agentos_vfs/s3/
object_backend.rs1use super::block_store::{collect_body, s3_error};
2use async_trait::async_trait;
3use aws_sdk_s3::primitives::ByteStream;
4use aws_sdk_s3::Client;
5use std::collections::HashMap;
6use vfs::engine::{InodeType, ObjectBackend, ObjectEntry, ObjectMeta, Timespec, VfsResult};
7
8#[derive(Debug, Clone, Default)]
9pub struct S3ObjectBackendOptions {
10 pub prefix: String,
11}
12
13#[derive(Debug, Clone)]
14pub struct S3ObjectBackend {
15 client: Client,
16 bucket: String,
17 options: S3ObjectBackendOptions,
18}
19
20impl S3ObjectBackend {
21 pub fn new(client: Client, bucket: impl Into<String>) -> Self {
22 Self::with_options(client, bucket, S3ObjectBackendOptions::default())
23 }
24
25 pub fn with_options(
26 client: Client,
27 bucket: impl Into<String>,
28 options: S3ObjectBackendOptions,
29 ) -> Self {
30 Self {
31 client,
32 bucket: bucket.into(),
33 options,
34 }
35 }
36
37 fn key_for(&self, key: &str) -> String {
38 format!("{}{}", self.options.prefix, key)
39 }
40
41 fn strip_prefix<'a>(&self, key: &'a str) -> &'a str {
42 key.strip_prefix(&self.options.prefix).unwrap_or(key)
43 }
44}
45
46#[async_trait]
47impl ObjectBackend for S3ObjectBackend {
48 async fn list(&self, prefix: &str) -> VfsResult<Vec<ObjectEntry>> {
49 let s3_prefix = self.key_for(prefix);
50 let mut continuation = None;
51 let mut entries = Vec::new();
52 loop {
53 let response = self
54 .client
55 .list_objects_v2()
56 .bucket(&self.bucket)
57 .prefix(&s3_prefix)
58 .delimiter("/")
59 .set_continuation_token(continuation)
60 .send()
61 .await
62 .map_err(|err| s3_error(format!("list s3 prefix '{s3_prefix}': {err}")))?;
63
64 for object in response.contents() {
65 let Some(key) = object.key() else {
66 continue;
67 };
68 entries.push(ObjectEntry {
69 name: self.strip_prefix(key).to_string(),
70 size: object.size().unwrap_or(0).max(0) as u64,
71 mtime: object
72 .last_modified()
73 .map(timespec_from_smithy)
74 .unwrap_or_else(Timespec::now),
75 is_prefix: false,
76 });
77 }
78
79 for prefix in response.common_prefixes() {
80 let Some(prefix) = prefix.prefix() else {
81 continue;
82 };
83 entries.push(ObjectEntry {
84 name: self.strip_prefix(prefix).to_string(),
85 size: 0,
86 mtime: Timespec::now(),
87 is_prefix: true,
88 });
89 }
90
91 if response.is_truncated().unwrap_or(false) {
92 continuation = response.next_continuation_token().map(ToOwned::to_owned);
93 } else {
94 break;
95 }
96 }
97 Ok(entries)
98 }
99
100 async fn head(&self, key: &str) -> VfsResult<Option<ObjectMeta>> {
101 let s3_key = self.key_for(key);
102 let response = match self
103 .client
104 .head_object()
105 .bucket(&self.bucket)
106 .key(&s3_key)
107 .send()
108 .await
109 {
110 Ok(response) => response,
111 Err(err)
112 if err.as_service_error().is_some_and(|err| {
113 err.is_not_found()
114 || matches!(err.meta().code(), Some("NotFound" | "NoSuchKey"))
115 }) =>
116 {
117 return Ok(None);
118 }
119 Err(err) => return Err(s3_error(format!("head s3 object '{s3_key}': {err}"))),
120 };
121
122 let metadata = response.metadata();
123 let kind = metadata
124 .and_then(|metadata| metadata.get("vfs-kind"))
125 .map(|kind| match kind.as_str() {
126 "directory" => InodeType::Directory,
127 "symlink" => InodeType::Symlink,
128 _ => InodeType::File,
129 })
130 .unwrap_or(InodeType::File);
131 Ok(Some(ObjectMeta {
132 size: response.content_length().unwrap_or(0).max(0) as u64,
133 mtime: response
134 .last_modified()
135 .map(timespec_from_smithy)
136 .unwrap_or_else(Timespec::now),
137 mode: metadata
138 .and_then(|metadata| metadata.get("vfs-mode"))
139 .and_then(|mode| u32::from_str_radix(mode, 8).ok())
140 .unwrap_or(0o644),
141 uid: metadata
142 .and_then(|metadata| metadata.get("vfs-uid"))
143 .and_then(|uid| uid.parse().ok())
144 .unwrap_or(0),
145 gid: metadata
146 .and_then(|metadata| metadata.get("vfs-gid"))
147 .and_then(|gid| gid.parse().ok())
148 .unwrap_or(0),
149 kind,
150 symlink_target: metadata
151 .and_then(|metadata| metadata.get("vfs-symlink-target").cloned()),
152 }))
153 }
154
155 async fn get_range(&self, key: &str, off: u64, len: u64) -> VfsResult<Vec<u8>> {
156 if len == 0 {
157 return Ok(Vec::new());
158 }
159 let s3_key = self.key_for(key);
160 let end = off
161 .checked_add(len)
162 .and_then(|value| value.checked_sub(1))
163 .ok_or_else(|| vfs::engine::VfsError::einval("invalid S3 byte range"))?;
164 let response = self
165 .client
166 .get_object()
167 .bucket(&self.bucket)
168 .key(&s3_key)
169 .range(format!("bytes={off}-{end}"))
170 .send()
171 .await
172 .map_err(|err| s3_error(format!("get s3 object range '{s3_key}': {err}")))?;
173 collect_body(response.body, &s3_key).await
174 }
175
176 async fn put(&self, key: &str, data: &[u8], meta: ObjectMeta) -> VfsResult<()> {
177 let s3_key = self.key_for(key);
178 let mut metadata = HashMap::new();
179 metadata.insert("vfs-kind".to_string(), kind_name(meta.kind).to_string());
180 metadata.insert("vfs-mode".to_string(), format!("{:o}", meta.mode));
181 metadata.insert("vfs-uid".to_string(), meta.uid.to_string());
182 metadata.insert("vfs-gid".to_string(), meta.gid.to_string());
183 if let Some(target) = meta.symlink_target {
184 metadata.insert("vfs-symlink-target".to_string(), target);
185 }
186 self.client
187 .put_object()
188 .bucket(&self.bucket)
189 .key(&s3_key)
190 .body(ByteStream::from(data.to_vec()))
191 .set_metadata(Some(metadata))
192 .send()
193 .await
194 .map_err(|err| s3_error(format!("put s3 object '{s3_key}': {err}")))?;
195 Ok(())
196 }
197
198 async fn copy(&self, src: &str, dst: &str) -> VfsResult<()> {
199 let src_key = self.key_for(src);
200 let dst_key = self.key_for(dst);
201 self.client
202 .copy_object()
203 .bucket(&self.bucket)
204 .copy_source(format!("{}/{}", self.bucket, src_key))
205 .key(&dst_key)
206 .send()
207 .await
208 .map_err(|err| s3_error(format!("copy s3 object '{src_key}' to '{dst_key}': {err}")))?;
209 Ok(())
210 }
211
212 async fn delete(&self, key: &str) -> VfsResult<()> {
213 let s3_key = self.key_for(key);
214 self.client
215 .delete_object()
216 .bucket(&self.bucket)
217 .key(&s3_key)
218 .send()
219 .await
220 .map_err(|err| s3_error(format!("delete s3 object '{s3_key}': {err}")))?;
221 Ok(())
222 }
223}
224
225fn kind_name(kind: InodeType) -> &'static str {
226 match kind {
227 InodeType::File => "file",
228 InodeType::Directory => "directory",
229 InodeType::Symlink => "symlink",
230 }
231}
232
233fn timespec_from_smithy(time: &aws_sdk_s3::primitives::DateTime) -> Timespec {
234 Timespec {
235 sec: time.secs(),
236 nsec: 0,
237 }
238}