Skip to main content

agentos_vfs/s3/
object_backend.rs

1use super::block_store::{collect_body, s3_error};
2use async_trait::async_trait;
3use aws_sdk_s3::primitives::ByteStream;
4use aws_sdk_s3::Client;
5use base64::engine::general_purpose::STANDARD as BASE64;
6use base64::Engine as _;
7use std::collections::HashMap;
8use vfs::engine::{
9    InodeType, ObjectBackend, ObjectEntry, ObjectMeta, Timespec, VfsError, VfsResult,
10};
11
12#[derive(Debug, Clone, Default)]
13pub struct S3ObjectBackendOptions {
14    pub prefix: String,
15}
16
17#[derive(Debug, Clone)]
18pub struct S3ObjectBackend {
19    client: Client,
20    bucket: String,
21    options: S3ObjectBackendOptions,
22}
23
24impl S3ObjectBackend {
25    pub fn new(client: Client, bucket: impl Into<String>) -> Self {
26        Self::with_options(client, bucket, S3ObjectBackendOptions::default())
27    }
28
29    pub fn with_options(
30        client: Client,
31        bucket: impl Into<String>,
32        options: S3ObjectBackendOptions,
33    ) -> Self {
34        Self {
35            client,
36            bucket: bucket.into(),
37            options,
38        }
39    }
40
41    fn key_for(&self, key: &str) -> String {
42        format!("{}{}", self.options.prefix, key)
43    }
44
45    fn strip_prefix<'a>(&self, key: &'a str) -> &'a str {
46        key.strip_prefix(&self.options.prefix).unwrap_or(key)
47    }
48}
49
50#[async_trait]
51impl ObjectBackend for S3ObjectBackend {
52    async fn list(&self, prefix: &str) -> VfsResult<Vec<ObjectEntry>> {
53        let s3_prefix = self.key_for(prefix);
54        let mut continuation = None;
55        let mut entries = Vec::new();
56        loop {
57            let response = self
58                .client
59                .list_objects_v2()
60                .bucket(&self.bucket)
61                .prefix(&s3_prefix)
62                .delimiter("/")
63                .set_continuation_token(continuation)
64                .send()
65                .await
66                .map_err(|err| s3_error(format!("list s3 prefix '{s3_prefix}': {err}")))?;
67
68            for object in response.contents() {
69                let Some(key) = object.key() else {
70                    continue;
71                };
72                entries.push(ObjectEntry {
73                    name: self.strip_prefix(key).to_string(),
74                    size: object.size().unwrap_or(0).max(0) as u64,
75                    mtime: object
76                        .last_modified()
77                        .map(timespec_from_smithy)
78                        .unwrap_or_else(Timespec::now),
79                    is_prefix: false,
80                });
81            }
82
83            for prefix in response.common_prefixes() {
84                let Some(prefix) = prefix.prefix() else {
85                    continue;
86                };
87                entries.push(ObjectEntry {
88                    name: self.strip_prefix(prefix).to_string(),
89                    size: 0,
90                    mtime: Timespec::now(),
91                    is_prefix: true,
92                });
93            }
94
95            if response.is_truncated().unwrap_or(false) {
96                continuation = response.next_continuation_token().map(ToOwned::to_owned);
97            } else {
98                break;
99            }
100        }
101        Ok(entries)
102    }
103
104    async fn head(&self, key: &str) -> VfsResult<Option<ObjectMeta>> {
105        let s3_key = self.key_for(key);
106        let response = match self
107            .client
108            .head_object()
109            .bucket(&self.bucket)
110            .key(&s3_key)
111            .send()
112            .await
113        {
114            Ok(response) => response,
115            Err(err)
116                if err.as_service_error().is_some_and(|err| {
117                    err.is_not_found()
118                        || matches!(err.meta().code(), Some("NotFound" | "NoSuchKey"))
119                }) =>
120            {
121                return Ok(None);
122            }
123            Err(err) => return Err(s3_error(format!("head s3 object '{s3_key}': {err}"))),
124        };
125
126        let metadata = response.metadata();
127        let kind = metadata
128            .and_then(|metadata| metadata.get("vfs-kind"))
129            .map(|kind| match kind.as_str() {
130                "directory" => InodeType::Directory,
131                "symlink" => InodeType::Symlink,
132                "character-device" => InodeType::CharacterDevice,
133                "block-device" => InodeType::BlockDevice,
134                "fifo" => InodeType::Fifo,
135                _ => InodeType::File,
136            })
137            .unwrap_or(InodeType::File);
138        let xattrs = metadata
139            .and_then(|metadata| metadata.get("vfs-xattrs"))
140            .map(|encoded| {
141                let bytes = BASE64
142                    .decode(encoded)
143                    .map_err(|error| VfsError::eio(format!("decode S3 xattrs: {error}")))?;
144                serde_json::from_slice(&bytes)
145                    .map_err(|error| VfsError::eio(format!("parse S3 xattrs: {error}")))
146            })
147            .transpose()?
148            .unwrap_or_default();
149        let size = response.content_length().unwrap_or(0).max(0) as u64;
150        let allocated_extents = metadata
151            .and_then(|metadata| metadata.get("vfs-allocated-extents"))
152            .map(|encoded| {
153                let bytes = BASE64.decode(encoded).map_err(|error| {
154                    VfsError::eio(format!("decode S3 allocated extents: {error}"))
155                })?;
156                serde_json::from_slice(&bytes)
157                    .map_err(|error| VfsError::eio(format!("parse S3 allocated extents: {error}")))
158            })
159            .transpose()?
160            .unwrap_or_else(|| {
161                if size == 0 {
162                    Vec::new()
163                } else {
164                    vec![(0, size.div_ceil(512))]
165                }
166            });
167        let fallback_time = response
168            .last_modified()
169            .map(timespec_from_smithy)
170            .unwrap_or_else(Timespec::now);
171        Ok(Some(ObjectMeta {
172            size,
173            allocated_extents,
174            atime: metadata
175                .and_then(|metadata| metadata.get("vfs-atime"))
176                .and_then(|value| parse_timespec(value))
177                .unwrap_or(fallback_time),
178            mtime: metadata
179                .and_then(|metadata| metadata.get("vfs-mtime"))
180                .and_then(|value| parse_timespec(value))
181                .unwrap_or(fallback_time),
182            ctime: metadata
183                .and_then(|metadata| metadata.get("vfs-ctime"))
184                .and_then(|value| parse_timespec(value))
185                .unwrap_or(fallback_time),
186            birthtime: metadata
187                .and_then(|metadata| metadata.get("vfs-birthtime"))
188                .and_then(|value| parse_timespec(value))
189                .unwrap_or(fallback_time),
190            mode: metadata
191                .and_then(|metadata| metadata.get("vfs-mode"))
192                .and_then(|mode| u32::from_str_radix(mode, 8).ok())
193                .unwrap_or(0o644),
194            uid: metadata
195                .and_then(|metadata| metadata.get("vfs-uid"))
196                .and_then(|uid| uid.parse().ok())
197                .unwrap_or(0),
198            gid: metadata
199                .and_then(|metadata| metadata.get("vfs-gid"))
200                .and_then(|gid| gid.parse().ok())
201                .unwrap_or(0),
202            kind,
203            symlink_target: metadata
204                .and_then(|metadata| metadata.get("vfs-symlink-target").cloned()),
205            link_id: metadata.and_then(|metadata| metadata.get("vfs-link-id").cloned()),
206            xattrs,
207        }))
208    }
209
210    async fn get_range(&self, key: &str, off: u64, len: u64) -> VfsResult<Vec<u8>> {
211        if len == 0 {
212            return Ok(Vec::new());
213        }
214        let s3_key = self.key_for(key);
215        let end = off
216            .checked_add(len)
217            .and_then(|value| value.checked_sub(1))
218            .ok_or_else(|| vfs::engine::VfsError::einval("invalid S3 byte range"))?;
219        let response = self
220            .client
221            .get_object()
222            .bucket(&self.bucket)
223            .key(&s3_key)
224            .range(format!("bytes={off}-{end}"))
225            .send()
226            .await
227            .map_err(|err| s3_error(format!("get s3 object range '{s3_key}': {err}")))?;
228        collect_body(response.body, &s3_key).await
229    }
230
231    async fn put(&self, key: &str, data: &[u8], meta: ObjectMeta) -> VfsResult<()> {
232        let s3_key = self.key_for(key);
233        let mut metadata = HashMap::new();
234        metadata.insert("vfs-kind".to_string(), kind_name(meta.kind).to_string());
235        metadata.insert("vfs-mode".to_string(), format!("{:o}", meta.mode));
236        metadata.insert("vfs-uid".to_string(), meta.uid.to_string());
237        metadata.insert("vfs-gid".to_string(), meta.gid.to_string());
238        metadata.insert("vfs-atime".to_string(), format_timespec(meta.atime));
239        metadata.insert("vfs-mtime".to_string(), format_timespec(meta.mtime));
240        metadata.insert("vfs-ctime".to_string(), format_timespec(meta.ctime));
241        metadata.insert("vfs-birthtime".to_string(), format_timespec(meta.birthtime));
242        let encoded_extents = serde_json::to_vec(&meta.allocated_extents)
243            .map(|bytes| BASE64.encode(bytes))
244            .map_err(|error| VfsError::eio(format!("serialize S3 allocated extents: {error}")))?;
245        metadata.insert("vfs-allocated-extents".to_string(), encoded_extents);
246        if let Some(target) = meta.symlink_target {
247            metadata.insert("vfs-symlink-target".to_string(), target);
248        }
249        if let Some(link_id) = meta.link_id {
250            metadata.insert("vfs-link-id".to_string(), link_id);
251        }
252        if !meta.xattrs.is_empty() {
253            let encoded = serde_json::to_vec(&meta.xattrs)
254                .map(|bytes| BASE64.encode(bytes))
255                .map_err(|error| VfsError::eio(format!("serialize S3 xattrs: {error}")))?;
256            metadata.insert("vfs-xattrs".to_string(), encoded);
257        }
258        self.client
259            .put_object()
260            .bucket(&self.bucket)
261            .key(&s3_key)
262            .body(ByteStream::from(data.to_vec()))
263            .set_metadata(Some(metadata))
264            .send()
265            .await
266            .map_err(|err| s3_error(format!("put s3 object '{s3_key}': {err}")))?;
267        Ok(())
268    }
269
270    async fn copy(&self, src: &str, dst: &str) -> VfsResult<()> {
271        let src_key = self.key_for(src);
272        let dst_key = self.key_for(dst);
273        self.client
274            .copy_object()
275            .bucket(&self.bucket)
276            .copy_source(format!("{}/{}", self.bucket, src_key))
277            .key(&dst_key)
278            .send()
279            .await
280            .map_err(|err| s3_error(format!("copy s3 object '{src_key}' to '{dst_key}': {err}")))?;
281        Ok(())
282    }
283
284    async fn delete(&self, key: &str) -> VfsResult<()> {
285        let s3_key = self.key_for(key);
286        self.client
287            .delete_object()
288            .bucket(&self.bucket)
289            .key(&s3_key)
290            .send()
291            .await
292            .map_err(|err| s3_error(format!("delete s3 object '{s3_key}': {err}")))?;
293        Ok(())
294    }
295}
296
297fn kind_name(kind: InodeType) -> &'static str {
298    match kind {
299        InodeType::File => "file",
300        InodeType::Directory => "directory",
301        InodeType::Symlink => "symlink",
302        InodeType::CharacterDevice => "character-device",
303        InodeType::BlockDevice => "block-device",
304        InodeType::Fifo => "fifo",
305    }
306}
307
308fn timespec_from_smithy(time: &aws_sdk_s3::primitives::DateTime) -> Timespec {
309    Timespec {
310        sec: time.secs(),
311        nsec: 0,
312    }
313}
314
315fn format_timespec(time: Timespec) -> String {
316    format!("{}:{}", time.sec, time.nsec)
317}
318
319fn parse_timespec(value: &str) -> Option<Timespec> {
320    let (seconds, nanoseconds) = value.split_once(':')?;
321    let sec = seconds.parse().ok()?;
322    let nsec = nanoseconds.parse().ok()?;
323    (nsec < 1_000_000_000).then_some(Timespec { sec, nsec })
324}