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