Skip to main content

cratefield_runtime_native/ports/
blob.rs

1//! A directory-backed [`Blob`] adapter (issue #105): small media on the local
2//! filesystem for a self-hosted deployment. Each object is a file under a base
3//! directory, and its content type is a sibling `.__ct` file. There are no
4//! presigned URLs, so `signed_url` reports [`BlobError::Unsupported`] and
5//! `signed_put_url` inherits the trait default, which reports the same — serve
6//! the bytes through the harness with [`Blob::get`], or reach for an
7//! S3-compatible adapter when direct downloads matter.
8
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::time::Duration;
12
13use async_trait::async_trait;
14use cratefield_core::{Blob, BlobError, BlobObject, check_blob_size};
15
16/// Stores blobs as files under `base`.
17pub struct DirBlob {
18    base: PathBuf,
19}
20
21impl DirBlob {
22    /// A store rooted at `base`. The directory is created on first write.
23    #[must_use]
24    pub fn new(base: impl Into<PathBuf>) -> Self {
25        Self { base: base.into() }
26    }
27
28    /// The store as an `Arc<dyn Blob>`, for `Native::blob_arc`.
29    #[must_use]
30    pub fn arc(base: impl Into<PathBuf>) -> Arc<dyn Blob> {
31        Arc::new(Self::new(base))
32    }
33
34    /// The object path for `key`, refusing an empty, absolute, or escaping key
35    /// (defence in depth — the harness's `ScopedBlob` already prefixes and
36    /// checks, but the adapter must be safe on its own).
37    fn object_path(&self, key: &str) -> Result<PathBuf, BlobError> {
38        if key.is_empty() {
39            return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
40        }
41        if key.starts_with('/') {
42            return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
43        }
44        if key
45            .split('/')
46            .any(|segment| segment == ".." || segment == ".")
47        {
48            return Err(BlobError::BadKey(format!(
49                "key `{key}` must not contain `.` or `..` segments"
50            )));
51        }
52        Ok(self.base.join(key))
53    }
54
55    fn content_type_path(path: &std::path::Path) -> PathBuf {
56        PathBuf::from(format!("{}.__ct", path.display()))
57    }
58
59    fn io_err(err: &std::io::Error) -> BlobError {
60        BlobError::Operation(err.to_string())
61    }
62}
63
64#[async_trait]
65impl Blob for DirBlob {
66    async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
67        check_blob_size(bytes)?;
68        let path = self.object_path(key)?;
69        if let Some(parent) = path.parent() {
70            tokio::fs::create_dir_all(parent)
71                .await
72                .map_err(|err| Self::io_err(&err))?;
73        }
74        tokio::fs::write(&path, bytes)
75            .await
76            .map_err(|err| Self::io_err(&err))?;
77        tokio::fs::write(Self::content_type_path(&path), content_type.as_bytes())
78            .await
79            .map_err(|err| Self::io_err(&err))?;
80        Ok(())
81    }
82
83    async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
84        let path = self.object_path(key)?;
85        let bytes = match tokio::fs::read(&path).await {
86            Ok(bytes) => bytes,
87            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
88            Err(err) => return Err(Self::io_err(&err)),
89        };
90        let content_type = tokio::fs::read_to_string(Self::content_type_path(&path))
91            .await
92            .unwrap_or_else(|_| "application/octet-stream".to_owned());
93        Ok(Some(BlobObject {
94            bytes,
95            content_type,
96        }))
97    }
98
99    async fn delete(&self, key: &str) -> Result<(), BlobError> {
100        let path = self.object_path(key)?;
101        for target in [path.clone(), Self::content_type_path(&path)] {
102            match tokio::fs::remove_file(&target).await {
103                Ok(()) => {}
104                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
105                Err(err) => return Err(Self::io_err(&err)),
106            }
107        }
108        Ok(())
109    }
110
111    /// A directory store has no presigned URLs: neither a `GET` nor (through
112    /// the trait default) a `PUT`.
113    async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
114        Err(BlobError::Unsupported(
115            "a directory store has no presigned URLs; serve the bytes through the harness \
116             or use an S3-compatible adapter"
117                .to_owned(),
118        ))
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn temp_dir() -> PathBuf {
127        std::env::temp_dir().join(format!("cf-blob-{}", uuid_like()))
128    }
129
130    fn uuid_like() -> String {
131        use std::time::{SystemTime, UNIX_EPOCH};
132        format!(
133            "{}",
134            SystemTime::now()
135                .duration_since(UNIX_EPOCH)
136                .unwrap()
137                .as_nanos()
138        )
139    }
140
141    #[tokio::test]
142    async fn round_trips_bytes_and_content_type() {
143        let base = temp_dir();
144        let blob = DirBlob::new(&base);
145        blob.put("cms/hero.png", b"\x89PNG", "image/png")
146            .await
147            .unwrap();
148        let got = blob.get("cms/hero.png").await.unwrap().expect("present");
149        assert_eq!(got.bytes, b"\x89PNG");
150        assert_eq!(got.content_type, "image/png");
151        assert!(blob.get("cms/missing.png").await.unwrap().is_none());
152        let _ = std::fs::remove_dir_all(&base);
153    }
154
155    #[tokio::test]
156    async fn delete_is_idempotent_and_removes_both_files() {
157        let base = temp_dir();
158        let blob = DirBlob::new(&base);
159        blob.put("a/b.txt", b"hi", "text/plain").await.unwrap();
160        blob.delete("a/b.txt").await.unwrap();
161        assert!(blob.get("a/b.txt").await.unwrap().is_none());
162        // A second delete is a no-op, not an error.
163        blob.delete("a/b.txt").await.unwrap();
164        let _ = std::fs::remove_dir_all(&base);
165    }
166
167    #[tokio::test]
168    async fn an_escaping_key_is_refused() {
169        let blob = DirBlob::new(temp_dir());
170        for bad in ["", "/etc/passwd", "../x", "a/../../b", "."] {
171            assert!(matches!(
172                blob.get(bad).await.unwrap_err(),
173                BlobError::BadKey(_)
174            ));
175        }
176    }
177}