1use async_trait::async_trait;
6use crate::error::Result;
7
8#[async_trait]
13pub trait ChunkTransport: Send + Sync {
14 async fn put_chunk(&self, hash: &str, data: &[u8]) -> Result<()>;
16
17 async fn get_chunk(&self, hash: &str) -> Result<Vec<u8>>;
19
20 async fn has_chunk(&self, hash: &str) -> Result<bool>;
22
23 async fn put_manifest(&self, data: &[u8]) -> Result<()>;
25
26 async fn get_manifest(&self) -> Result<Option<Vec<u8>>>;
28
29 async fn health_check(&self) -> Result<()>;
31}
32
33use std::path::PathBuf;
34
35const CAS_DIR: &str = ".foxing_cas";
36const CHUNKS_DIR: &str = "chunks";
37const TMP_DIR: &str = "chunks/tmp";
38const MANIFEST_FILE: &str = "manifest.bin";
39
40#[derive(Debug)]
45pub struct PosixChunkTransport {
46 root: PathBuf,
47}
48
49impl PosixChunkTransport {
50 pub fn new(root: PathBuf) -> Self {
51 Self { root }
52 }
53
54 fn chunk_path(&self, hash: &str) -> PathBuf {
55 let prefix = &hash[..std::cmp::min(2, hash.len())];
56 self.root.join(CAS_DIR).join(CHUNKS_DIR).join(prefix).join(hash)
57 }
58
59 fn tmp_dir(&self) -> PathBuf {
60 self.root.join(CAS_DIR).join(TMP_DIR)
61 }
62
63 fn manifest_path(&self) -> PathBuf {
64 self.root.join(CAS_DIR).join(MANIFEST_FILE)
65 }
66
67 fn tmp_name() -> String {
68 uuid::Uuid::new_v4().to_string()
69 }
70}
71
72#[async_trait]
73impl ChunkTransport for PosixChunkTransport {
74 async fn put_chunk(&self, hash: &str, data: &[u8]) -> Result<()> {
75 use std::io::Write;
76
77 let final_path = self.chunk_path(hash);
78 if final_path.exists() {
79 return Ok(());
80 }
81
82 if let Some(parent) = final_path.parent() {
83 std::fs::create_dir_all(parent)?;
84 }
85
86 let tmp_dir = self.tmp_dir();
87 std::fs::create_dir_all(&tmp_dir)?;
88
89 let tmp_path = tmp_dir.join(Self::tmp_name());
91 {
92 let mut f = std::fs::File::create(&tmp_path)?;
93 f.write_all(data)?;
94 f.sync_all()?;
95 }
96 std::fs::rename(&tmp_path, &final_path)?;
97 if let Some(parent) = final_path.parent()
98 && let Ok(dir) = std::fs::File::open(parent) {
99 let _ = dir.sync_all();
100 }
101
102 Ok(())
103 }
104
105 async fn get_chunk(&self, hash: &str) -> Result<Vec<u8>> {
106 let path = self.chunk_path(hash);
107 std::fs::read(&path).map_err(|e| {
108 if e.kind() == std::io::ErrorKind::NotFound {
109 crate::error::FxcpError::ChunkMissing(hash.to_string())
110 } else {
111 crate::error::FxcpError::Io(e)
112 }
113 })
114 }
115
116 async fn has_chunk(&self, hash: &str) -> Result<bool> {
117 Ok(self.chunk_path(hash).exists())
118 }
119
120 async fn put_manifest(&self, data: &[u8]) -> Result<()> {
121 use std::io::Write;
122
123 let final_path = self.manifest_path();
124 if let Some(parent) = final_path.parent() {
125 std::fs::create_dir_all(parent)?;
126 }
127
128 let tmp_dir = self.tmp_dir();
129 std::fs::create_dir_all(&tmp_dir)?;
130
131 let tmp_path = tmp_dir.join(Self::tmp_name());
133 {
134 let mut f = std::fs::File::create(&tmp_path)?;
135 f.write_all(data)?;
136 f.sync_all()?;
137 }
138 std::fs::rename(&tmp_path, &final_path)?;
139 if let Some(parent) = final_path.parent()
140 && let Ok(dir) = std::fs::File::open(parent) {
141 let _ = dir.sync_all();
142 }
143
144 Ok(())
145 }
146
147 async fn get_manifest(&self) -> Result<Option<Vec<u8>>> {
148 let path = self.manifest_path();
149 match std::fs::read(&path) {
150 Ok(data) => Ok(Some(data)),
151 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
152 Err(e) => Err(crate::error::FxcpError::Io(e)),
153 }
154 }
155
156 async fn health_check(&self) -> Result<()> {
157 let cas_dir = self.root.join(CAS_DIR);
158 std::fs::create_dir_all(&cas_dir)?;
159
160 let probe = cas_dir.join(format!(".health_probe_{}", Self::tmp_name()));
161 std::fs::write(&probe, b"ok").map_err(|e| {
162 crate::error::FxcpError::CasStoreError(format!("health check write failed: {}", e))
163 })?;
164 std::fs::remove_file(&probe).map_err(|e| {
165 crate::error::FxcpError::CasStoreError(format!("health check cleanup failed: {}", e))
166 })?;
167
168 Ok(())
169 }
170}
171
172#[cfg(feature = "cloud")]
173mod s3_transport {
174 use super::ChunkTransport;
175 use crate::error::{FxcpError, Result};
176 use async_trait::async_trait;
177 use object_store::{ObjectStore, PutPayload, path::Path as ObjPath};
178 use std::sync::Arc;
179
180 pub struct S3ChunkTransport {
184 store: Arc<dyn ObjectStore>,
185 prefix: String,
186 }
187
188 impl S3ChunkTransport {
189 pub fn new(store: Arc<dyn ObjectStore>, prefix: String) -> Self {
190 Self { store, prefix }
191 }
192
193 fn chunk_path(&self, hash: &str) -> ObjPath {
194 let shard = &hash[..4.min(hash.len())];
195 ObjPath::from(format!(
196 "{}/.foxing_cas/chunks/{}/{}",
197 self.prefix, shard, hash
198 ))
199 }
200
201 fn manifest_path(&self) -> ObjPath {
202 ObjPath::from(format!(
203 "{}/.foxing_cas/transport_manifest.bin",
204 self.prefix
205 ))
206 }
207 }
208
209 #[async_trait]
210 impl ChunkTransport for S3ChunkTransport {
211 async fn put_chunk(&self, hash: &str, data: &[u8]) -> Result<()> {
212 let path = self.chunk_path(hash);
213 self.store
214 .put(&path, PutPayload::from(data.to_vec()))
215 .await
216 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
217 Ok(())
218 }
219
220 async fn get_chunk(&self, hash: &str) -> Result<Vec<u8>> {
221 let path = self.chunk_path(hash);
222 let result = self.store.get(&path).await.map_err(|e| match e {
223 object_store::Error::NotFound { .. } => {
224 FxcpError::ChunkMissing(hash.to_string())
225 }
226 other => FxcpError::CasStoreError(other.to_string()),
227 })?;
228 let bytes = result
229 .bytes()
230 .await
231 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
232 Ok(bytes.to_vec())
233 }
234
235 async fn has_chunk(&self, hash: &str) -> Result<bool> {
236 let path = self.chunk_path(hash);
237 Ok(self.store.head(&path).await.is_ok())
238 }
239
240 async fn put_manifest(&self, data: &[u8]) -> Result<()> {
241 let path = self.manifest_path();
242 self.store
243 .put(&path, PutPayload::from(data.to_vec()))
244 .await
245 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
246 Ok(())
247 }
248
249 async fn get_manifest(&self) -> Result<Option<Vec<u8>>> {
250 let path = self.manifest_path();
251 match self.store.get(&path).await {
252 Ok(result) => {
253 let bytes = result
254 .bytes()
255 .await
256 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
257 Ok(Some(bytes.to_vec()))
258 }
259 Err(object_store::Error::NotFound { .. }) => Ok(None),
260 Err(e) => Err(FxcpError::CasStoreError(e.to_string())),
261 }
262 }
263
264 async fn health_check(&self) -> Result<()> {
265 let probe_path = ObjPath::from(format!(
266 "{}/.foxing_cas/_health_probe",
267 self.prefix
268 ));
269 self.store
270 .put(&probe_path, PutPayload::from(b"ok".to_vec()))
271 .await
272 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
273 self.store
274 .delete(&probe_path)
275 .await
276 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
277 Ok(())
278 }
279 }
280}
281
282#[cfg(feature = "cloud")]
283pub use s3_transport::S3ChunkTransport;
284
285#[cfg(test)]
286mod tests {
287 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
288 use super::*;
289 use std::collections::HashMap;
290 use std::sync::Mutex;
291
292 struct InMemoryChunkTransport {
293 chunks: Mutex<HashMap<String, Vec<u8>>>,
294 manifest: Mutex<Option<Vec<u8>>>,
295 }
296
297 impl InMemoryChunkTransport {
298 fn new() -> Self {
299 Self {
300 chunks: Mutex::new(HashMap::new()),
301 manifest: Mutex::new(None),
302 }
303 }
304 }
305
306 #[async_trait::async_trait]
307 impl ChunkTransport for InMemoryChunkTransport {
308 async fn put_chunk(&self, hash: &str, data: &[u8]) -> Result<()> {
309 self.chunks.lock().unwrap().insert(hash.to_string(), data.to_vec());
310 Ok(())
311 }
312 async fn get_chunk(&self, hash: &str) -> Result<Vec<u8>> {
313 self.chunks.lock().unwrap().get(hash)
314 .cloned()
315 .ok_or_else(|| crate::error::FxcpError::ChunkMissing(hash.to_string()))
316 }
317 async fn has_chunk(&self, hash: &str) -> Result<bool> {
318 Ok(self.chunks.lock().unwrap().contains_key(hash))
319 }
320 async fn put_manifest(&self, data: &[u8]) -> Result<()> {
321 *self.manifest.lock().unwrap() = Some(data.to_vec());
322 Ok(())
323 }
324 async fn get_manifest(&self) -> Result<Option<Vec<u8>>> {
325 Ok(self.manifest.lock().unwrap().clone())
326 }
327 async fn health_check(&self) -> Result<()> {
328 Ok(())
329 }
330 }
331
332 #[tokio::test]
333 async fn test_put_get_round_trip() {
334 let t = InMemoryChunkTransport::new();
335 t.put_chunk("abc", b"hello world").await.unwrap();
336 let got = t.get_chunk("abc").await.unwrap();
337 assert_eq!(got, b"hello world");
338 }
339
340 #[tokio::test]
341 async fn test_get_missing_returns_chunk_missing_error() {
342 let t = InMemoryChunkTransport::new();
343 let err = t.get_chunk("nonexistent").await;
344 assert!(err.is_err());
345 match err.unwrap_err() {
346 crate::error::FxcpError::ChunkMissing(h) => assert_eq!(h, "nonexistent"),
347 other => panic!("expected ChunkMissing, got {:?}", other),
348 }
349 }
350
351 #[tokio::test]
352 async fn test_has_chunk_before_and_after_put() {
353 let t = InMemoryChunkTransport::new();
354 assert!(!t.has_chunk("xyz").await.unwrap());
355 t.put_chunk("xyz", b"data").await.unwrap();
356 assert!(t.has_chunk("xyz").await.unwrap());
357 }
358
359 #[tokio::test]
360 async fn test_manifest_round_trip() {
361 let t = InMemoryChunkTransport::new();
362 assert!(t.get_manifest().await.unwrap().is_none());
363 t.put_manifest(b"manifest data").await.unwrap();
364 let got = t.get_manifest().await.unwrap();
365 assert_eq!(got.as_deref(), Some(b"manifest data".as_slice()));
366 }
367
368 #[tokio::test]
369 async fn test_health_check() {
370 let t = InMemoryChunkTransport::new();
371 t.health_check().await.unwrap();
372 }
373
374 #[tokio::test]
375 async fn test_posix_put_get_roundtrip() {
376 let dir = tempfile::tempdir().unwrap();
377 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
378 t.put_chunk("abc123", b"hello world").await.unwrap();
379 let got = t.get_chunk("abc123").await.unwrap();
380 assert_eq!(got, b"hello world");
381 }
382
383 #[tokio::test]
384 async fn test_posix_get_missing_returns_chunk_missing() {
385 let dir = tempfile::tempdir().unwrap();
386 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
387 let err = t.get_chunk("nonexistent").await;
388 assert!(err.is_err());
389 match err.unwrap_err() {
390 crate::error::FxcpError::ChunkMissing(h) => assert_eq!(h, "nonexistent"),
391 other => panic!("expected ChunkMissing, got {:?}", other),
392 }
393 }
394
395 #[tokio::test]
396 async fn test_posix_has_chunk_before_and_after() {
397 let dir = tempfile::tempdir().unwrap();
398 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
399 assert!(!t.has_chunk("xyz789").await.unwrap());
400 t.put_chunk("xyz789", b"data").await.unwrap();
401 assert!(t.has_chunk("xyz789").await.unwrap());
402 }
403
404 #[tokio::test]
405 async fn test_posix_manifest_roundtrip() {
406 let dir = tempfile::tempdir().unwrap();
407 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
408 assert!(t.get_manifest().await.unwrap().is_none());
409 t.put_manifest(b"manifest payload").await.unwrap();
410 let got = t.get_manifest().await.unwrap();
411 assert_eq!(got.as_deref(), Some(b"manifest payload".as_slice()));
412 }
413
414 #[tokio::test]
415 async fn test_posix_health_check_writable() {
416 let dir = tempfile::tempdir().unwrap();
417 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
418 t.health_check().await.unwrap();
419 }
420
421 #[tokio::test]
422 async fn test_posix_no_tmp_files_after_put() {
423 let dir = tempfile::tempdir().unwrap();
424 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
425 t.put_chunk("aabbcc", b"some data").await.unwrap();
426
427 let tmp_dir = dir.path().join(".foxing_cas").join("chunks").join("tmp");
428 if tmp_dir.exists() {
429 let entries: Vec<_> = std::fs::read_dir(&tmp_dir).unwrap().collect();
430 assert!(entries.is_empty(), "tmp directory should be empty after put_chunk, found {} files", entries.len());
431 }
432 }
433
434 #[tokio::test]
435 async fn test_posix_shard_path_is_two_char() {
436 let dir = tempfile::tempdir().unwrap();
437 let t = super::PosixChunkTransport::new(dir.path().to_path_buf());
438 t.put_chunk("deadbeef0123", b"shard test").await.unwrap();
439
440 let expected_shard = dir.path().join(".foxing_cas").join("chunks").join("de");
441 assert!(expected_shard.is_dir(), "shard directory 'de' should exist");
442
443 let chunk_file = expected_shard.join("deadbeef0123");
444 assert!(chunk_file.exists(), "chunk file should be in 2-char shard directory");
445 }
446}
447
448#[cfg(all(test, feature = "cloud"))]
449mod s3_tests {
450 use super::*;
451 use object_store::memory::InMemory;
452 use object_store::{ObjectStore, PutPayload, path::Path as ObjPath};
453 use std::sync::Arc;
454
455 fn make_transport() -> S3ChunkTransport {
456 S3ChunkTransport::new(Arc::new(InMemory::new()), "test".to_string())
457 }
458
459 #[tokio::test]
460 async fn test_s3_put_get_roundtrip() {
461 let t = make_transport();
462 t.put_chunk("abc", b"hello").await.unwrap();
463 let got = t.get_chunk("abc").await.unwrap();
464 assert_eq!(got, b"hello");
465 }
466
467 #[tokio::test]
468 async fn test_s3_get_missing_returns_chunk_missing() {
469 let t = make_transport();
470 let err = t.get_chunk("nonexistent").await.unwrap_err();
471 match err {
472 crate::error::FxcpError::ChunkMissing(h) => assert_eq!(h, "nonexistent"),
473 other => panic!("expected ChunkMissing, got {other:?}"),
474 }
475 }
476
477 #[tokio::test]
478 async fn test_s3_has_chunk_true_false() {
479 let t = make_transport();
480 assert!(!t.has_chunk("xyz").await.unwrap());
481 t.put_chunk("xyz", b"data").await.unwrap();
482 assert!(t.has_chunk("xyz").await.unwrap());
483 }
484
485 #[tokio::test]
486 async fn test_s3_manifest_roundtrip() {
487 let t = make_transport();
488 assert!(t.get_manifest().await.unwrap().is_none());
489 t.put_manifest(b"manifest bytes").await.unwrap();
490 let got = t.get_manifest().await.unwrap();
491 assert_eq!(got.as_deref(), Some(b"manifest bytes".as_slice()));
492 }
493
494 #[tokio::test]
495 async fn test_s3_health_check() {
496 let t = make_transport();
497 t.health_check().await.unwrap();
498 }
499
500 #[tokio::test]
501 async fn test_s3_put_chunk_error_propagates() {
502 #[derive(Debug)]
503 struct FailingPutStore;
504
505 impl std::fmt::Display for FailingPutStore {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 write!(f, "FailingPutStore")
508 }
509 }
510
511 #[async_trait::async_trait]
512 impl ObjectStore for FailingPutStore {
513 async fn put_opts(
514 &self,
515 _location: &ObjPath,
516 _payload: PutPayload,
517 _opts: object_store::PutOptions,
518 ) -> object_store::Result<object_store::PutResult> {
519 Err(object_store::Error::Generic {
520 store: "FailingPutStore",
521 source: "simulated failure".into(),
522 })
523 }
524
525 async fn put_multipart_opts(
526 &self,
527 _location: &ObjPath,
528 _opts: object_store::PutMultipartOpts,
529 ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
530 Err(object_store::Error::Generic {
531 store: "FailingPutStore",
532 source: "simulated failure".into(),
533 })
534 }
535
536 async fn get_opts(
537 &self,
538 _location: &ObjPath,
539 _options: object_store::GetOptions,
540 ) -> object_store::Result<object_store::GetResult> {
541 Err(object_store::Error::Generic {
542 store: "FailingPutStore",
543 source: "simulated failure".into(),
544 })
545 }
546
547 async fn delete(&self, _location: &ObjPath) -> object_store::Result<()> {
548 Ok(())
549 }
550
551 fn list(
552 &self,
553 _prefix: Option<&ObjPath>,
554 ) -> futures::stream::BoxStream<'_, object_store::Result<object_store::ObjectMeta>>
555 {
556 futures::stream::empty().boxed()
557 }
558
559 async fn list_with_delimiter(
560 &self,
561 _prefix: Option<&ObjPath>,
562 ) -> object_store::Result<object_store::ListResult> {
563 Ok(object_store::ListResult {
564 common_prefixes: vec![],
565 objects: vec![],
566 })
567 }
568
569 async fn copy(
570 &self,
571 _from: &ObjPath,
572 _to: &ObjPath,
573 ) -> object_store::Result<()> {
574 Ok(())
575 }
576
577 async fn copy_if_not_exists(
578 &self,
579 _from: &ObjPath,
580 _to: &ObjPath,
581 ) -> object_store::Result<()> {
582 Ok(())
583 }
584 }
585
586 use futures::StreamExt;
587
588 let t = S3ChunkTransport::new(Arc::new(FailingPutStore), "test".to_string());
589 let err = t.put_chunk("abc", b"data").await;
590 assert!(err.is_err());
591 match err.unwrap_err() {
592 crate::error::FxcpError::CasStoreError(_) => {}
593 other => panic!("expected CasStoreError, got {other:?}"),
594 }
595 }
596
597 #[tokio::test]
598 async fn test_s3_chunk_path_uses_four_char_shard() {
599 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
600 let t = S3ChunkTransport::new(Arc::clone(&store), "repo".to_string());
601
602 t.put_chunk("abcdef0123456789", b"payload").await.unwrap();
603
604 let expected = ObjPath::from("repo/.foxing_cas/chunks/abcd/abcdef0123456789");
605 let meta = store.head(&expected).await;
606 assert!(meta.is_ok(), "chunk not found at expected 4-char shard path");
607 }
608}