Skip to main content

fxcp_core/
cas_store.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wiramu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/cas_store.rs  --  Content-addressable chunk store for filesystem-agnostic targets
5
6//! Content-addressable storage (CAS) directory for foxing replication targets.
7//!
8//! Stores files as BLAKE3-addressed chunks in a sharded directory layout.
9//! Works on any filesystem (exFAT, FAT32, NTFS, NFS)  --  no xattrs, permissions,
10//! or symlinks required. All metadata is in the append-only manifest.
11//!
12//! Layout:
13//! ```text
14//! .foxing_cas/
15//!   config.json        --  store parameters (chunk sizes, compression)
16//!   manifest.jsonl     --  append-only operation log (one JSON line per op)
17//!   chunks/
18//!     a1/b2c3d4...    --  chunk files named by BLAKE3 hash
19//!     tmp/            --  staging area for crash-safe writes
20//! ```
21
22use 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// -----------------------------------------------------------------------
40// Types
41// -----------------------------------------------------------------------
42
43/// Store configuration (persisted as config.json).
44#[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    /// Normalization level for gear-hash chunk-size distribution (0..=2).
52    #[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/// File metadata stored in manifest entries.
70#[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/// A single operation in the manifest log.
80#[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/// Current state of a file in the store (after replaying manifest).
102#[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/// Statistics from a store_file operation.
112#[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/// Statistics from a compact operation.
121#[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/// Statistics from a batched store operation (amortized fsync).
130#[derive(Debug, Clone)]
131pub struct BatchStats {
132    /// Number of files stored in the batch.
133    pub files: usize,
134    /// Total fsync calls issued (chunk dirs + manifest). Much less than 2*N
135    /// for N files because chunk directory fsyncs are deduplicated across the
136    /// batch and the manifest receives a single fsync for all entries.
137    pub fsyncs: usize,
138}
139
140/// Statistics from a delta store operation.
141#[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
150// -----------------------------------------------------------------------
151// CasStore
152// -----------------------------------------------------------------------
153
154/// Content-addressable chunk store.
155pub struct CasStore {
156    root: PathBuf,
157    config: CasConfig,
158    /// Serializes `compact()` (write) against `store_file()` (read) to prevent
159    /// chunk loss when a concurrent store writes chunks between the referenced-set
160    /// computation and the orphan-deletion pass in compact.
161    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    /// Open or create a CAS store at `{path}/.foxing_cas/`.
175    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        // Load or create config
180        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    /// Store a file by chunking its data and writing unique chunks.
202    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    /// Store a file using chunk-level delta, writing only changed chunks.
247    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        // All writes succeeded -> update manifest
338        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    /// Record a file deletion in the manifest.
360    pub fn delete_file(&self, rel_path: &str) -> crate::Result<()> {
361        self.append_manifest(&ManifestOp::Delete { path: rel_path.to_string() })
362    }
363
364    /// Record a file rename in the manifest.
365    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    /// Record a snapshot marker in the manifest.
373    pub fn snapshot(&self, name: &str) -> crate::Result<()> {
374        self.append_manifest(&ManifestOp::Snapshot { name: name.to_string() })
375    }
376
377    /// Reconstruct a file from its chunks.
378    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        // Verify whole-file BLAKE3 (accepts both hex and CID manifest fields)
391        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    /// Replay manifest to get current file state.
406    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 { .. } => {} // metadata only
425            }
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    /// Compact the manifest and remove orphaned chunks.
434    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        // Collect all referenced chunk hashes
443        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        // Rewrite manifest with only current state
451        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        // Remove orphaned chunks
472        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        // Clean empty prefix directories
493        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()); // only succeeds if empty
498        }
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    /// Get store statistics.
507    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    // ---- FXAR CAS storage ----
518
519    /// Store an FXAR archive as a CAS-chunked entity under a synthetic path.
520    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    /// Read an FXAR archive back from CAS, reconstructing it byte-identically.
528    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    /// Check whether an FXAR archive exists in this CAS store.
538    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    // ---- FXAR interchange ----
545
546    /// Import an FXAR v2 archive into this CAS store.
547    /// Reads the archive, stores unique chunks, and creates manifest entries.
548    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        // Read each file via restore, then store in CAS (chunks are re-computed)
554        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            // Strip snapshot/tree prefix from FXAR path (e.g. "snap1/tree/data/file.txt" -> "data/file.txt")
570            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    /// Export this CAS store as an FXAR v2 archive.
586    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        // Reconstruct all files and write via the FXAR writer infrastructure
593        // Create a temporary version store layout that the FXAR writer can consume
594        let tmp_dir = std::env::temp_dir().join(format!("foxing-cas-export-{}", std::process::id()));
595        std::fs::create_dir_all(&tmp_dir)?;
596        // Ensure cleanup on exit
597        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        // Write summary.json
610        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        // Reconstruct files from CAS store into tmp tree
621        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        // Use FXAR writer
631        let store = crate::version_store::VersionStore::open(tmp);
632        crate::fxar::write_archive_seekable(&store, writer, compress, None)
633    }
634
635    // ---- Batch store (amortized fsync) ----
636
637    /// Store multiple files in a single batch with amortized fsync.
638    ///
639    /// Reduces NFS COMMIT RPCs from 2N (per-chunk sync + per-chunk dir sync)
640    /// to ~3 for an N-file batch by deferring all fsyncs to two bulk phases:
641    ///   Phase 1: write all chunks (tmp -> rename, no fsync)
642    ///   Phase 2: fsync each unique chunk shard directory (typically 1-3)
643    ///   Phase 3: append all manifest entries + single fsync
644    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        // Phase 1: write all chunks without per-chunk fsync
656        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        // Phase 2: fsync each unique chunk shard directory
671        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        // Phase 3: append all manifest entries with single fsync
680        self.append_manifest_batch(&manifest_ops)?;
681        fsync_count += 1;
682
683        Ok(BatchStats { files: files.len(), fsyncs: fsync_count })
684    }
685
686    /// Write all chunks for a single file without calling sync_data.
687    ///
688    /// Returns (chunk_cid_hashes, chunk_sizes, touched_shard_dirs).
689    /// Caller is responsible for fsyncing the shard directories afterward.
690    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    /// Write a chunk to disk without any fsync (tmp -> rename only).
734    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            // No sync_data  --  deferred to batch phase 2
747        }
748
749        std::fs::rename(&tmp_path, &final_path)?;
750        Ok(())
751    }
752
753    /// Append multiple manifest operations with a single fsync.
754    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    // ---- Internal helpers ----
770
771    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    /// Crash-safe chunk write: tmp -> sync_data -> rename -> dir sync_data
781    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(()); } // already stored
784
785        // Ensure prefix directory exists
786        if let Some(parent) = final_path.parent() {
787            std::fs::create_dir_all(parent)?;
788        }
789
790        // Write to tmp
791        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        // Atomic rename
799        std::fs::rename(&tmp_path, &final_path)?;
800
801        // Sync target directory to persist the rename
802        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        // Decompress (zstd flag = 1)
816        let flag = match self.config.compression.as_str() {
817            "none" => 0,
818            "lz4" => 2,
819            "gzip" => 3,
820            "xz" => 4,
821            _ => 1, // zstd default
822        };
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            // Partial last line (crash recovery)  --  skip silently
854            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// -----------------------------------------------------------------------
867// Tests
868// -----------------------------------------------------------------------
869
870#[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        // Verify both files read back correctly
907        assert_eq!(store.read_file("file1.dat").unwrap(), data);
908        assert_eq!(store.read_file("file2.dat").unwrap(), data);
909
910        // Count actual chunk files on disk
911        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        // Use a store with smaller chunk params for testability
923        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        // Generate pseudo-random data that creates many chunks at 4KB avg
939        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        // Modify ~5% of data near the middle
949        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        // Most chunks should be deduped (gear hash resynchronizes after the modified region)
958        assert!(stats2.chunks_deduped > 0, "partial change should dedup some chunks (wrote={}, deduped={})",
959            stats2.chunks_written, stats2.chunks_deduped);
960
961        // Read back should return modified version
962        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        // Verify keep.txt still works
983        assert_eq!(store.read_file("keep.txt").unwrap(), b"keepx");
984        // Verify delete.txt is gone
985        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        // Append a partial/corrupt line to manifest
995        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(); // incomplete JSON
998
999        // list_files should skip the corrupt line and return the good file
1000        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        // Create a CAS store with some files
1035        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        // Export CAS -> FXAR
1041        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        // Import FXAR -> new CAS store
1048        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        // Verify files match
1054        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        // Verify content matches
1062        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        // Old JSON without chunk_sizes must deserialize to vec![]
1220        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    // ---- Delta store tests ----
1270
1271    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        // chunk_path returns: root / CHUNKS_DIR / shard / cid
1400        // shard must equal cid[7..9]
1401        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    // ---- Batch store tests ----
1409
1410    #[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        // Per-file store_file: 3 fsyncs each (chunk sync + dir sync + manifest sync) = 30.
1455        // Batch: 0 chunk syncs + unique_dirs + 1 manifest sync. With 10 random CIDs,
1456        // up to 10 shard dirs possible, so max = 11. Always < 30.
1457        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}