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