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 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}