1use std::collections::{HashMap, HashSet};
23use std::io::{self, BufRead, Write};
24use std::path::{Path, PathBuf};
25use std::sync::RwLock;
26use serde::{Serialize, Deserialize};
27use tracing::{info, warn, debug};
28
29use crate::chunk_delta::{ChunkRef, compute_chunk_delta};
30use crate::chunker::GearChunker;
31use crate::error::FxcpError;
32
33const CAS_DIR: &str = ".foxing_cas";
34const CHUNKS_DIR: &str = "chunks";
35const TMP_DIR: &str = "chunks/tmp";
36const MANIFEST_FILE: &str = "manifest.jsonl";
37const CONFIG_FILE: &str = "config.json";
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CasConfig {
46 pub version: u32,
47 pub chunk_min: usize,
48 pub chunk_avg: usize,
49 pub chunk_max: usize,
50 pub compression: String,
51 #[serde(default)]
53 pub normalization_level: u8,
54}
55
56impl Default for CasConfig {
57 fn default() -> Self {
58 Self {
59 version: 1,
60 chunk_min: crate::chunker::DEFAULT_CHUNK_MIN,
61 chunk_avg: crate::chunker::DEFAULT_CHUNK_AVG,
62 chunk_max: crate::chunker::DEFAULT_CHUNK_MAX,
63 compression: "zstd".into(),
64 normalization_level: 0,
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct FileMeta {
72 pub size: u64,
73 pub mode: u32,
74 pub mtime: i64,
75 pub uid: u32,
76 pub gid: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "op")]
82pub enum ManifestOp {
83 #[serde(rename = "put")]
84 Put {
85 path: String,
86 #[serde(flatten)]
87 meta: FileMeta,
88 blake3: String,
89 chunks: Vec<String>,
90 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 chunk_sizes: Vec<u64>,
92 },
93 #[serde(rename = "delete")]
94 Delete { path: String },
95 #[serde(rename = "rename")]
96 Rename { from: String, to: String },
97 #[serde(rename = "snapshot")]
98 Snapshot { name: String },
99}
100
101#[derive(Debug, Clone)]
103pub struct FileEntry {
104 pub path: String,
105 pub meta: FileMeta,
106 pub blake3: String,
107 pub chunks: Vec<String>,
108 pub chunk_sizes: Vec<u64>,
109}
110
111#[derive(Debug, Default)]
113pub struct CasStoreStats {
114 pub chunks_written: u64,
115 pub chunks_deduped: u64,
116 pub bytes_written: u64,
117 pub bytes_deduped: u64,
118}
119
120#[derive(Debug, Default)]
122pub struct CompactStats {
123 pub files_kept: u64,
124 pub ops_removed: u64,
125 pub chunks_removed: u64,
126 pub bytes_freed: u64,
127}
128
129#[derive(Debug, Clone)]
131pub struct BatchStats {
132 pub files: usize,
134 pub fsyncs: usize,
138}
139
140#[derive(Debug)]
142pub struct DeltaStoreStats {
143 pub chunks_added: u64,
144 pub chunks_reused: u64,
145 pub bytes_uploaded: u64,
146 pub bytes_skipped: u64,
147 pub efficiency: f64,
148}
149
150pub struct CasStore {
156 root: PathBuf,
157 config: CasConfig,
158 compact_lock: RwLock<()>,
162}
163
164impl std::fmt::Debug for CasStore {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.debug_struct("CasStore")
167 .field("root", &self.root)
168 .field("config", &self.config)
169 .finish()
170 }
171}
172
173impl CasStore {
174 pub fn open(path: &Path) -> crate::Result<Self> {
176 let root = path.join(CAS_DIR);
177 std::fs::create_dir_all(root.join(TMP_DIR))?;
178
179 let config_path = root.join(CONFIG_FILE);
181 let config = if config_path.exists() {
182 let data = std::fs::read_to_string(&config_path)?;
183 serde_json::from_str(&data).unwrap_or_default()
184 } else {
185 let config = CasConfig::default();
186 let json = serde_json::to_string_pretty(&config)
187 .map_err(|e| FxcpError::CasStoreError(e.to_string()))?;
188 std::fs::write(&config_path, json)?;
189 config
190 };
191
192 if config.normalization_level > 2 {
193 return Err(FxcpError::Config(
194 "normalization_level must be 0..=2".into(),
195 ));
196 }
197
198 Ok(Self { root, config, compact_lock: RwLock::new(()) })
199 }
200
201 pub fn store_file(&self, rel_path: &str, data: &[u8], meta: &FileMeta) -> crate::Result<CasStoreStats> {
203 let _guard = self.compact_lock.read()
204 .unwrap_or_else(|e| e.into_inner());
205
206 let chunker = GearChunker::new(self.config.chunk_min, self.config.chunk_avg, self.config.chunk_max)?
207 .with_normalization(self.config.normalization_level)?;
208 let file_hash = blake3::hash(data);
209 let mut stats = CasStoreStats::default();
210 let mut chunk_hashes = Vec::new();
211 let mut chunk_sizes = Vec::new();
212
213 let boundaries = chunker.find_boundaries(data);
214 let mut start = 0;
215 for end in &boundaries {
216 let slice = &data[start..*end];
217 let hash = blake3::hash(slice);
218 let hash_cid = crate::cid::blake3_to_cid_string(hash.as_bytes());
219
220 chunk_sizes.push(slice.len() as u64);
221 if self.chunk_exists(&hash_cid) {
222 stats.chunks_deduped += 1;
223 stats.bytes_deduped += slice.len() as u64;
224 } else {
225 let compressed = crate::fxar::compress_chunk(slice, &self.config.compression);
226 self.write_chunk_atomic(&hash_cid, &compressed)?;
227 stats.chunks_written += 1;
228 stats.bytes_written += compressed.len() as u64;
229 }
230 chunk_hashes.push(hash_cid);
231 start = *end;
232 }
233
234 let op = ManifestOp::Put {
235 path: rel_path.to_string(),
236 meta: meta.clone(),
237 blake3: crate::cid::blake3_to_cid_string(file_hash.as_bytes()),
238 chunks: chunk_hashes,
239 chunk_sizes,
240 };
241 self.append_manifest(&op)?;
242
243 Ok(stats)
244 }
245
246 pub fn delta_store_file(
248 &self,
249 rel_path: &str,
250 data: &[u8],
251 meta: &FileMeta,
252 ) -> crate::Result<DeltaStoreStats> {
253 let files = self.list_files()?;
254 let old_entry = files.iter().find(|f| f.path == rel_path);
255
256 let old_entry = match old_entry {
257 Some(e) if e.chunks.len() >= 3 && !e.chunk_sizes.is_empty() => e,
258 _ => {
259 let stats = self.store_file(rel_path, data, meta)?;
260 return Ok(DeltaStoreStats {
261 chunks_added: stats.chunks_written + stats.chunks_deduped,
262 chunks_reused: 0,
263 bytes_uploaded: stats.bytes_written,
264 bytes_skipped: 0,
265 efficiency: 0.0,
266 });
267 }
268 };
269
270 let old_chunks: Vec<ChunkRef> = old_entry
271 .chunks
272 .iter()
273 .zip(old_entry.chunk_sizes.iter().copied())
274 .map(|(h, s)| ChunkRef { hash: h.clone(), size: s })
275 .collect();
276
277 let _guard = self.compact_lock.read().unwrap_or_else(|e| e.into_inner());
278
279 let chunker = GearChunker::new(
280 self.config.chunk_min,
281 self.config.chunk_avg,
282 self.config.chunk_max,
283 )?
284 .with_normalization(self.config.normalization_level)?;
285
286 let file_hash = blake3::hash(data);
287 let boundaries = chunker.find_boundaries(data);
288
289 let mut new_chunk_data: Vec<(&[u8], String, u64)> = Vec::new();
290 let mut start = 0;
291 for end in &boundaries {
292 let slice = &data[start..*end];
293 let hash_cid = crate::cid::blake3_to_cid_string(blake3::hash(slice).as_bytes());
294 let size = slice.len() as u64;
295 new_chunk_data.push((slice, hash_cid, size));
296 start = *end;
297 }
298
299 let new_chunks: Vec<ChunkRef> = new_chunk_data
300 .iter()
301 .map(|(_, h, s)| ChunkRef { hash: h.clone(), size: *s })
302 .collect();
303
304 let delta = compute_chunk_delta(&old_chunks, &new_chunks);
305
306 if delta.full_upload_recommended {
307 drop(_guard);
308 let stats = self.store_file(rel_path, data, meta)?;
309 return Ok(DeltaStoreStats {
310 chunks_added: stats.chunks_written + stats.chunks_deduped,
311 chunks_reused: 0,
312 bytes_uploaded: stats.bytes_written,
313 bytes_skipped: 0,
314 efficiency: 0.0,
315 });
316 }
317
318 let added_set: HashSet<&str> = delta.added.iter().map(|c| c.hash.as_str()).collect();
319 let mut bytes_uploaded: u64 = 0;
320 let mut bytes_skipped: u64 = 0;
321 let mut chunks_written: Vec<String> = Vec::new();
322
323 for (slice, hash_cid, size) in &new_chunk_data {
324 if added_set.contains(hash_cid.as_str()) {
325 if !self.chunk_exists(hash_cid) {
326 let compressed =
327 crate::fxar::compress_chunk(slice, &self.config.compression);
328 self.write_chunk_atomic(hash_cid, &compressed)?;
329 bytes_uploaded += compressed.len() as u64;
330 }
331 chunks_written.push(hash_cid.clone());
332 } else {
333 bytes_skipped += size;
334 }
335 }
336
337 let chunk_hashes: Vec<String> = new_chunks.iter().map(|c| c.hash.clone()).collect();
339 let chunk_sizes: Vec<u64> = new_chunks.iter().map(|c| c.size).collect();
340
341 let op = ManifestOp::Put {
342 path: rel_path.to_string(),
343 meta: meta.clone(),
344 blake3: crate::cid::blake3_to_cid_string(file_hash.as_bytes()),
345 chunks: chunk_hashes,
346 chunk_sizes,
347 };
348 self.append_manifest(&op)?;
349
350 Ok(DeltaStoreStats {
351 chunks_added: delta.added.len() as u64,
352 chunks_reused: delta.unchanged as u64,
353 bytes_uploaded,
354 bytes_skipped,
355 efficiency: delta.efficiency,
356 })
357 }
358
359 pub fn delete_file(&self, rel_path: &str) -> crate::Result<()> {
361 self.append_manifest(&ManifestOp::Delete { path: rel_path.to_string() })
362 }
363
364 pub fn rename_file(&self, from: &str, to: &str) -> crate::Result<()> {
366 self.append_manifest(&ManifestOp::Rename {
367 from: from.to_string(),
368 to: to.to_string(),
369 })
370 }
371
372 pub fn snapshot(&self, name: &str) -> crate::Result<()> {
374 self.append_manifest(&ManifestOp::Snapshot { name: name.to_string() })
375 }
376
377 pub fn read_file(&self, rel_path: &str) -> crate::Result<Vec<u8>> {
379 let files = self.list_files()?;
380 let entry = files.iter().find(|f| f.path == rel_path)
381 .ok_or_else(|| FxcpError::CasStoreError(
382 format!("file not found in CAS store: {}", rel_path)))?;
383
384 let mut data = Vec::with_capacity(entry.meta.size as usize);
385 for chunk_hash in &entry.chunks {
386 let chunk_data = self.read_chunk(chunk_hash)?;
387 data.extend_from_slice(&chunk_data);
388 }
389
390 let actual = blake3::hash(&data);
392 let expected: [u8; 32] = if crate::cid::is_blake3_cid_string(&entry.blake3) {
393 crate::cid::cid_string_to_blake3(&entry.blake3).unwrap_or_default()
394 } else {
395 hex::decode(&entry.blake3).ok().and_then(|v| v.try_into().ok()).unwrap_or_default()
396 };
397 if *actual.as_bytes() != expected {
398 return Err(FxcpError::ManifestCorrupt(
399 format!("BLAKE3 mismatch for {}", rel_path)));
400 }
401
402 Ok(data)
403 }
404
405 pub fn list_files(&self) -> crate::Result<Vec<FileEntry>> {
407 let ops = self.read_manifest()?;
408 let mut state: HashMap<String, FileEntry> = HashMap::new();
409
410 for op in ops {
411 match op {
412 ManifestOp::Put { path, meta, blake3, chunks, chunk_sizes } => {
413 state.insert(path.clone(), FileEntry { path, meta, blake3, chunks, chunk_sizes });
414 }
415 ManifestOp::Delete { path } => {
416 state.remove(&path);
417 }
418 ManifestOp::Rename { from, to } => {
419 if let Some(mut entry) = state.remove(&from) {
420 entry.path = to.clone();
421 state.insert(to, entry);
422 }
423 }
424 ManifestOp::Snapshot { .. } => {} }
426 }
427
428 let mut files: Vec<FileEntry> = state.into_values().collect();
429 files.sort_by(|a, b| a.path.cmp(&b.path));
430 Ok(files)
431 }
432
433 pub fn compact(&self) -> crate::Result<CompactStats> {
435 let _guard = self.compact_lock.write()
436 .unwrap_or_else(|e| e.into_inner());
437
438 let ops = self.read_manifest()?;
439 let files = self.list_files()?;
440 let mut stats = CompactStats::default();
441
442 let referenced: HashSet<&str> = files.iter()
444 .flat_map(|f| f.chunks.iter().map(|c| c.as_str()))
445 .collect();
446
447 stats.files_kept = files.len() as u64;
448 stats.ops_removed = ops.len().saturating_sub(files.len()) as u64;
449
450 let manifest_path = self.root.join(MANIFEST_FILE);
452 let tmp_path = self.root.join(format!("{}.tmp", MANIFEST_FILE));
453 {
454 let mut f = std::fs::File::create(&tmp_path)?;
455 for file in &files {
456 let op = ManifestOp::Put {
457 path: file.path.clone(),
458 meta: file.meta.clone(),
459 blake3: file.blake3.clone(),
460 chunks: file.chunks.clone(),
461 chunk_sizes: file.chunk_sizes.clone(),
462 };
463 let line = serde_json::to_string(&op)
464 .map_err(|e| FxcpError::ManifestCorrupt(e.to_string()))?;
465 writeln!(f, "{}", line)?;
466 }
467 f.sync_data()?;
468 }
469 std::fs::rename(&tmp_path, &manifest_path)?;
470
471 let chunks_dir = self.root.join(CHUNKS_DIR);
473 for prefix_entry in std::fs::read_dir(&chunks_dir)? {
474 let prefix_entry = match prefix_entry { Ok(e) => e, Err(_) => continue };
475 let prefix_name = prefix_entry.file_name().to_string_lossy().into_owned();
476 if prefix_name == "tmp" { continue; }
477 if !prefix_entry.file_type()?.is_dir() { continue; }
478
479 for chunk_entry in std::fs::read_dir(prefix_entry.path())? {
480 let chunk_entry = match chunk_entry { Ok(e) => e, Err(_) => continue };
481 let chunk_name = chunk_entry.file_name().to_string_lossy().into_owned();
482 if !referenced.contains(chunk_name.as_str()) {
483 let size = chunk_entry.metadata().map(|m| m.len()).unwrap_or(0);
484 if std::fs::remove_file(chunk_entry.path()).is_ok() {
485 stats.chunks_removed += 1;
486 stats.bytes_freed += size;
487 }
488 }
489 }
490 }
491
492 for prefix_entry in std::fs::read_dir(&chunks_dir)? {
494 let prefix_entry = match prefix_entry { Ok(e) => e, Err(_) => continue };
495 let prefix_name = prefix_entry.file_name().to_string_lossy().into_owned();
496 if prefix_name == "tmp" { continue; }
497 let _ = std::fs::remove_dir(prefix_entry.path()); }
499
500 info!("CAS compact: {} files kept, {} ops removed, {} orphan chunks freed ({} bytes)",
501 stats.files_kept, stats.ops_removed, stats.chunks_removed, stats.bytes_freed);
502
503 Ok(stats)
504 }
505
506 pub fn stats(&self) -> crate::Result<(u64, u64, u64)> {
508 let files = self.list_files()?;
509 let file_count = files.len() as u64;
510 let apparent_bytes: u64 = files.iter().map(|f| f.meta.size).sum();
511 let unique_chunks: HashSet<&str> = files.iter()
512 .flat_map(|f| f.chunks.iter().map(|c| c.as_str()))
513 .collect();
514 Ok((file_count, apparent_bytes, unique_chunks.len() as u64))
515 }
516
517 pub fn store_fxar_as_cas(&self, archive_name: &str, data: &[u8]) -> crate::Result<()> {
521 let synthetic = format!("__fxar__/{archive_name}");
522 let meta = FileMeta { size: data.len() as u64, mode: 0o644, mtime: 0, uid: 0, gid: 0 };
523 self.store_file(&synthetic, data, &meta)?;
524 Ok(())
525 }
526
527 pub fn read_fxar_from_cas(&self, archive_name: &str) -> crate::Result<Vec<u8>> {
529 let synthetic = format!("__fxar__/{archive_name}");
530 let files = self.list_files()?;
531 if !files.iter().any(|f| f.path == synthetic) {
532 return Err(FxcpError::ChunkMissing(archive_name.to_string()));
533 }
534 self.read_file(&synthetic)
535 }
536
537 pub fn has_fxar_in_cas(&self, archive_name: &str) -> crate::Result<bool> {
539 let synthetic = format!("__fxar__/{archive_name}");
540 let files = self.list_files()?;
541 Ok(files.iter().any(|f| f.path == synthetic))
542 }
543
544 pub fn import_fxar<R: io::Read + io::Seek>(&self, reader: R) -> crate::Result<CasStoreStats> {
549 let mut fxar = crate::fxar::FxarReader::open(reader)?;
550 let manifest = fxar.read_manifest()?;
551 let mut stats = CasStoreStats::default();
552
553 for file_entry in &manifest.files {
555 let data = match fxar.restore_file(&file_entry.path) {
556 Ok(d) => d,
557 Err(e) => {
558 warn!("CAS import: skipping {}: {}", file_entry.path, e);
559 continue;
560 }
561 };
562 let meta = FileMeta {
563 size: file_entry.size,
564 mode: file_entry.mode,
565 mtime: file_entry.mtime,
566 uid: file_entry.uid,
567 gid: file_entry.gid,
568 };
569 let cas_path = file_entry.path.find("/tree/")
571 .map(|idx| &file_entry.path[idx + 6..])
572 .unwrap_or(&file_entry.path);
573 let file_stats = self.store_file(cas_path, &data, &meta)?;
574 stats.chunks_written += file_stats.chunks_written;
575 stats.chunks_deduped += file_stats.chunks_deduped;
576 stats.bytes_written += file_stats.bytes_written;
577 stats.bytes_deduped += file_stats.bytes_deduped;
578 }
579
580 info!("CAS import from FXAR: {} files, {} chunks written, {} deduped",
581 manifest.files.len(), stats.chunks_written, stats.chunks_deduped);
582 Ok(stats)
583 }
584
585 pub fn export_fxar<W: io::Write + io::Seek>(&self, writer: W, compress: &str) -> crate::Result<crate::fxar::FxarExportStats> {
587 let files = self.list_files()?;
588 if files.is_empty() {
589 return Ok(crate::fxar::FxarExportStats::default());
590 }
591
592 let tmp_dir = std::env::temp_dir().join(format!("foxing-cas-export-{}", std::process::id()));
595 std::fs::create_dir_all(&tmp_dir)?;
596 struct CleanupGuard(PathBuf);
598 impl Drop for CleanupGuard {
599 fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
600 }
601 let _guard = CleanupGuard(tmp_dir.clone());
602 let tmp = &tmp_dir;
603 let vs_root = tmp.join(".foxing_versions");
604 let snap_ts = chrono::Utc::now().format("%Y-%m-%dT%H%M%S").to_string();
605 let snap_dir = vs_root.join(&snap_ts);
606 let tree_dir = snap_dir.join("tree");
607 std::fs::create_dir_all(&tree_dir)?;
608
609 let summary = serde_json::json!({
611 "timestamp": chrono::Utc::now().to_rfc3339(),
612 "status": "success", "type": "full",
613 "source": "cas-store", "trigger": "export",
614 "files": files.len(), "size_bytes": 0,
615 "disk_usage_bytes": 0, "savings_pct": 0.0, "elapsed_ms": 0
616 });
617 std::fs::write(snap_dir.join("summary.json"),
618 serde_json::to_string_pretty(&summary).unwrap_or_default())?;
619
620 for file in &files {
622 let data = self.read_file(&file.path)?;
623 let dest = tree_dir.join(&file.path);
624 if let Some(parent) = dest.parent() {
625 std::fs::create_dir_all(parent)?;
626 }
627 std::fs::write(&dest, &data)?;
628 }
629
630 let store = crate::version_store::VersionStore::open(tmp);
632 crate::fxar::write_archive_seekable(&store, writer, compress, None)
633 }
634
635 pub fn store_files_batch(
645 &self,
646 files: &[(String, &[u8], &FileMeta)],
647 ) -> crate::Result<BatchStats> {
648 let _guard = self.compact_lock.read()
649 .unwrap_or_else(|e| e.into_inner());
650
651 let mut all_chunk_dirs: HashSet<PathBuf> = HashSet::new();
652 let mut manifest_ops: Vec<ManifestOp> = Vec::with_capacity(files.len());
653 let mut fsync_count: usize = 0;
654
655 for (rel_path, data, meta) in files {
657 let (chunk_hashes, chunk_sizes, dirs) = self.write_chunks_no_fsync(data)?;
658 all_chunk_dirs.extend(dirs);
659
660 let file_hash = blake3::hash(data);
661 manifest_ops.push(ManifestOp::Put {
662 path: rel_path.clone(),
663 meta: (*meta).clone(),
664 blake3: crate::cid::blake3_to_cid_string(file_hash.as_bytes()),
665 chunks: chunk_hashes,
666 chunk_sizes,
667 });
668 }
669
670 for dir in &all_chunk_dirs {
672 if let Ok(dir_file) = std::fs::File::open(dir)
673 && let Err(e) = dir_file.sync_data() {
674 warn!("CAS batch dir fsync failed for {}: {e}", dir.display());
675 }
676 fsync_count += 1;
677 }
678
679 self.append_manifest_batch(&manifest_ops)?;
681 fsync_count += 1;
682
683 Ok(BatchStats { files: files.len(), fsyncs: fsync_count })
684 }
685
686 fn write_chunks_no_fsync(
691 &self,
692 data: &[u8],
693 ) -> crate::Result<(Vec<String>, Vec<u64>, Vec<PathBuf>)> {
694 let chunker = GearChunker::new(
695 self.config.chunk_min,
696 self.config.chunk_avg,
697 self.config.chunk_max,
698 )?.with_normalization(self.config.normalization_level)?;
699
700 let boundaries = chunker.find_boundaries(data);
701 let mut chunk_hashes = Vec::with_capacity(boundaries.len());
702 let mut chunk_sizes = Vec::with_capacity(boundaries.len());
703 let mut touched_dirs: Vec<PathBuf> = Vec::new();
704 let mut seen_dirs: HashSet<PathBuf> = HashSet::new();
705
706 let mut start = 0;
707 for end in &boundaries {
708 let slice = &data[start..*end];
709 let hash = blake3::hash(slice);
710 let hash_cid = crate::cid::blake3_to_cid_string(hash.as_bytes());
711
712 chunk_sizes.push(slice.len() as u64);
713
714 if !self.chunk_exists(&hash_cid) {
715 let compressed = crate::fxar::compress_chunk(slice, &self.config.compression);
716 self.write_chunk_no_sync(&hash_cid, &compressed)?;
717
718 if let Some(parent) = self.chunk_path(&hash_cid).parent() {
719 let parent_buf = parent.to_path_buf();
720 if seen_dirs.insert(parent_buf.clone()) {
721 touched_dirs.push(parent_buf);
722 }
723 }
724 }
725
726 chunk_hashes.push(hash_cid);
727 start = *end;
728 }
729
730 Ok((chunk_hashes, chunk_sizes, touched_dirs))
731 }
732
733 fn write_chunk_no_sync(&self, hash_cid: &str, data: &[u8]) -> crate::Result<()> {
735 let final_path = self.chunk_path(hash_cid);
736 if final_path.exists() { return Ok(()); }
737
738 if let Some(parent) = final_path.parent() {
739 std::fs::create_dir_all(parent)?;
740 }
741
742 let tmp_path = self.root.join(TMP_DIR).join(hash_cid);
743 {
744 let mut f = std::fs::File::create(&tmp_path)?;
745 f.write_all(data)?;
746 }
748
749 std::fs::rename(&tmp_path, &final_path)?;
750 Ok(())
751 }
752
753 fn append_manifest_batch(&self, ops: &[ManifestOp]) -> crate::Result<()> {
755 let manifest_path = self.root.join(MANIFEST_FILE);
756 let mut f = std::fs::OpenOptions::new()
757 .create(true)
758 .append(true)
759 .open(&manifest_path)?;
760 for op in ops {
761 let line = serde_json::to_string(op)
762 .map_err(|e| FxcpError::ManifestCorrupt(e.to_string()))?;
763 writeln!(f, "{}", line)?;
764 }
765 f.sync_data()?;
766 Ok(())
767 }
768
769 fn chunk_path(&self, hash_cid: &str) -> PathBuf {
772 let shard = if hash_cid.len() > 9 { &hash_cid[7..9] } else { &hash_cid[..2.min(hash_cid.len())] };
773 self.root.join(CHUNKS_DIR).join(shard).join(hash_cid)
774 }
775
776 fn chunk_exists(&self, hash_cid: &str) -> bool {
777 self.chunk_path(hash_cid).exists()
778 }
779
780 fn write_chunk_atomic(&self, hash_cid: &str, data: &[u8]) -> crate::Result<()> {
782 let final_path = self.chunk_path(hash_cid);
783 if final_path.exists() { return Ok(()); } if let Some(parent) = final_path.parent() {
787 std::fs::create_dir_all(parent)?;
788 }
789
790 let tmp_path = self.root.join(TMP_DIR).join(hash_cid);
792 {
793 let mut f = std::fs::File::create(&tmp_path)?;
794 f.write_all(data)?;
795 f.sync_data()?;
796 }
797
798 std::fs::rename(&tmp_path, &final_path)?;
800
801 if let Some(parent) = final_path.parent()
803 && let Ok(dir) = std::fs::File::open(parent)
804 && let Err(e) = dir.sync_data() {
805 warn!("CAS store dir fsync failed: {e}");
806 }
807
808 Ok(())
809 }
810
811 fn read_chunk(&self, hash_cid: &str) -> crate::Result<Vec<u8>> {
812 let path = self.chunk_path(hash_cid);
813 let compressed = std::fs::read(&path)
814 .map_err(|e| FxcpError::ChunkMissing(format!("{}: {}", hash_cid, e)))?;
815 let flag = match self.config.compression.as_str() {
817 "none" => 0,
818 "lz4" => 2,
819 "gzip" => 3,
820 "xz" => 4,
821 _ => 1, };
823 Ok(crate::fxar::decompress_chunk(&compressed, flag)?)
824 }
825
826 fn append_manifest(&self, op: &ManifestOp) -> crate::Result<()> {
827 let manifest_path = self.root.join(MANIFEST_FILE);
828 let line = serde_json::to_string(op)
829 .map_err(|e| FxcpError::ManifestCorrupt(e.to_string()))?;
830 let mut f = std::fs::OpenOptions::new()
831 .create(true)
832 .append(true)
833 .open(&manifest_path)?;
834 writeln!(f, "{}", line)?;
835 f.sync_data()?;
836 Ok(())
837 }
838
839 fn read_manifest(&self) -> crate::Result<Vec<ManifestOp>> {
840 let manifest_path = self.root.join(MANIFEST_FILE);
841 if !manifest_path.exists() {
842 return Ok(Vec::new());
843 }
844
845 let file = std::fs::File::open(&manifest_path)?;
846 let reader = io::BufReader::new(file);
847 let mut ops = Vec::new();
848
849 for line in reader.lines() {
850 let line = match line { Ok(l) => l, Err(_) => break };
851 let trimmed = line.trim();
852 if trimmed.is_empty() { continue; }
853 match serde_json::from_str::<ManifestOp>(trimmed) {
855 Ok(op) => ops.push(op),
856 Err(e) => {
857 debug!("CAS manifest: skipping malformed line: {}", e);
858 }
859 }
860 }
861
862 Ok(ops)
863 }
864}
865
866#[cfg(test)]
871mod tests {
872 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
873 use super::*;
874 use tempfile::TempDir;
875
876 fn test_store() -> (TempDir, CasStore) {
877 let dir = TempDir::new().unwrap();
878 let store = CasStore::open(dir.path()).unwrap();
879 (dir, store)
880 }
881
882 #[test]
883 fn test_store_read_roundtrip() {
884 let (_dir, store) = test_store();
885 let data = b"hello world, this is CAS store test data";
886 let meta = FileMeta { size: data.len() as u64, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
887
888 store.store_file("test/file.txt", data, &meta).unwrap();
889 let read_back = store.read_file("test/file.txt").unwrap();
890 assert_eq!(read_back, data);
891 }
892
893 #[test]
894 fn test_dedup_identical_files() {
895 let (dir, store) = test_store();
896 let data = vec![0x42u8; 100_000];
897 let meta = FileMeta { size: data.len() as u64, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
898
899 let stats1 = store.store_file("file1.dat", &data, &meta).unwrap();
900 let stats2 = store.store_file("file2.dat", &data, &meta).unwrap();
901
902 assert!(stats1.chunks_written > 0);
903 assert_eq!(stats2.chunks_written, 0, "second store should dedup all chunks");
904 assert_eq!(stats2.chunks_deduped, stats1.chunks_written);
905
906 assert_eq!(store.read_file("file1.dat").unwrap(), data);
908 assert_eq!(store.read_file("file2.dat").unwrap(), data);
909
910 let chunks_dir = dir.path().join(CAS_DIR).join(CHUNKS_DIR);
912 let chunk_count: usize = walkdir::WalkDir::new(&chunks_dir)
913 .into_iter()
914 .filter_map(|e| e.ok())
915 .filter(|e| e.file_type().is_file())
916 .count();
917 assert_eq!(chunk_count as u64, stats1.chunks_written, "only one set of chunks on disk");
918 }
919
920 #[test]
921 fn test_dedup_partial_change() {
922 let dir = TempDir::new().unwrap();
924 let root = dir.path().join(CAS_DIR);
925 std::fs::create_dir_all(root.join(TMP_DIR)).unwrap();
926 let config = CasConfig {
927 version: 1,
928 chunk_min: 512,
929 chunk_avg: 4096,
930 chunk_max: 16384,
931 compression: "none".into(),
932 normalization_level: 0,
933 };
934 let json = serde_json::to_string_pretty(&config).unwrap();
935 std::fs::write(root.join(CONFIG_FILE), json).unwrap();
936 let store = CasStore::open(dir.path()).unwrap();
937
938 let mut data = vec![0u8; 100_000];
940 for (i, b) in data.iter_mut().enumerate() {
941 *b = (i.wrapping_mul(0x9E3779B9) >> 24) as u8;
942 }
943 let meta = FileMeta { size: data.len() as u64, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
944
945 let stats1 = store.store_file("file.dat", &data, &meta).unwrap();
946 assert!(stats1.chunks_written >= 4, "need multiple chunks for dedup test (got {})", stats1.chunks_written);
947
948 let mut modified = data.clone();
950 let mid = data.len() / 2;
951 for i in mid..mid + 5000 {
952 modified[i] = 0xFF;
953 }
954 let meta2 = FileMeta { size: modified.len() as u64, mode: 0o644, mtime: 2000, uid: 1000, gid: 1000 };
955 let stats2 = store.store_file("file.dat", &modified, &meta2).unwrap();
956
957 assert!(stats2.chunks_deduped > 0, "partial change should dedup some chunks (wrote={}, deduped={})",
959 stats2.chunks_written, stats2.chunks_deduped);
960
961 assert_eq!(store.read_file("file.dat").unwrap(), modified);
963 }
964
965 #[test]
966 fn test_delete_and_compact() {
967 let (_dir, store) = test_store();
968 let meta = FileMeta { size: 5, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
969
970 store.store_file("keep.txt", b"keepx", &meta).unwrap();
971 store.store_file("delete.txt", b"delxx", &meta).unwrap();
972
973 assert_eq!(store.list_files().unwrap().len(), 2);
974
975 store.delete_file("delete.txt").unwrap();
976 assert_eq!(store.list_files().unwrap().len(), 1);
977
978 let stats = store.compact().unwrap();
979 assert_eq!(stats.files_kept, 1);
980 assert!(stats.chunks_removed > 0 || stats.ops_removed > 0);
981
982 assert_eq!(store.read_file("keep.txt").unwrap(), b"keepx");
984 assert!(store.read_file("delete.txt").is_err());
986 }
987
988 #[test]
989 fn test_crash_safe_partial_manifest() {
990 let (dir, store) = test_store();
991 let meta = FileMeta { size: 5, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
992 store.store_file("good.txt", b"good!", &meta).unwrap();
993
994 let manifest_path = dir.path().join(CAS_DIR).join(MANIFEST_FILE);
996 let mut f = std::fs::OpenOptions::new().append(true).open(&manifest_path).unwrap();
997 write!(f, "{{\"op\":\"put\",\"path\":\"corrupt").unwrap(); let files = store.list_files().unwrap();
1001 assert_eq!(files.len(), 1);
1002 assert_eq!(files[0].path, "good.txt");
1003 }
1004
1005 #[test]
1006 fn test_snapshot_marker() {
1007 let (_dir, store) = test_store();
1008 store.snapshot("2026-03-14T080000").unwrap();
1009
1010 let ops = store.read_manifest().unwrap();
1011 assert_eq!(ops.len(), 1);
1012 match &ops[0] {
1013 ManifestOp::Snapshot { name } => assert_eq!(name, "2026-03-14T080000"),
1014 _ => panic!("expected snapshot op"),
1015 }
1016 }
1017
1018 #[test]
1019 fn test_rename_file() {
1020 let (_dir, store) = test_store();
1021 let meta = FileMeta { size: 4, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
1022 store.store_file("old.txt", b"data", &meta).unwrap();
1023 store.rename_file("old.txt", "new.txt").unwrap();
1024
1025 let files = store.list_files().unwrap();
1026 assert_eq!(files.len(), 1);
1027 assert_eq!(files[0].path, "new.txt");
1028 assert_eq!(store.read_file("new.txt").unwrap(), b"data");
1029 assert!(store.read_file("old.txt").is_err());
1030 }
1031
1032 #[test]
1033 fn test_fxar_roundtrip() {
1034 let (_dir, store) = test_store();
1036 let meta = FileMeta { size: 100, mode: 0o755, mtime: 1773484800, uid: 1000, gid: 1000 };
1037 store.store_file("data/test.txt", &[0x42u8; 100], &meta).unwrap();
1038 store.store_file("data/other.txt", &[0x43u8; 200], &FileMeta { size: 200, ..meta.clone() }).unwrap();
1039
1040 let mut fxar_buf = std::io::Cursor::new(Vec::new());
1042 store.export_fxar(&mut fxar_buf, "none").unwrap();
1043 let fxar_data = fxar_buf.into_inner();
1044 assert!(fxar_data.len() > 64, "FXAR archive should have content");
1045 assert_eq!(&fxar_data[..4], b"FXAR");
1046
1047 let dir2 = TempDir::new().unwrap();
1049 let store2 = CasStore::open(dir2.path()).unwrap();
1050 let cursor = std::io::Cursor::new(fxar_data);
1051 store2.import_fxar(cursor).unwrap();
1052
1053 let files1 = store.list_files().unwrap();
1055 let files2 = store2.list_files().unwrap();
1056 assert_eq!(files1.len(), files2.len());
1057 for (a, b) in files1.iter().zip(files2.iter()) {
1058 assert_eq!(a.blake3, b.blake3, "BLAKE3 mismatch for {}", a.path);
1059 }
1060
1061 let d1 = store.read_file("data/test.txt").unwrap();
1063 let d2 = store2.read_file("data/test.txt").unwrap();
1064 assert_eq!(d1, d2);
1065 }
1066
1067 #[test]
1068 fn test_error_type_alignment() {
1069 let result = CasStore::open(Path::new("/nonexistent/path/that/does/not/exist"));
1070 assert!(result.is_err());
1071 match result.err().unwrap() {
1072 crate::error::FxcpError::Io(_) => {}
1073 other => panic!("expected FxcpError::Io, got: {:?}", other),
1074 }
1075
1076 let dir = TempDir::new().unwrap();
1077 let store = CasStore::open(dir.path()).unwrap();
1078 let result = store.read_file("nonexistent.txt");
1079 assert!(result.is_err());
1080 match result.err().unwrap() {
1081 crate::error::FxcpError::CasStoreError(msg) => {
1082 assert!(msg.contains("not found"), "msg: {}", msg);
1083 }
1084 other => panic!("expected FxcpError::CasStoreError, got: {:?}", other),
1085 }
1086 }
1087
1088 #[test]
1089 fn test_stats() {
1090 let (_dir, store) = test_store();
1091 let meta = FileMeta { size: 100, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
1092 store.store_file("a.dat", &[0x42u8; 100], &meta).unwrap();
1093 store.store_file("b.dat", &[0x43u8; 200], &FileMeta { size: 200, ..meta }).unwrap();
1094
1095 let (files, apparent, chunks) = store.stats().unwrap();
1096 assert_eq!(files, 2);
1097 assert_eq!(apparent, 300);
1098 assert!(chunks > 0);
1099 }
1100
1101 #[test]
1102 fn test_casconfig_default_normalization() {
1103 let json = r#"{"version":1,"chunk_min":2048,"chunk_avg":65536,"chunk_max":2097152,"compression":"zstd"}"#;
1104 let cfg: CasConfig = serde_json::from_str(json).unwrap();
1105 assert_eq!(cfg.normalization_level, 0);
1106 }
1107
1108 #[test]
1109 fn test_casconfig_with_normalization() {
1110 let json = r#"{"version":1,"chunk_min":2048,"chunk_avg":65536,"chunk_max":2097152,"compression":"zstd","normalization_level":1}"#;
1111 let cfg: CasConfig = serde_json::from_str(json).unwrap();
1112 assert_eq!(cfg.normalization_level, 1);
1113 }
1114
1115 #[test]
1116 fn test_cas_compact_concurrent_store() {
1117 use std::sync::{Arc, Barrier};
1118 use std::thread;
1119
1120 let dir = TempDir::new().unwrap();
1121 let store = Arc::new(CasStore::open(dir.path()).unwrap());
1122 let barrier = Arc::new(Barrier::new(2));
1123
1124 let store_w = Arc::clone(&store);
1125 let barrier_w = Arc::clone(&barrier);
1126 let writer = thread::spawn(move || {
1127 barrier_w.wait();
1128 for i in 0..10 {
1129 let data = format!("file-{}-payload-{}", i, "x".repeat(1024));
1130 let meta = FileMeta {
1131 size: data.len() as u64,
1132 mode: 0o644,
1133 mtime: 1000 + i as i64,
1134 uid: 1000,
1135 gid: 1000,
1136 };
1137 store_w
1138 .store_file(&format!("concurrent/{}.txt", i), data.as_bytes(), &meta)
1139 .unwrap();
1140 }
1141 });
1142
1143 let store_c = Arc::clone(&store);
1144 let barrier_c = Arc::clone(&barrier);
1145 let compactor = thread::spawn(move || {
1146 barrier_c.wait();
1147 for _ in 0..5 {
1148 let _ = store_c.compact();
1149 thread::yield_now();
1150 }
1151 });
1152
1153 writer.join().unwrap();
1154 compactor.join().unwrap();
1155
1156 for i in 0..10 {
1157 let path = format!("concurrent/{}.txt", i);
1158 let data = store
1159 .read_file(&path)
1160 .unwrap_or_else(|e| panic!("file {} lost after concurrent compact: {}", path, e));
1161 let expected = format!("file-{}-payload-{}", i, "x".repeat(1024));
1162 assert_eq!(data, expected.as_bytes(), "content mismatch for {}", path);
1163 }
1164 }
1165
1166 #[test]
1167 fn test_casconfig_invalid_normalization() {
1168 let dir = TempDir::new().unwrap();
1169 let root = dir.path().join(CAS_DIR);
1170 std::fs::create_dir_all(root.join(TMP_DIR)).unwrap();
1171 let config = CasConfig {
1172 version: 1,
1173 chunk_min: 2048,
1174 chunk_avg: 65536,
1175 chunk_max: 2097152,
1176 compression: "zstd".into(),
1177 normalization_level: 3,
1178 };
1179 let json = serde_json::to_string_pretty(&config).unwrap();
1180 std::fs::write(root.join(CONFIG_FILE), json).unwrap();
1181
1182 let result = CasStore::open(dir.path());
1183 assert!(result.is_err());
1184 match result.err().unwrap() {
1185 FxcpError::Config(msg) => assert!(msg.contains("normalization_level"), "msg: {}", msg),
1186 other => panic!("expected FxcpError::Config, got: {:?}", other),
1187 }
1188 }
1189
1190 #[test]
1191 fn test_local_cas_chunk_sizes() {
1192 let (_dir, store) = test_store();
1193 let data = vec![0xABu8; 50_000];
1194 let meta = FileMeta { size: data.len() as u64, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 };
1195
1196 store.store_file("chunked.bin", &data, &meta).unwrap();
1197 let files = store.list_files().unwrap();
1198 let entry = files.iter().find(|f| f.path == "chunked.bin").unwrap();
1199
1200 assert_eq!(
1201 entry.chunk_sizes.len(),
1202 entry.chunks.len(),
1203 "chunk_sizes.len() ({}) must equal chunks.len() ({})",
1204 entry.chunk_sizes.len(),
1205 entry.chunks.len()
1206 );
1207 assert_eq!(
1208 entry.chunk_sizes.iter().sum::<u64>(),
1209 data.len() as u64,
1210 "sum(chunk_sizes) must equal file size"
1211 );
1212 for (i, &sz) in entry.chunk_sizes.iter().enumerate() {
1213 assert!(sz > 0, "chunk_sizes[{i}] must be > 0");
1214 }
1215 }
1216
1217 #[test]
1218 fn test_manifest_op_put_chunk_sizes_backward_compat() {
1219 let json = r#"{"op":"put","path":"a.txt","size":0,"mode":0,"mtime":0,"uid":0,"gid":0,"blake3":"abc","chunks":["x"]}"#;
1221 let op: ManifestOp = serde_json::from_str(json).unwrap();
1222 match op {
1223 ManifestOp::Put { chunk_sizes, .. } => {
1224 assert_eq!(
1225 chunk_sizes,
1226 vec![] as Vec<u64>,
1227 "chunk_sizes must default to empty for old manifests"
1228 );
1229 }
1230 _ => panic!("expected Put"),
1231 }
1232 }
1233
1234 #[test]
1235 fn test_local_cas_fxar_roundtrip() {
1236 let (_dir, store) = test_store();
1237 let fxar_data: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(0x9E37) >> 8) as u8).collect();
1238 store.store_fxar_as_cas("test.fxar", &fxar_data).unwrap();
1239 let restored = store.read_fxar_from_cas("test.fxar").unwrap();
1240 assert_eq!(fxar_data, restored, "local CAS FXAR round-trip must be byte-identical");
1241 }
1242
1243 #[test]
1244 fn test_local_cas_fxar_has_before_and_after() {
1245 let (_dir, store) = test_store();
1246 assert!(!store.has_fxar_in_cas("missing.fxar").unwrap());
1247 store.store_fxar_as_cas("present.fxar", &[0xAB; 512]).unwrap();
1248 assert!(store.has_fxar_in_cas("present.fxar").unwrap());
1249 }
1250
1251 #[test]
1252 fn test_local_cas_fxar_read_missing_returns_error() {
1253 let (_dir, store) = test_store();
1254 let err = store.read_fxar_from_cas("nonexistent.fxar");
1255 assert!(err.is_err(), "reading missing FXAR must return Err");
1256 }
1257
1258 #[test]
1259 fn test_local_cas_fxar_synthetic_path() {
1260 let (_dir, store) = test_store();
1261 store.store_fxar_as_cas("archive.fxar", &[0x42; 256]).unwrap();
1262 let files = store.list_files().unwrap();
1263 let fxar_entry = files.iter().find(|f| f.path == "__fxar__/archive.fxar");
1264 assert!(fxar_entry.is_some(), "FXAR must be stored under __fxar__/ synthetic path");
1265 let bare = files.iter().find(|f| f.path == "archive.fxar");
1266 assert!(bare.is_none(), "FXAR must NOT be stored under bare name");
1267 }
1268
1269 fn test_meta() -> FileMeta {
1272 FileMeta { size: 0, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000 }
1273 }
1274
1275 fn delta_test_data(size: usize) -> Vec<u8> {
1276 let mut data = Vec::with_capacity(size);
1277 let mut state: u32 = 0xDEAD_BEEF;
1278 for _ in 0..size {
1279 state = state.wrapping_mul(1103515245).wrapping_add(12345);
1280 data.push((state >> 16) as u8);
1281 }
1282 data
1283 }
1284
1285 fn delta_test_store() -> (TempDir, CasStore) {
1286 let dir = TempDir::new().unwrap();
1287 let root = dir.path().join(CAS_DIR);
1288 std::fs::create_dir_all(root.join(TMP_DIR)).unwrap();
1289 let config = CasConfig {
1290 version: 1,
1291 chunk_min: 512,
1292 chunk_avg: 4096,
1293 chunk_max: 16384,
1294 compression: "none".into(),
1295 normalization_level: 0,
1296 };
1297 let json = serde_json::to_string_pretty(&config).unwrap();
1298 std::fs::write(root.join(CONFIG_FILE), json).unwrap();
1299 let store = CasStore::open(dir.path()).unwrap();
1300 (dir, store)
1301 }
1302
1303 #[test]
1304 fn test_local_delta_new_path_falls_through() {
1305 let (_dir, store) = delta_test_store();
1306 let data = delta_test_data(50_000);
1307 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1308 let stats = store.delta_store_file("new.bin", &data, &meta).unwrap();
1309 assert!(stats.chunks_added > 0);
1310 assert_eq!(stats.chunks_reused, 0);
1311 assert_eq!(stats.efficiency, 0.0);
1312 assert_eq!(store.read_file("new.bin").unwrap(), data);
1313 }
1314
1315 #[test]
1316 fn test_local_delta_unchanged_uploads_zero() {
1317 let (_dir, store) = delta_test_store();
1318 let data = delta_test_data(50_000);
1319 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1320 store.store_file("same.bin", &data, &meta).unwrap();
1321 let stats = store.delta_store_file("same.bin", &data, &meta).unwrap();
1322 assert_eq!(stats.chunks_added, 0);
1323 assert!(stats.chunks_reused > 0);
1324 assert_eq!(stats.bytes_uploaded, 0);
1325 assert!((stats.efficiency - 1.0).abs() < f64::EPSILON);
1326 }
1327
1328 #[test]
1329 fn test_local_delta_ten_percent_change() {
1330 let (_dir, store) = delta_test_store();
1331 let data = delta_test_data(50_000);
1332 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1333 store.store_file("partial.bin", &data, &meta).unwrap();
1334
1335 let mut modified = data.clone();
1336 let change_len = modified.len() / 10;
1337 for b in &mut modified[..change_len] {
1338 *b ^= 0xFF;
1339 }
1340 let meta2 = FileMeta { size: modified.len() as u64, ..test_meta() };
1341 let stats = store.delta_store_file("partial.bin", &modified, &meta2).unwrap();
1342 assert!(stats.efficiency > 0.5, "efficiency {} should be > 0.5", stats.efficiency);
1343 assert!(stats.bytes_skipped > 0);
1344 }
1345
1346 #[test]
1347 fn test_local_delta_produces_correct_content() {
1348 let (_dir, store) = delta_test_store();
1349 let data = delta_test_data(50_000);
1350 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1351 store.store_file("content.bin", &data, &meta).unwrap();
1352
1353 let mut modified = data.clone();
1354 modified[100..200].fill(0xAA);
1355 let meta2 = FileMeta { size: modified.len() as u64, ..test_meta() };
1356 store.delta_store_file("content.bin", &modified, &meta2).unwrap();
1357
1358 assert_eq!(store.read_file("content.bin").unwrap(), modified);
1359 }
1360
1361 #[test]
1362 fn test_local_delta_small_file_skips() {
1363 let (_dir, store) = delta_test_store();
1364 let data = b"tiny file";
1365 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1366 store.store_file("tiny.bin", data, &meta).unwrap();
1367
1368 let files = store.list_files().unwrap();
1369 let entry = files.iter().find(|f| f.path == "tiny.bin").unwrap();
1370 assert!(entry.chunks.len() < 3, "test setup: need <3 chunks");
1371
1372 let stats = store.delta_store_file("tiny.bin", data, &meta).unwrap();
1373 assert_eq!(stats.efficiency, 0.0);
1374 }
1375
1376 #[test]
1377 fn test_local_delta_idempotent() {
1378 let (_dir, store) = delta_test_store();
1379 let data = delta_test_data(50_000);
1380 let meta = FileMeta { size: data.len() as u64, ..test_meta() };
1381 store.store_file("idem.bin", &data, &meta).unwrap();
1382
1383 let stats1 = store.delta_store_file("idem.bin", &data, &meta).unwrap();
1384 assert_eq!(stats1.chunks_added, 0);
1385
1386 let stats2 = store.delta_store_file("idem.bin", &data, &meta).unwrap();
1387 assert_eq!(stats2.chunks_added, 0);
1388 assert!((stats2.efficiency - 1.0).abs() < f64::EPSILON);
1389 }
1390
1391 #[test]
1392 fn test_cid_chunk_path_sharding() {
1393 let (_dir, store) = test_store();
1394 let hash = blake3::hash(b"test-shard-input");
1395 let cid = crate::cid::blake3_to_cid_string(hash.as_bytes());
1396 assert!(cid.len() > 9, "CID string must be longer than 9 chars");
1397
1398 let path = store.chunk_path(&cid);
1399 let expected_shard = &cid[7..9];
1402 let parent = path.parent().unwrap();
1403 let actual_shard = parent.file_name().unwrap().to_str().unwrap();
1404 assert_eq!(actual_shard, expected_shard,
1405 "shard directory should be chars 7-9 of CID string: cid={cid}");
1406 }
1407
1408 #[test]
1411 fn test_store_files_batch_content_integrity() {
1412 let (_dir, store) = test_store();
1413 let payloads: Vec<(String, Vec<u8>)> = (0..5).map(|i| {
1414 let content = format!("batch-file-{}-payload-{}", i, "x".repeat(4096));
1415 (format!("batch/{}.txt", i), content.into_bytes())
1416 }).collect();
1417
1418 let files: Vec<(String, &[u8], &FileMeta)> = payloads.iter().map(|(p, d)| {
1419 let meta = Box::leak(Box::new(FileMeta {
1420 size: d.len() as u64, mode: 0o644, mtime: 1000, uid: 1000, gid: 1000,
1421 }));
1422 (p.clone(), d.as_slice(), meta as &FileMeta)
1423 }).collect();
1424
1425 let stats = store.store_files_batch(&files).unwrap();
1426 assert_eq!(stats.files, 5);
1427
1428 for (path, data) in &payloads {
1429 let read_back = store.read_file(path).unwrap();
1430 assert_eq!(&read_back, data, "content mismatch for {}", path);
1431 }
1432 }
1433
1434 #[test]
1435 fn test_store_files_batch_fsync_reduction() {
1436 let (_dir, store) = test_store();
1437 let payloads: Vec<(String, Vec<u8>)> = (0..10).map(|i| {
1438 let mut data = vec![0u8; 4096];
1439 for (j, b) in data.iter_mut().enumerate() {
1440 *b = ((i * 1000 + j).wrapping_mul(0x9E3779B9) >> 24) as u8;
1441 }
1442 (format!("fsync_test/{}.bin", i), data)
1443 }).collect();
1444
1445 let files: Vec<(String, &[u8], &FileMeta)> = payloads.iter().map(|(p, d)| {
1446 let meta = Box::leak(Box::new(FileMeta {
1447 size: d.len() as u64, mode: 0o644, mtime: 2000, uid: 1000, gid: 1000,
1448 }));
1449 (p.clone(), d.as_slice(), meta as &FileMeta)
1450 }).collect();
1451
1452 let stats = store.store_files_batch(&files).unwrap();
1453 assert_eq!(stats.files, 10);
1454 assert!(stats.fsyncs < 3 * 10,
1458 "batch fsyncs ({}) must be less than per-file total (30)", stats.fsyncs);
1459
1460 for (path, data) in &payloads {
1461 let read_back = store.read_file(path).unwrap();
1462 assert_eq!(&read_back, data, "content mismatch for {}", path);
1463 }
1464
1465 let listed = store.list_files().unwrap();
1466 let batch_files: Vec<_> = listed.iter()
1467 .filter(|f| f.path.starts_with("fsync_test/"))
1468 .collect();
1469 assert_eq!(batch_files.len(), 10);
1470 }
1471}