cratefield_runtime_native/ports/
blob.rs1use 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
15pub struct DirBlob {
17 base: PathBuf,
18}
19
20impl DirBlob {
21 #[must_use]
23 pub fn new(base: impl Into<PathBuf>) -> Self {
24 Self { base: base.into() }
25 }
26
27 #[must_use]
29 pub fn arc(base: impl Into<PathBuf>) -> Arc<dyn Blob> {
30 Arc::new(Self::new(base))
31 }
32
33 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 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}