Skip to main content

fxcp_core/
fxar.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/fxar.rs  --  FXAR v2 content-addressable archive format
5
6//! FXAR v2: variable-size gear-hash chunking + BLAKE3 CAS + binary index.
7//! Replaces v1's tar+whole-file-dedup with true chunk-level deduplication.
8//!
9//! Archive layout: HEADER | MANIFEST | CHUNK INDEX | CHUNK DATA [ | FOOTER ]
10
11use std::collections::HashSet;
12use std::io::{self, Read, Write, Seek, SeekFrom};
13use std::path::Path;
14use serde::{Serialize, Deserialize};
15use tracing::{info, warn};
16
17use crate::chunker::GearChunker;
18
19// -----------------------------------------------------------------------
20// Magic bytes and constants
21// -----------------------------------------------------------------------
22
23const FXAR_MAGIC: &[u8; 4] = b"FXAR";
24const FXAR_FOOTER_MAGIC: &[u8; 4] = b"FXAF";
25const FXAR_VERSION: u32 = 2;
26const HEADER_SIZE: usize = 64;
27const CHUNK_INDEX_ENTRY_SIZE: usize = 48;
28const FOOTER_SIZE: usize = 32;
29
30/// Maximum allocation size for untrusted FXAR archive fields (64 MiB).
31const MAX_FXAR_ALLOC: u64 = 64 * 1024 * 1024;
32
33// Compression flag values
34const FLAG_COMPRESS_NONE: u32 = 0;
35const FLAG_COMPRESS_ZSTD: u32 = 1;
36const FLAG_COMPRESS_LZ4: u32 = 2;
37const FLAG_COMPRESS_GZIP: u32 = 3;
38const FLAG_COMPRESS_XZ: u32 = 4;
39
40/// Flag indicating the archive contains an extension section directory.
41/// Set in `FxarHeader.flags` (bit 31  --  does not overlap compression bits 0-4).
42pub const FLAG_HAS_EXTENSION_DIR: u32 = 0x8000_0000;
43
44/// Extension section type: HNSW vector index.
45pub const FXAR_EXT_HNSW: u32 = 1;
46/// Extension section type: chunk-level content map.
47pub const FXAR_EXT_CHUNK_MAP: u32 = 2;
48/// Extension section type: file-level content map.
49pub const FXAR_EXT_FILE_MAP: u32 = 3;
50
51const EXTENSION_ENTRY_SIZE: usize = 56;
52
53// -----------------------------------------------------------------------
54// Header (64 bytes, little-endian)
55// -----------------------------------------------------------------------
56
57#[derive(Debug, Clone, Copy)]
58#[repr(C)]
59/// Binary header for FXAR v2 archives (64 bytes, little-endian).
60pub struct FxarHeader {
61    pub magic: [u8; 4],
62    pub version: u32,
63    pub flags: u32,
64    pub chunk_min: u32,
65    pub chunk_max: u32,
66    pub chunk_avg: u32,
67    pub index_offset: u64,
68    pub index_size: u64,
69    pub manifest_offset: u64,
70    pub manifest_size: u64,
71    pub chunk_count: u64,
72}
73// Header is 64 bytes: 4+4+4+4+4+4 + 8+8+8+8+8 = 64
74
75impl FxarHeader {
76    fn new(flags: u32, chunker: &GearChunker) -> Self {
77        Self {
78            magic: *FXAR_MAGIC,
79            version: FXAR_VERSION,
80            flags,
81            chunk_min: chunker.min as u32,
82            chunk_max: chunker.max as u32,
83            chunk_avg: chunker.avg as u32,
84            index_offset: 0,
85            index_size: 0,
86            manifest_offset: 0,
87            manifest_size: 0,
88            chunk_count: 0,
89        }
90    }
91
92    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
93        w.write_all(&self.magic)?;
94        w.write_all(&self.version.to_le_bytes())?;
95        w.write_all(&self.flags.to_le_bytes())?;
96        w.write_all(&self.chunk_min.to_le_bytes())?;
97        w.write_all(&self.chunk_max.to_le_bytes())?;
98        w.write_all(&self.chunk_avg.to_le_bytes())?;
99        w.write_all(&self.index_offset.to_le_bytes())?;
100        w.write_all(&self.index_size.to_le_bytes())?;
101        w.write_all(&self.manifest_offset.to_le_bytes())?;
102        w.write_all(&self.manifest_size.to_le_bytes())?;
103        w.write_all(&self.chunk_count.to_le_bytes())?;
104        Ok(())
105    }
106
107    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
108        let mut buf = [0u8; HEADER_SIZE];
109        r.read_exact(&mut buf)?;
110
111        let magic = [buf[0], buf[1], buf[2], buf[3]];
112        if &magic != FXAR_MAGIC {
113            return Err(io::Error::new(io::ErrorKind::InvalidData, "not an FXAR archive"));
114        }
115
116        Ok(Self {
117            magic,
118            version: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]),
119            flags: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]),
120            chunk_min: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]),
121            chunk_max: u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]),
122            chunk_avg: u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]),
123            index_offset: u64::from_le_bytes([buf[24], buf[25], buf[26], buf[27], buf[28], buf[29], buf[30], buf[31]]),
124            index_size: u64::from_le_bytes([buf[32], buf[33], buf[34], buf[35], buf[36], buf[37], buf[38], buf[39]]),
125            manifest_offset: u64::from_le_bytes([buf[40], buf[41], buf[42], buf[43], buf[44], buf[45], buf[46], buf[47]]),
126            manifest_size: u64::from_le_bytes([buf[48], buf[49], buf[50], buf[51], buf[52], buf[53], buf[54], buf[55]]),
127            chunk_count: u64::from_le_bytes([buf[56], buf[57], buf[58], buf[59], buf[60], buf[61], buf[62], buf[63]]),
128        })
129    }
130}
131
132// -----------------------------------------------------------------------
133// Chunk Index Entry (48 bytes, little-endian)
134// -----------------------------------------------------------------------
135
136#[derive(Debug, Clone)]
137/// Binary chunk index entry (48 bytes, little-endian).
138pub struct ChunkIndexEntry {
139    pub blake3_hash: [u8; 32],
140    pub size: u32,
141    pub offset: u64,
142    pub compressed_size: u32,
143}
144
145impl ChunkIndexEntry {
146    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
147        w.write_all(&self.blake3_hash)?;
148        w.write_all(&self.size.to_le_bytes())?;
149        w.write_all(&self.offset.to_le_bytes())?;
150        w.write_all(&self.compressed_size.to_le_bytes())?;
151        Ok(())
152    }
153
154    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
155        let mut buf = [0u8; CHUNK_INDEX_ENTRY_SIZE];
156        r.read_exact(&mut buf)?;
157
158        let mut blake3_hash = [0u8; 32];
159        blake3_hash.copy_from_slice(&buf[0..32]);
160
161        Ok(Self {
162            blake3_hash,
163            size: u32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]),
164            offset: u64::from_le_bytes([buf[36], buf[37], buf[38], buf[39], buf[40], buf[41], buf[42], buf[43]]),
165            compressed_size: u32::from_le_bytes([buf[44], buf[45], buf[46], buf[47]]),
166        })
167    }
168}
169
170// -----------------------------------------------------------------------
171// Footer (32 bytes, for non-seekable streams)
172// -----------------------------------------------------------------------
173
174#[derive(Debug, Clone)]
175struct FxarFooter {
176    magic: [u8; 4],
177    manifest_offset: u64,
178    index_offset: u64,
179    chunk_count: u64,
180    checksum: u32,
181}
182
183impl FxarFooter {
184    fn compute_checksum(manifest_offset: u64, index_offset: u64, chunk_count: u64) -> u32 {
185        // Simple checksum: XOR of all u32 words
186        let mut crc: u32 = 0;
187        for b in FXAR_FOOTER_MAGIC {
188            crc = crc.wrapping_add(*b as u32);
189        }
190        for word in [
191            manifest_offset as u32, (manifest_offset >> 32) as u32,
192            index_offset as u32, (index_offset >> 32) as u32,
193            chunk_count as u32, (chunk_count >> 32) as u32,
194        ] {
195            crc = crc.wrapping_add(word);
196        }
197        crc
198    }
199
200    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
201        w.write_all(&self.magic)?;
202        w.write_all(&self.manifest_offset.to_le_bytes())?;
203        w.write_all(&self.index_offset.to_le_bytes())?;
204        w.write_all(&self.chunk_count.to_le_bytes())?;
205        w.write_all(&self.checksum.to_le_bytes())?;
206        Ok(())
207    }
208
209    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
210        let mut buf = [0u8; FOOTER_SIZE];
211        r.read_exact(&mut buf)?;
212
213        let magic = [buf[0], buf[1], buf[2], buf[3]];
214        if &magic != FXAR_FOOTER_MAGIC {
215            return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid FXAR footer"));
216        }
217
218        Ok(Self {
219            magic,
220            manifest_offset: u64::from_le_bytes([buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11]]),
221            index_offset: u64::from_le_bytes([buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]]),
222            chunk_count: u64::from_le_bytes([buf[20], buf[21], buf[22], buf[23], buf[24], buf[25], buf[26], buf[27]]),
223            checksum: u32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]),
224        })
225    }
226}
227
228// -----------------------------------------------------------------------
229// Extension Section Directory (variable size, little-endian)
230// -----------------------------------------------------------------------
231
232/// A single entry in the FXAR extension section directory.
233#[derive(Debug, Clone)]
234pub struct FxarExtensionEntry {
235    pub section_type: u32,
236    pub offset: u64,
237    pub size: u64,
238    pub checksum: [u8; 32],
239    pub _reserved: [u8; 4],
240}
241
242impl FxarExtensionEntry {
243    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
244        w.write_all(&self.section_type.to_le_bytes())?;
245        w.write_all(&self.offset.to_le_bytes())?;
246        w.write_all(&self.size.to_le_bytes())?;
247        w.write_all(&self.checksum)?;
248        w.write_all(&self._reserved)?;
249        Ok(())
250    }
251
252    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
253        let mut buf = [0u8; EXTENSION_ENTRY_SIZE];
254        r.read_exact(&mut buf)?;
255
256        let mut checksum = [0u8; 32];
257        checksum.copy_from_slice(&buf[20..52]);
258        let mut reserved = [0u8; 4];
259        reserved.copy_from_slice(&buf[52..56]);
260
261        Ok(Self {
262            section_type: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
263            offset: u64::from_le_bytes([buf[4], buf[5], buf[6], buf[7], buf[8], buf[9], buf[10], buf[11]]),
264            size: u64::from_le_bytes([buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19]]),
265            checksum,
266            _reserved: reserved,
267        })
268    }
269}
270
271/// Directory of extension sections appended to an FXAR archive.
272#[derive(Debug, Clone)]
273pub struct FxarExtensionDirectory {
274    pub entries: Vec<FxarExtensionEntry>,
275}
276
277impl FxarExtensionDirectory {
278    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
279        w.write_all(&(self.entries.len() as u32).to_le_bytes())?;
280        for entry in &self.entries {
281            entry.write_to(w)?;
282        }
283        Ok(())
284    }
285
286    fn read_from<R: Read>(r: &mut R) -> io::Result<Self> {
287        let mut count_buf = [0u8; 4];
288        r.read_exact(&mut count_buf)?;
289        let count = u32::from_le_bytes(count_buf) as usize;
290
291        if count as u64 * EXTENSION_ENTRY_SIZE as u64 > MAX_FXAR_ALLOC {
292            return Err(io::Error::new(
293                io::ErrorKind::InvalidData,
294                format!("extension directory entry count {} exceeds safety limit", count),
295            ));
296        }
297
298        let mut entries = Vec::with_capacity(count);
299        for _ in 0..count {
300            entries.push(FxarExtensionEntry::read_from(r)?);
301        }
302        Ok(Self { entries })
303    }
304}
305
306// -----------------------------------------------------------------------
307// Manifest types (JSON)
308// -----------------------------------------------------------------------
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311/// Archive manifest listing all files and their chunk references.
312pub struct FxarManifest {
313    pub version: u32,
314    pub created: String,
315    pub files: Vec<FxarManifestEntry>,
316    #[serde(default)]
317    pub snapshots: Vec<String>,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
321/// A single file entry in the FXAR manifest.
322pub struct FxarManifestEntry {
323    pub path: String,
324    pub size: u64,
325    pub mode: u32,
326    pub mtime: i64,
327    pub uid: u32,
328    pub gid: u32,
329    pub blake3: String,
330    pub chunks: Vec<u64>,
331    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
332    pub xattr: std::collections::HashMap<String, String>,
333}
334
335// -----------------------------------------------------------------------
336// Export/Import stats
337// -----------------------------------------------------------------------
338
339#[derive(Debug, Default, Serialize, Deserialize)]
340/// Statistics from an FXAR export operation.
341pub struct FxarExportStats {
342    pub snapshots_exported: u64,
343    pub total_files: u64,
344    pub total_apparent_bytes: u64,
345    pub chunk_count: u64,
346    pub unique_chunks: u64,
347    pub dedup_chunks: u64,
348    pub archive_bytes: u64,
349}
350
351impl FxarExportStats {
352    /// Compute the deduplication ratio (0.0 = no savings, 1.0 = all deduped).
353    pub fn dedup_ratio(&self) -> f64 {
354        if self.total_apparent_bytes == 0 { return 0.0; }
355        1.0 - (self.archive_bytes as f64 / self.total_apparent_bytes as f64)
356    }
357}
358
359#[derive(Debug, Default, Serialize, Deserialize)]
360/// Statistics from an FXAR import/restore operation.
361pub struct FxarImportStats {
362    pub files_restored: u64,
363    pub bytes_restored: u64,
364    pub chunks_verified: u64,
365    pub chunks_failed: u64,
366}
367
368// -----------------------------------------------------------------------
369// Writer  --  parallel pipeline
370// -----------------------------------------------------------------------
371
372/// Per-file result from parallel processing (read + chunk + compress).
373struct ProcessedFile {
374    archive_path: String,
375    size: u64,
376    mode: u32,
377    mtime: i64,
378    uid: u32,
379    gid: u32,
380    /// Whole-file BLAKE3 derived from chunk hashes (avoids double-hashing).
381    blake3: [u8; 32],
382    /// (chunk_hash, uncompressed_size, compressed_data)
383    chunks: Vec<([u8; 32], u32, Vec<u8>)>,
384    xattr: std::collections::HashMap<String, String>,
385}
386
387/// Extract (snapshot_timestamp, relative_path) from an FXAR manifest path.
388///
389/// Manifest paths from VersionStore exports have the form `{snapshot}/tree/{rel_path}`.
390/// Direct directory exports have bare relative paths (no snapshot/tree prefix).
391///
392/// Returns `(snapshot_ts, rel_path)` where `snapshot_ts` is empty for direct exports.
393fn strip_snapshot_prefix(path: &str) -> (&str, &str) {
394    if let Some(tree_pos) = path.find("/tree/") {
395        (&path[..tree_pos], &path[tree_pos + 6..])
396    } else {
397        ("", path)
398    }
399}
400
401/// Information about available versions for a file in the archive.
402#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct FileVersionInfo {
404    /// Relative path (stripped of snapshot/tree prefix).
405    pub rel_path: String,
406    /// Snapshot timestamp of the version being restored.
407    pub restored_snapshot: String,
408    /// All available snapshot timestamps for this file (chronological order).
409    pub available_snapshots: Vec<String>,
410    /// Size of the restored version.
411    pub size: u64,
412}
413
414/// Decode a blake3 manifest field that may be hex (legacy) or CID (new).
415fn decode_blake3_field(s: &str) -> Option<[u8; 32]> {
416    if crate::cid::is_blake3_cid_string(s) {
417        crate::cid::cid_string_to_blake3(s).ok()
418    } else {
419        hex::decode(s).ok().and_then(|v| v.try_into().ok())
420    }
421}
422
423/// Collect file paths for all snapshots, then process in parallel with rayon.
424fn collect_and_process_files(
425    snap_dirs: &[std::path::PathBuf],
426    compress: &str,
427) -> Vec<ProcessedFile> {
428    use rayon::prelude::*;
429    use std::os::unix::fs::{MetadataExt, PermissionsExt};
430
431    // Collect all (snap_name, tree_dir, rel_path, full_path) tuples first
432    let mut file_jobs: Vec<(String, std::path::PathBuf)> = Vec::new();
433    for snap_dir in snap_dirs {
434        let tree_dir = snap_dir.join("tree");
435        if !tree_dir.exists() { continue; }
436        let snap_name = snap_dir.file_name().unwrap_or_default().to_string_lossy().into_owned();
437
438        for entry in walkdir::WalkDir::new(&tree_dir).follow_links(false) {
439            let entry = match entry { Ok(e) => e, Err(_) => continue };
440            if !entry.file_type().is_file() { continue; }
441            let rel = match entry.path().strip_prefix(&tree_dir) {
442                Ok(r) => r, Err(_) => continue,
443            };
444            let archive_path = format!("{}/tree/{}", snap_name, rel.display());
445            file_jobs.push((archive_path, entry.path().to_path_buf()));
446        }
447    }
448
449    let chunker = GearChunker::default();
450    let compress_str = compress.to_string();
451
452    // Parallel: read + chunk + hash + compress per file
453    file_jobs.par_iter().filter_map(|(archive_path, full_path)| {
454        let meta = std::fs::metadata(full_path).ok()?;
455        let file_data = std::fs::read(full_path).ok()?;
456
457        // Whole-file BLAKE3 (for integrity verification on restore)
458        let file_hash = *blake3::hash(&file_data).as_bytes();
459
460        // Gear-hash chunk + per-chunk BLAKE3 + compress  --  all in this thread
461        let boundaries = chunker.find_boundaries(&file_data);
462        let mut chunks = Vec::with_capacity(boundaries.len());
463        let mut start = 0;
464        for end in &boundaries {
465            let slice = &file_data[start..*end];
466            let chunk_hash = blake3::hash(slice);
467            let compressed = compress_chunk(slice, &compress_str);
468            chunks.push((*chunk_hash.as_bytes(), slice.len() as u32, compressed));
469            start = *end;
470        }
471
472        // xattr  --  only attempt if file is on a filesystem that supports it
473        let mut xattr_map = std::collections::HashMap::new();
474        if let Ok(attrs) = xattr::list(full_path) {
475            for attr in attrs {
476                let key = attr.to_string_lossy().into_owned();
477                if (key.starts_with("user.foxing")
478                    || key.starts_with("user.dublincore")
479                    || key == "user.xdg.tags"
480                    || key.starts_with("security.")
481                    || key.starts_with("system.posix_acl_"))
482                    && let Ok(Some(val)) = xattr::get(full_path, &attr) {
483                        xattr_map.insert(key, hex::encode(&val));
484                    }
485            }
486        }
487
488        Some(ProcessedFile {
489            archive_path: archive_path.clone(),
490            size: meta.len(),
491            mode: meta.permissions().mode(),
492            mtime: meta.mtime(),
493            uid: meta.uid(),
494            gid: meta.gid(),
495            blake3: file_hash,
496            chunks,
497            xattr: xattr_map,
498        })
499    }).collect()
500}
501
502/// Merge parallel results into dedup tables (single-threaded  --  dedup map is sequential).
503fn build_dedup_tables(
504    processed: Vec<ProcessedFile>,
505) -> (Vec<FxarManifestEntry>, Vec<ChunkIndexEntry>, Vec<Vec<u8>>, FxarExportStats) {
506    let mut seen_chunks: std::collections::HashMap<[u8; 32], u64> = std::collections::HashMap::new();
507    let mut chunk_entries: Vec<ChunkIndexEntry> = Vec::new();
508    let mut chunk_data_blobs: Vec<Vec<u8>> = Vec::new();
509    let mut manifest_entries = Vec::with_capacity(processed.len());
510    let mut stats = FxarExportStats::default();
511
512    for file in processed {
513        stats.total_files += 1;
514        stats.total_apparent_bytes += file.size;
515
516        let mut chunk_indices = Vec::with_capacity(file.chunks.len());
517        for (hash_bytes, uncompressed_size, compressed_data) in file.chunks {
518            if let Some(&existing_idx) = seen_chunks.get(&hash_bytes) {
519                chunk_indices.push(existing_idx);
520                stats.dedup_chunks += 1;
521            } else {
522                let idx = chunk_entries.len() as u64;
523                seen_chunks.insert(hash_bytes, idx);
524                chunk_entries.push(ChunkIndexEntry {
525                    blake3_hash: hash_bytes,
526                    size: uncompressed_size,
527                    offset: 0,
528                    compressed_size: compressed_data.len() as u32,
529                });
530                chunk_data_blobs.push(compressed_data);
531                chunk_indices.push(idx);
532                stats.unique_chunks += 1;
533            }
534            stats.chunk_count += 1;
535        }
536
537        manifest_entries.push(FxarManifestEntry {
538            path: file.archive_path,
539            size: file.size,
540            mode: file.mode,
541            mtime: file.mtime,
542            uid: file.uid,
543            gid: file.gid,
544            blake3: crate::cid::blake3_to_cid_string(&file.blake3),
545            chunks: chunk_indices,
546            xattr: file.xattr,
547        });
548    }
549
550    (manifest_entries, chunk_entries, chunk_data_blobs, stats)
551}
552
553/// Emit the FXAR v2 binary archive to a writer.
554fn emit_archive<W: Write>(
555    mut writer: W,
556    compress: &str,
557    snap_names: Vec<String>,
558    manifest_entries: Vec<FxarManifestEntry>,
559    mut chunk_entries: Vec<ChunkIndexEntry>,
560    chunk_data_blobs: &[Vec<u8>],
561    _stats: &FxarExportStats,
562    append_footer: bool,
563) -> crate::Result<(FxarHeader, u64)> {
564    let chunker = GearChunker::default();
565    let flags = compress_flag(compress);
566    let mut header = FxarHeader::new(flags, &chunker);
567    header.chunk_count = chunk_entries.len() as u64;
568
569    let manifest = FxarManifest {
570        version: FXAR_VERSION,
571        created: chrono::Utc::now().to_rfc3339(),
572        files: manifest_entries,
573        snapshots: snap_names,
574    };
575
576    // Write placeholder header
577    header.write_to(&mut writer)?;
578    let mut offset = HEADER_SIZE as u64;
579
580    // Write manifest (JSON, compressed)
581    let manifest_json = serde_json::to_vec(&manifest)
582        .map_err(|e| crate::error::FxcpError::Config(format!("manifest JSON error: {}", e)))?;
583    let manifest_compressed = compress_chunk(&manifest_json, compress);
584    header.manifest_offset = offset;
585    header.manifest_size = manifest_compressed.len() as u64;
586    writer.write_all(&manifest_compressed.len().to_le_bytes())?;
587    writer.write_all(&manifest_json.len().to_le_bytes())?;
588    writer.write_all(&manifest_compressed)?;
589    offset += 16 + manifest_compressed.len() as u64;
590
591    // Write chunk index
592    header.index_offset = offset;
593    header.index_size = (chunk_entries.len() * CHUNK_INDEX_ENTRY_SIZE) as u64;
594
595    let chunk_data_start = offset + header.index_size;
596    let mut data_offset = chunk_data_start;
597    for (i, entry) in chunk_entries.iter_mut().enumerate() {
598        entry.offset = data_offset;
599        data_offset += chunk_data_blobs[i].len() as u64;
600    }
601    for entry in &chunk_entries {
602        entry.write_to(&mut writer)?;
603    }
604    offset += header.index_size;
605
606    // Write chunk data
607    for blob in chunk_data_blobs {
608        writer.write_all(blob)?;
609        offset += blob.len() as u64;
610    }
611
612    if append_footer {
613        let footer = FxarFooter {
614            magic: *FXAR_FOOTER_MAGIC,
615            manifest_offset: header.manifest_offset,
616            index_offset: header.index_offset,
617            chunk_count: header.chunk_count,
618            checksum: FxarFooter::compute_checksum(
619                header.manifest_offset, header.index_offset, header.chunk_count
620            ),
621        };
622        footer.write_to(&mut writer)?;
623        offset += FOOTER_SIZE as u64;
624    }
625
626    writer.flush()?;
627    Ok((header, offset))
628}
629
630/// Write an FXAR v2 archive from a VersionStore (streaming  --  appends footer).
631pub fn write_archive<W: Write>(
632    store: &crate::version_store::VersionStore,
633    writer: W,
634    compress: &str,
635    timestamp_filter: Option<&str>,
636) -> crate::Result<FxarExportStats> {
637    let snap_dirs = store.collect_snap_dirs(timestamp_filter)?;
638    let snap_names: Vec<String> = snap_dirs.iter()
639        .map(|d| d.file_name().unwrap_or_default().to_string_lossy().into_owned())
640        .collect();
641
642    let processed = collect_and_process_files(&snap_dirs, compress);
643    let snap_count = snap_dirs.len() as u64;
644    let (manifest_entries, chunk_entries, chunk_data_blobs, mut stats) =
645        build_dedup_tables(processed);
646    stats.snapshots_exported = snap_count;
647
648    let (_header, offset) = emit_archive(
649        writer, compress, snap_names, manifest_entries,
650        chunk_entries, &chunk_data_blobs, &stats, true,
651    )?;
652    stats.archive_bytes = offset;
653
654    info!("FXAR v2 export: {} snapshots, {} files, {} chunks ({} unique, {} dedup), {:.1}% dedup ratio",
655          stats.snapshots_exported, stats.total_files, stats.chunk_count,
656          stats.unique_chunks, stats.dedup_chunks, stats.dedup_ratio() * 100.0);
657
658    Ok(stats)
659}
660
661/// Write FXAR v2 with seekable writer  --  updates header offsets in-place.
662pub fn write_archive_seekable<W: Write + Seek>(
663    store: &crate::version_store::VersionStore,
664    mut writer: W,
665    compress: &str,
666    timestamp_filter: Option<&str>,
667) -> crate::Result<FxarExportStats> {
668    let snap_dirs = store.collect_snap_dirs(timestamp_filter)?;
669    let snap_names: Vec<String> = snap_dirs.iter()
670        .map(|d| d.file_name().unwrap_or_default().to_string_lossy().into_owned())
671        .collect();
672
673    let processed = collect_and_process_files(&snap_dirs, compress);
674    let snap_count = snap_dirs.len() as u64;
675    let (manifest_entries, chunk_entries, chunk_data_blobs, mut stats) =
676        build_dedup_tables(processed);
677    stats.snapshots_exported = snap_count;
678
679    let (header, offset) = emit_archive(
680        &mut writer, compress, snap_names, manifest_entries,
681        chunk_entries, &chunk_data_blobs, &stats, false,
682    )?;
683    stats.archive_bytes = offset;
684
685    // Seek back and update header with real offsets
686    writer.seek(SeekFrom::Start(0))?;
687    header.write_to(&mut writer)?;
688    writer.seek(SeekFrom::End(0))?;
689    writer.flush()?;
690
691    info!("FXAR v2 export (seekable): {} snapshots, {} files, {} unique chunks, {:.1}% dedup",
692          stats.snapshots_exported, stats.total_files, stats.unique_chunks,
693          stats.dedup_ratio() * 100.0);
694
695    Ok(stats)
696}
697
698pub fn write_archive_seekable_with_ai<W: Write + Seek>(
699    store: &crate::version_store::VersionStore,
700    mut writer: W,
701    compress: &str,
702    timestamp_filter: Option<&str>,
703    extension_sections: Vec<(u32, Vec<u8>)>,
704) -> crate::Result<FxarExportStats> {
705    let snap_dirs = store.collect_snap_dirs(timestamp_filter)?;
706    let snap_names: Vec<String> = snap_dirs.iter()
707        .map(|d| d.file_name().unwrap_or_default().to_string_lossy().into_owned())
708        .collect();
709
710    let processed = collect_and_process_files(&snap_dirs, compress);
711    let snap_count = snap_dirs.len() as u64;
712    let (manifest_entries, chunk_entries, chunk_data_blobs, mut stats) =
713        build_dedup_tables(processed);
714    stats.snapshots_exported = snap_count;
715
716    let (mut header, offset) = emit_archive(
717        &mut writer, compress, snap_names, manifest_entries,
718        chunk_entries, &chunk_data_blobs, &stats, false,
719    )?;
720    stats.archive_bytes = offset;
721
722    if !extension_sections.is_empty() {
723        let mut ext_entries = Vec::with_capacity(extension_sections.len());
724
725        for (section_type, data) in &extension_sections {
726            let section_offset = writer.stream_position()?;
727            writer.write_all(data)?;
728            let checksum: [u8; 32] = *blake3::hash(data).as_bytes();
729            ext_entries.push(FxarExtensionEntry {
730                section_type: *section_type,
731                offset: section_offset,
732                size: data.len() as u64,
733                checksum,
734                _reserved: [0u8; 4],
735            });
736        }
737
738        let ext_dir = FxarExtensionDirectory { entries: ext_entries };
739        let ext_dir_offset = writer.stream_position()?;
740        ext_dir.write_to(&mut writer)?;
741
742        // 8-byte sentinel: absolute offset of the extension directory
743        writer.write_all(&ext_dir_offset.to_le_bytes())?;
744
745        // Trailing footer so sentinel position is well-defined at file_end - 40
746        let footer = FxarFooter {
747            magic: *FXAR_FOOTER_MAGIC,
748            manifest_offset: header.manifest_offset,
749            index_offset: header.index_offset,
750            chunk_count: header.chunk_count,
751            checksum: FxarFooter::compute_checksum(
752                header.manifest_offset, header.index_offset, header.chunk_count,
753            ),
754        };
755        footer.write_to(&mut writer)?;
756
757        header.flags |= FLAG_HAS_EXTENSION_DIR;
758        stats.archive_bytes = writer.stream_position()?;
759    }
760
761    writer.seek(SeekFrom::Start(0))?;
762    header.write_to(&mut writer)?;
763    writer.seek(SeekFrom::End(0))?;
764    writer.flush()?;
765
766    info!("FXAR v2 export (seekable+ai): {} snapshots, {} files, {} unique chunks, {} ext sections, {:.1}% dedup",
767          stats.snapshots_exported, stats.total_files, stats.unique_chunks,
768          extension_sections.len(), stats.dedup_ratio() * 100.0);
769
770    Ok(stats)
771}
772
773/// Read `.foxing_index/` files from disk and return them as raw
774/// `(section_type, bytes)` pairs ready for [`write_archive_seekable_with_ai`].
775///
776/// Always includes `chunk_map.bin` ([`FXAR_EXT_CHUNK_MAP`]) and
777/// `file_map.bin` ([`FXAR_EXT_FILE_MAP`]) when present.
778/// `vectors.usearch` ([`FXAR_EXT_HNSW`]) is included only when
779/// `include_vectors` is `true`.
780///
781/// Returns an empty `Vec` if `index_dir` does not exist.
782pub fn prepare_ai_extension_sections(
783    index_dir: &Path,
784    include_vectors: bool,
785) -> io::Result<Vec<(u32, Vec<u8>)>> {
786    if !index_dir.exists() {
787        return Ok(vec![]);
788    }
789    let mut sections = Vec::new();
790    for (path, section_type) in [
791        (index_dir.join("chunk_map.bin"), FXAR_EXT_CHUNK_MAP),
792        (index_dir.join("file_map.bin"), FXAR_EXT_FILE_MAP),
793    ] {
794        if path.exists() {
795            let data = std::fs::read(&path)?;
796            sections.push((section_type, data));
797        }
798    }
799    if include_vectors {
800        let vectors_path = index_dir.join("vectors.usearch");
801        if vectors_path.exists() {
802            let data = std::fs::read(&vectors_path)?;
803            sections.push((FXAR_EXT_HNSW, data));
804        }
805    }
806    Ok(sections)
807}
808
809/// Convenience wrapper: reads `.foxing_index/` files via
810/// [`prepare_ai_extension_sections`], then writes the archive with
811/// [`write_archive_seekable_with_ai`].
812pub fn write_archive_with_ai_index<W: Write + Seek>(
813    store: &crate::version_store::VersionStore,
814    writer: W,
815    compress: &str,
816    timestamp_filter: Option<&str>,
817    index_dir: Option<&Path>,
818    include_vectors: bool,
819) -> crate::Result<FxarExportStats> {
820    let sections = match index_dir {
821        Some(dir) => prepare_ai_extension_sections(dir, include_vectors)
822            .map_err(crate::error::FxcpError::Io)?,
823        None => vec![],
824    };
825    write_archive_seekable_with_ai(store, writer, compress, timestamp_filter, sections)
826}
827
828/// Write an FXAR v2 archive directly from a directory (no VersionStore required).
829///
830/// Walks `source_dir` and archives all files with bare relative paths in the
831/// manifest (no `{snapshot}/tree/` prefix). This is the "just archive this
832/// directory" mode  --  like `tar -cf archive.fxar /source`.
833pub fn write_archive_from_directory<W: Write + Seek>(
834    source_dir: &Path,
835    mut writer: W,
836    compress: &str,
837) -> crate::Result<FxarExportStats> {
838    let processed = collect_files_from_directory(source_dir, compress);
839    let (manifest_entries, chunk_entries, chunk_data_blobs, mut stats) =
840        build_dedup_tables(processed);
841    stats.snapshots_exported = 0;
842
843    let (header, offset) = emit_archive(
844        &mut writer, compress, vec![], manifest_entries,
845        chunk_entries, &chunk_data_blobs, &stats, false,
846    )?;
847    stats.archive_bytes = offset;
848
849    writer.seek(SeekFrom::Start(0))?;
850    header.write_to(&mut writer)?;
851    writer.seek(SeekFrom::End(0))?;
852    writer.flush()?;
853
854    info!("FXAR v2 export (directory): {} files, {} unique chunks, {:.1}% dedup",
855          stats.total_files, stats.unique_chunks, stats.dedup_ratio() * 100.0);
856
857    Ok(stats)
858}
859
860/// Write an FXAR v2 archive from a directory to a non-seekable stream (appends footer).
861pub fn write_archive_from_directory_stream<W: Write>(
862    source_dir: &Path,
863    writer: W,
864    compress: &str,
865) -> crate::Result<FxarExportStats> {
866    let processed = collect_files_from_directory(source_dir, compress);
867    let (manifest_entries, chunk_entries, chunk_data_blobs, mut stats) =
868        build_dedup_tables(processed);
869    stats.snapshots_exported = 0;
870
871    let (_header, offset) = emit_archive(
872        writer, compress, vec![], manifest_entries,
873        chunk_entries, &chunk_data_blobs, &stats, true,
874    )?;
875    stats.archive_bytes = offset;
876
877    info!("FXAR v2 export (directory, streaming): {} files, {} unique chunks, {:.1}% dedup",
878          stats.total_files, stats.unique_chunks, stats.dedup_ratio() * 100.0);
879
880    Ok(stats)
881}
882
883/// Collect files from a directory with bare relative paths (no snapshot/tree prefix).
884fn collect_files_from_directory(
885    source_dir: &Path,
886    compress: &str,
887) -> Vec<ProcessedFile> {
888    use rayon::prelude::*;
889    use std::os::unix::fs::{MetadataExt, PermissionsExt};
890
891    let mut file_jobs: Vec<(String, std::path::PathBuf)> = Vec::new();
892    for entry in walkdir::WalkDir::new(source_dir).follow_links(false) {
893        let entry = match entry { Ok(e) => e, Err(_) => continue };
894        if !entry.file_type().is_file() { continue; }
895        let rel = match entry.path().strip_prefix(source_dir) {
896            Ok(r) => r, Err(_) => continue,
897        };
898        // Skip .foxing internal directories
899        let rel_str = rel.to_string_lossy();
900        if rel_str.starts_with(".foxing") { continue; }
901        file_jobs.push((rel_str.into_owned(), entry.path().to_path_buf()));
902    }
903
904    let chunker = GearChunker::default();
905    let compress_str = compress.to_string();
906
907    file_jobs.par_iter().filter_map(|(archive_path, full_path)| {
908        let meta = std::fs::metadata(full_path).ok()?;
909        let file_data = std::fs::read(full_path).ok()?;
910        let file_hash = *blake3::hash(&file_data).as_bytes();
911
912        let boundaries = chunker.find_boundaries(&file_data);
913        let mut chunks = Vec::with_capacity(boundaries.len());
914        let mut start = 0;
915        for end in &boundaries {
916            let slice = &file_data[start..*end];
917            let chunk_hash = blake3::hash(slice);
918            let compressed = compress_chunk(slice, &compress_str);
919            chunks.push((*chunk_hash.as_bytes(), slice.len() as u32, compressed));
920            start = *end;
921        }
922
923        let mut xattr_map = std::collections::HashMap::new();
924        if let Ok(attrs) = xattr::list(full_path) {
925            for attr in attrs {
926                let key = attr.to_string_lossy().into_owned();
927                if (key.starts_with("user.foxing")
928                    || key.starts_with("user.dublincore")
929                    || key == "user.xdg.tags"
930                    || key.starts_with("security.")
931                    || key.starts_with("system.posix_acl_"))
932                    && let Ok(Some(val)) = xattr::get(full_path, &attr) {
933                        xattr_map.insert(key, hex::encode(&val));
934                    }
935            }
936        }
937
938        Some(ProcessedFile {
939            archive_path: archive_path.clone(),
940            size: meta.len(),
941            mode: meta.permissions().mode(),
942            mtime: meta.mtime(),
943            uid: meta.uid(),
944            gid: meta.gid(),
945            blake3: file_hash,
946            chunks,
947            xattr: xattr_map,
948        })
949    }).collect()
950}
951
952/// Update the AI extension sections of an existing FXAR archive in-place.
953///
954/// Only the extension-section tail is rewritten  --  manifest, chunk index, and chunk data
955/// are never touched. Handles both archives that already have extension sections (update)
956/// and those that don't (append).
957///
958/// `sections` is a list of `(section_type, raw_bytes)` pairs, matching the format
959/// used by [`write_archive_seekable_with_ai`].
960pub fn update_fxar_ai_sections(path: &Path, sections: &[(u32, Vec<u8>)]) -> io::Result<()> {
961    if sections.is_empty() {
962        return Ok(());
963    }
964
965    let mut f = std::fs::OpenOptions::new()
966        .read(true)
967        .write(true)
968        .open(path)?;
969    let file_size = f.seek(SeekFrom::End(0))?;
970    if file_size < HEADER_SIZE as u64 {
971        return Err(io::Error::new(
972            io::ErrorKind::InvalidData,
973            "FXAR too small",
974        ));
975    }
976
977    f.seek(SeekFrom::Start(0))?;
978    let mut header = FxarHeader::read_from(&mut f)?;
979
980    let (insertion_point, manifest_offset, index_offset, chunk_count) =
981        if header.flags & FLAG_HAS_EXTENSION_DIR != 0 {
982            // Already has extension dir  --  read sentinel to find where sections start
983            let sentinel_pos = file_size - FOOTER_SIZE as u64 - 8;
984            f.seek(SeekFrom::Start(sentinel_pos))?;
985            let mut buf = [0u8; 8];
986            f.read_exact(&mut buf)?;
987            let ext_dir_offset = u64::from_le_bytes(buf);
988
989            f.seek(SeekFrom::Start(file_size - FOOTER_SIZE as u64))?;
990            let footer = FxarFooter::read_from(&mut f)?;
991
992            // Find the earliest section data offset from the extension directory
993            f.seek(SeekFrom::Start(ext_dir_offset))?;
994            let ext_dir = FxarExtensionDirectory::read_from(&mut f)?;
995            let earliest = ext_dir
996                .entries
997                .iter()
998                .map(|e| e.offset)
999                .min()
1000                .unwrap_or(ext_dir_offset);
1001
1002            (earliest, footer.manifest_offset, footer.index_offset, footer.chunk_count)
1003        } else {
1004            // No extension dir yet
1005            let has_footer = if file_size >= FOOTER_SIZE as u64 {
1006                f.seek(SeekFrom::Start(file_size - FOOTER_SIZE as u64))?;
1007                let mut magic = [0u8; 4];
1008                f.read_exact(&mut magic).is_ok() && &magic == FXAR_FOOTER_MAGIC
1009            } else {
1010                false
1011            };
1012
1013            if has_footer {
1014                f.seek(SeekFrom::Start(file_size - FOOTER_SIZE as u64))?;
1015                let footer = FxarFooter::read_from(&mut f)?;
1016                (
1017                    file_size - FOOTER_SIZE as u64,
1018                    footer.manifest_offset,
1019                    footer.index_offset,
1020                    footer.chunk_count,
1021                )
1022            } else {
1023                (
1024                    file_size,
1025                    header.manifest_offset,
1026                    header.index_offset,
1027                    header.chunk_count,
1028                )
1029            }
1030        };
1031
1032    f.seek(SeekFrom::Start(insertion_point))?;
1033
1034    let mut ext_entries = Vec::with_capacity(sections.len());
1035    for (section_type, data) in sections {
1036        let section_offset = f.stream_position()?;
1037        f.write_all(data)?;
1038        let checksum: [u8; 32] = *blake3::hash(data).as_bytes();
1039        ext_entries.push(FxarExtensionEntry {
1040            section_type: *section_type,
1041            offset: section_offset,
1042            size: data.len() as u64,
1043            checksum,
1044            _reserved: [0u8; 4],
1045        });
1046    }
1047
1048    let ext_dir_offset = f.stream_position()?;
1049    let ext_dir = FxarExtensionDirectory {
1050        entries: ext_entries,
1051    };
1052    ext_dir.write_to(&mut f)?;
1053
1054    f.write_all(&ext_dir_offset.to_le_bytes())?;
1055
1056    let new_footer = FxarFooter {
1057        magic: *FXAR_FOOTER_MAGIC,
1058        manifest_offset,
1059        index_offset,
1060        chunk_count,
1061        checksum: FxarFooter::compute_checksum(manifest_offset, index_offset, chunk_count),
1062    };
1063    new_footer.write_to(&mut f)?;
1064
1065    let new_size = f.stream_position()?;
1066    f.set_len(new_size)?;
1067    f.flush()?;
1068
1069    if header.flags & FLAG_HAS_EXTENSION_DIR == 0 {
1070        header.flags |= FLAG_HAS_EXTENSION_DIR;
1071        f.seek(SeekFrom::Start(0))?;
1072        header.write_to(&mut f)?;
1073        f.flush()?;
1074    }
1075
1076    Ok(())
1077}
1078
1079// -----------------------------------------------------------------------
1080// Reader (seekable random access)
1081// -----------------------------------------------------------------------
1082
1083/// Read an FXAR v2 archive with random access.
1084pub struct FxarReader<R: Read + Seek> {
1085    reader: R,
1086    pub header: FxarHeader,
1087}
1088
1089impl<R: Read + Seek> FxarReader<R> {
1090    /// Open an FXAR v2 archive for reading.
1091    ///
1092    /// Automatically handles streaming-mode archives whose header has placeholder
1093    /// offsets (manifest_offset=0, index_offset=0) by falling back to the footer.
1094    pub fn open(mut reader: R) -> io::Result<Self> {
1095        let mut header = FxarHeader::read_from(&mut reader)?;
1096        if header.version != FXAR_VERSION {
1097            return Err(io::Error::new(
1098                io::ErrorKind::InvalidData,
1099                format!("unsupported FXAR version: {}", header.version),
1100            ));
1101        }
1102
1103        // Streaming-mode archives write placeholder offsets in the header and
1104        // append a footer with the real values. Detect and patch.
1105        // manifest_offset < HEADER_SIZE means it's a placeholder (valid offset is always >= 64).
1106        if (header.manifest_offset as usize) < HEADER_SIZE {
1107            let file_size = reader.seek(SeekFrom::End(0))?;
1108            if file_size >= (HEADER_SIZE + FOOTER_SIZE) as u64 {
1109                reader.seek(SeekFrom::Start(file_size - FOOTER_SIZE as u64))?;
1110                if let Ok(footer) = FxarFooter::read_from(&mut reader) {
1111                    header.manifest_offset = footer.manifest_offset;
1112                    header.index_offset = footer.index_offset;
1113                    header.chunk_count = footer.chunk_count;
1114                    header.index_size = footer.chunk_count * CHUNK_INDEX_ENTRY_SIZE as u64;
1115                }
1116            }
1117        }
1118
1119        Ok(Self { reader, header })
1120    }
1121
1122    /// Read and parse the manifest.
1123    pub fn read_manifest(&mut self) -> io::Result<FxarManifest> {
1124        self.reader.seek(SeekFrom::Start(self.header.manifest_offset))?;
1125        let mut size_buf = [0u8; 8];
1126        self.reader.read_exact(&mut size_buf)?;
1127        let compressed_len = u64::from_le_bytes(size_buf);
1128        if compressed_len > MAX_FXAR_ALLOC {
1129            return Err(io::Error::new(
1130                io::ErrorKind::InvalidData,
1131                format!("FXAR manifest compressed size {} exceeds maximum {}", compressed_len, MAX_FXAR_ALLOC),
1132            ));
1133        }
1134        let compressed_len = compressed_len as usize;
1135        self.reader.read_exact(&mut size_buf)?;
1136        let _uncompressed_len = u64::from_le_bytes(size_buf) as usize;
1137
1138        let mut compressed = vec![0u8; compressed_len];
1139        self.reader.read_exact(&mut compressed)?;
1140
1141        let json_data = decompress_chunk(&compressed, self.header.flags)?;
1142        serde_json::from_slice(&json_data).map_err(|e|
1143            io::Error::new(io::ErrorKind::InvalidData, format!("manifest JSON: {}", e))
1144        )
1145    }
1146
1147    /// Read the chunk index.
1148    pub fn read_chunk_index(&mut self) -> io::Result<Vec<ChunkIndexEntry>> {
1149        self.reader.seek(SeekFrom::Start(self.header.index_offset))?;
1150        let count = self.header.chunk_count as usize;
1151        let mut entries = Vec::with_capacity(count);
1152        for _ in 0..count {
1153            entries.push(ChunkIndexEntry::read_from(&mut self.reader)?);
1154        }
1155        Ok(entries)
1156    }
1157
1158    /// Read and decompress a single chunk by its index entry.
1159    fn read_chunk_data(&mut self, entry: &ChunkIndexEntry) -> io::Result<Vec<u8>> {
1160        if entry.compressed_size as u64 > MAX_FXAR_ALLOC {
1161            return Err(io::Error::new(
1162                io::ErrorKind::InvalidData,
1163                format!("FXAR chunk compressed size {} exceeds maximum {}", entry.compressed_size, MAX_FXAR_ALLOC),
1164            ));
1165        }
1166        self.reader.seek(SeekFrom::Start(entry.offset))?;
1167        let mut compressed = vec![0u8; entry.compressed_size as usize];
1168        self.reader.read_exact(&mut compressed)?;
1169
1170        let data = decompress_chunk(&compressed, self.header.flags)?;
1171
1172        // Verify BLAKE3
1173        let actual_hash = blake3::hash(&data);
1174        if actual_hash.as_bytes() != &entry.blake3_hash {
1175            return Err(io::Error::new(
1176                io::ErrorKind::InvalidData,
1177                format!("BLAKE3 mismatch for chunk at offset {}", entry.offset),
1178            ));
1179        }
1180
1181        Ok(data)
1182    }
1183
1184    /// Restore a single file by path from the archive.
1185    pub fn restore_file(&mut self, file_path: &str) -> io::Result<Vec<u8>> {
1186        let manifest = self.read_manifest()?;
1187        let index = self.read_chunk_index()?;
1188
1189        let entry = manifest.files.iter()
1190            .find(|f| f.path == file_path)
1191            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found in archive"))?;
1192
1193        let mut data = Vec::with_capacity(entry.size as usize);
1194        for &chunk_idx in &entry.chunks {
1195            let chunk_meta = &index[chunk_idx as usize];
1196            let chunk_data = self.read_chunk_data(chunk_meta)?;
1197            data.extend_from_slice(&chunk_data);
1198        }
1199
1200        // Verify whole-file BLAKE3 (accepts both hex and CID manifest fields)
1201        let actual = blake3::hash(&data);
1202        let expected = decode_blake3_field(&entry.blake3).unwrap_or_default();
1203        if *actual.as_bytes() != expected {
1204            return Err(io::Error::new(
1205                io::ErrorKind::InvalidData,
1206                format!("file BLAKE3 mismatch for {}", file_path),
1207            ));
1208        }
1209
1210        Ok(data)
1211    }
1212
1213    /// Read the first `max_bytes` of a file from the archive without
1214    /// verifying the whole-file BLAKE3 hash (partial reads cannot be
1215    /// hash-verified).  Per-chunk BLAKE3 is still checked.
1216    ///
1217    /// Returns fewer than `max_bytes` if the file is smaller.
1218    pub fn restore_file_head(&mut self, file_path: &str, max_bytes: usize) -> io::Result<Vec<u8>> {
1219        let manifest = self.read_manifest()?;
1220        let index = self.read_chunk_index()?;
1221
1222        let entry = manifest.files.iter()
1223            .find(|f| f.path == file_path)
1224            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "file not found in archive"))?;
1225
1226        let mut data = Vec::with_capacity(max_bytes.min(entry.size as usize));
1227        for &chunk_idx in &entry.chunks {
1228            let chunk_meta = &index[chunk_idx as usize];
1229            let chunk_data = self.read_chunk_data(chunk_meta)?;
1230            data.extend_from_slice(&chunk_data);
1231            if data.len() >= max_bytes {
1232                break;
1233            }
1234        }
1235        data.truncate(max_bytes);
1236        Ok(data)
1237    }
1238
1239    /// Restore all files to a target directory.
1240    ///
1241    /// Pipelined: chunk loading overlaps with file writing.
1242    /// Producer thread reads chunks sequentially; consumer rayon threads
1243    /// write files as soon as their chunks become available.
1244    pub fn restore_all(&mut self, target: &Path, generate_sigs: bool) -> io::Result<FxarImportStats> {
1245        // Pre-flight disk space check
1246        let total_bytes: u64 = {
1247            let m = self.read_manifest()?;
1248            m.files.iter().map(|f| f.size).sum()
1249        };
1250        if let Some(avail) = check_available_space(target) {
1251            let needed = total_bytes + crate::constants::FXAR_RESTORE_HEADROOM_BYTES;
1252            if avail < needed {
1253                return Err(io::Error::other(
1254                    format!("insufficient disk space: {} bytes available, {} bytes needed",
1255                            avail, needed),
1256                ));
1257            }
1258        }
1259
1260        let manifest = self.read_manifest()?;
1261        let index = self.read_chunk_index()?;
1262        let chunk_count = index.len();
1263
1264        // Pre-create all parent directories (sequential, must happen before writes)
1265        for file_entry in &manifest.files {
1266            let dest = target.join(&file_entry.path);
1267            if let Some(parent) = dest.parent() {
1268                std::fs::create_dir_all(parent)?;
1269            }
1270        }
1271
1272        // Pipelined chunk loading: allocate chunk store, load in background thread
1273        let chunk_store: Vec<parking_lot::RwLock<Option<Vec<u8>>>> =
1274            (0..chunk_count).map(|_| parking_lot::RwLock::new(None)).collect();
1275        let chunk_store = std::sync::Arc::new(chunk_store);
1276        let chunks_loaded = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1277
1278        // Sort files by their highest chunk index so we can process them
1279        // as soon as their chunks become available
1280        let mut file_order: Vec<(usize, u64)> = manifest.files.iter().enumerate()
1281            .map(|(i, f)| (i, f.chunks.iter().copied().max().unwrap_or(0)))
1282            .collect();
1283        file_order.sort_by_key(|&(_, max_chunk)| max_chunk);
1284
1285        // Producer: load chunks in a background thread
1286        let producer_store = chunk_store.clone();
1287        let producer_loaded = chunks_loaded.clone();
1288        let producer_index = index.clone();
1289        // We need to move reader data out of self for the producer thread.
1290        // Read all compressed chunks first (sequential seeks), decompress in producer.
1291        let flags = self.header.flags;
1292        let mut raw_chunks: Vec<Vec<u8>> = Vec::with_capacity(chunk_count);
1293        for entry in &producer_index {
1294            if entry.compressed_size as u64 > MAX_FXAR_ALLOC {
1295                return Err(io::Error::new(
1296                    io::ErrorKind::InvalidData,
1297                    format!("FXAR chunk compressed size {} exceeds maximum {}", entry.compressed_size, MAX_FXAR_ALLOC),
1298                ));
1299            }
1300            self.reader.seek(SeekFrom::Start(entry.offset))?;
1301            let mut compressed = vec![0u8; entry.compressed_size as usize];
1302            self.reader.read_exact(&mut compressed)?;
1303            raw_chunks.push(compressed);
1304        }
1305
1306        let producer_handle = std::thread::spawn(move || {
1307            for (i, compressed) in raw_chunks.into_iter().enumerate() {
1308                let data = match decompress_chunk(&compressed, flags) {
1309                    Ok(d) => d,
1310                    Err(e) => {
1311                        warn!("chunk {} decompress failed: {}", i, e);
1312                        Vec::new() // empty = will fail BLAKE3 verify
1313                    }
1314                };
1315                // Verify per-chunk BLAKE3
1316                let actual = blake3::hash(&data);
1317                if actual.as_bytes() != &producer_index[i].blake3_hash {
1318                    warn!("chunk {} BLAKE3 mismatch", i);
1319                }
1320                *producer_store[i].write() = Some(data);
1321                producer_loaded.store(i + 1, std::sync::atomic::Ordering::Release);
1322            }
1323        });
1324
1325        // NFS bypass pool
1326        #[cfg(feature = "nfs-bypass")]
1327        let nfs_pool: Option<crate::nfs::NfsClientPool> = init_nfs_pool(target);
1328
1329        // Consumer: process files in chunk-availability order using rayon
1330        use rayon::prelude::*;
1331        use std::sync::atomic::{AtomicU64, Ordering};
1332
1333        let files_restored = AtomicU64::new(0);
1334        let bytes_restored = AtomicU64::new(0);
1335        let chunks_verified = AtomicU64::new(0);
1336        let chunks_failed = AtomicU64::new(0);
1337
1338        // Process files in batches based on chunk availability
1339        file_order.par_iter().for_each(|&(file_idx, max_chunk)| {
1340            let file_entry = &manifest.files[file_idx];
1341            let dest = target.join(&file_entry.path);
1342
1343            // Wait for all required chunks to be loaded
1344            let needed = max_chunk as usize + 1;
1345            while chunks_loaded.load(Ordering::Acquire) < needed {
1346                std::hint::spin_loop();
1347            }
1348
1349            // Assemble file from chunks
1350            let mut data = Vec::with_capacity(file_entry.size as usize);
1351            for &chunk_idx in &file_entry.chunks {
1352                let guard = chunk_store[chunk_idx as usize].read();
1353                if let Some(chunk_data) = guard.as_ref() {
1354                    data.extend_from_slice(chunk_data);
1355                    chunks_verified.fetch_add(1, Ordering::Relaxed);
1356                } else {
1357                    chunks_failed.fetch_add(1, Ordering::Relaxed);
1358                    return;
1359                }
1360            }
1361
1362            // Verify whole-file BLAKE3 (accepts both hex and CID manifest fields)
1363            let actual = blake3::hash(&data);
1364            let expected = decode_blake3_field(&file_entry.blake3).unwrap_or_default();
1365            if *actual.as_bytes() != expected {
1366                warn!("BLAKE3 mismatch for {}, skipping", file_entry.path);
1367                chunks_failed.fetch_add(1, Ordering::Relaxed);
1368                return;
1369            }
1370
1371            // Write file  --  NFS pool bypass or VFS fallback
1372            let wrote_via_nfs = write_file_with_pool(
1373                &dest, &data, file_entry, target, file_idx,
1374                #[cfg(feature = "nfs-bypass")]
1375                &nfs_pool,
1376            );
1377
1378            if !wrote_via_nfs {
1379                if std::fs::write(&dest, &data).is_err() {
1380                    chunks_failed.fetch_add(1, Ordering::Relaxed);
1381                    return;
1382                }
1383                use std::os::unix::fs::PermissionsExt;
1384                let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(file_entry.mode));
1385                // Restore ownership from manifest (requires root)
1386                if unsafe { libc::geteuid() } == 0 {
1387                    use nix::unistd::{chown, Uid, Gid};
1388                    let _ = chown(&dest, Some(Uid::from_raw(file_entry.uid)), Some(Gid::from_raw(file_entry.gid)));
1389                }
1390                let mtime = filetime::FileTime::from_unix_time(file_entry.mtime, 0);
1391                let _ = filetime::set_file_mtime(&dest, mtime);
1392            }
1393
1394            for (key, hex_val) in &file_entry.xattr {
1395                if let Ok(val) = hex::decode(hex_val) {
1396                    if key.starts_with("user.foxing") {
1397                        let _ = crate::sidecar::set_metadata(&dest, key, &val);
1398                    } else {
1399                        let _ = xattr::set(&dest, key, &val);
1400                    }
1401                }
1402            }
1403
1404            // Generate foxingd-compatible signatures if requested
1405            if generate_sigs {
1406                let sig = crate::sidecar::SyncSignature::compute_from_buffer(
1407                    &data, file_entry.mtime, 0,
1408                );
1409                let _ = crate::sidecar::set_sync_signature(&dest, &sig);
1410            }
1411
1412            files_restored.fetch_add(1, Ordering::Relaxed);
1413            bytes_restored.fetch_add(file_entry.size, Ordering::Relaxed);
1414        });
1415
1416        // Post-restore: compute dir hashes for adaptive pruning
1417        if generate_sigs {
1418            compute_dir_hashes_recursive(target);
1419        }
1420
1421        producer_handle.join().map_err(|_|
1422            io::Error::other("chunk producer thread panicked")
1423        )?;
1424
1425        let stats = FxarImportStats {
1426            files_restored: files_restored.load(Ordering::Relaxed),
1427            bytes_restored: bytes_restored.load(Ordering::Relaxed),
1428            chunks_verified: chunks_verified.load(Ordering::Relaxed),
1429            chunks_failed: chunks_failed.load(Ordering::Relaxed),
1430        };
1431
1432        info!("FXAR v2 import: {} files, {} bytes, {} chunks verified",
1433              stats.files_restored, stats.bytes_restored, stats.chunks_verified);
1434
1435        Ok(stats)
1436    }
1437
1438    /// Restore latest version of each file, stripping `{snapshot}/tree/` prefix.
1439    ///
1440    /// Default restore mode  --  behaves like `tar -xvf`. When an archive contains
1441    /// multiple snapshots with the same file, only the most recent version is
1442    /// extracted. Output paths are flat relative paths (no snapshot prefix).
1443    ///
1444    /// When `verbose` is true, prints per-file version info to stderr.
1445    pub fn restore_latest(
1446        &mut self,
1447        target: &Path,
1448        generate_sigs: bool,
1449        verbose: bool,
1450    ) -> io::Result<FxarImportStats> {
1451        let manifest = self.read_manifest()?;
1452        let index = self.read_chunk_index()?;
1453
1454        // Build version map: rel_path -> [(snapshot_ts, manifest_index)]
1455        let mut version_map: std::collections::HashMap<String, Vec<(String, usize)>> =
1456            std::collections::HashMap::new();
1457        for (i, entry) in manifest.files.iter().enumerate() {
1458            let (snapshot, rel_path) = strip_snapshot_prefix(&entry.path);
1459            version_map
1460                .entry(rel_path.to_string())
1461                .or_default()
1462                .push((snapshot.to_string(), i));
1463        }
1464
1465        // Select latest version per file, build version info
1466        let mut selected: Vec<(String, usize, Vec<String>)> = Vec::new();
1467        for (rel_path, mut versions) in version_map {
1468            versions.sort_by(|a, b| b.0.cmp(&a.0)); // latest first
1469            let all_snapshots: Vec<String> = versions.iter().rev().map(|(ts, _)| ts.clone()).collect();
1470            let (_, latest_idx) = versions[0];
1471            selected.push((rel_path, latest_idx, all_snapshots));
1472        }
1473        selected.sort_by(|a, b| a.0.cmp(&b.0));
1474
1475        // Pre-flight disk space check on selected files only
1476        let total_bytes: u64 = selected.iter()
1477            .map(|(_, idx, _)| manifest.files[*idx].size)
1478            .sum();
1479        if let Some(avail) = check_available_space(target) {
1480            let needed = total_bytes + crate::constants::FXAR_RESTORE_HEADROOM_BYTES;
1481            if avail < needed {
1482                return Err(io::Error::other(
1483                    format!("insufficient disk space: {} available, {} needed", avail, needed),
1484                ));
1485            }
1486        }
1487
1488        // Pre-create directories for stripped paths
1489        for (rel_path, _, _) in &selected {
1490            let dest = target.join(rel_path);
1491            if let Some(parent) = dest.parent() {
1492                std::fs::create_dir_all(parent)?;
1493            }
1494        }
1495
1496        // Pipelined chunk loading  --  load ALL chunks sequentially (same as restore_all).
1497        // Skipping unneeded chunks introduces a concurrency race between the skip
1498        // counter and the producer's store() calls.
1499        let chunk_count = index.len();
1500        let chunk_store: Vec<parking_lot::RwLock<Option<Vec<u8>>>> =
1501            (0..chunk_count).map(|_| parking_lot::RwLock::new(None)).collect();
1502        let chunk_store = std::sync::Arc::new(chunk_store);
1503        let chunks_loaded = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1504
1505        let flags = self.header.flags;
1506        let producer_index = index.clone();
1507        let mut raw_chunks: Vec<Vec<u8>> = Vec::with_capacity(chunk_count);
1508        for entry in &producer_index {
1509            if entry.compressed_size as u64 > MAX_FXAR_ALLOC {
1510                return Err(io::Error::new(
1511                    io::ErrorKind::InvalidData,
1512                    format!("chunk compressed size {} exceeds maximum", entry.compressed_size),
1513                ));
1514            }
1515            self.reader.seek(SeekFrom::Start(entry.offset))?;
1516            let mut compressed = vec![0u8; entry.compressed_size as usize];
1517            self.reader.read_exact(&mut compressed)?;
1518            raw_chunks.push(compressed);
1519        }
1520
1521        let producer_store = chunk_store.clone();
1522        let producer_loaded = chunks_loaded.clone();
1523        let producer_handle = std::thread::spawn(move || {
1524            for (i, compressed) in raw_chunks.into_iter().enumerate() {
1525                let data = match decompress_chunk(&compressed, flags) {
1526                    Ok(d) => d,
1527                    Err(e) => {
1528                        warn!("chunk {} decompress failed: {}", i, e);
1529                        Vec::new()
1530                    }
1531                };
1532                let actual = blake3::hash(&data);
1533                if actual.as_bytes() != &producer_index[i].blake3_hash {
1534                    warn!("chunk {} BLAKE3 mismatch", i);
1535                }
1536                *producer_store[i].write() = Some(data);
1537                producer_loaded.store(i + 1, std::sync::atomic::Ordering::Release);
1538            }
1539        });
1540
1541        #[cfg(feature = "nfs-bypass")]
1542        let nfs_pool: Option<crate::nfs::NfsClientPool> = init_nfs_pool(target);
1543
1544        use rayon::prelude::*;
1545        use std::sync::atomic::{AtomicU64, Ordering};
1546
1547        let files_restored = AtomicU64::new(0);
1548        let bytes_restored = AtomicU64::new(0);
1549        let chunks_verified = AtomicU64::new(0);
1550        let chunks_failed = AtomicU64::new(0);
1551
1552        // Print version info header if verbose
1553        if verbose && !selected.is_empty() {
1554            let snap_count = manifest.snapshots.len();
1555            if snap_count > 1 {
1556                eprintln!("Archive contains {} snapshots, extracting latest version of each file", snap_count);
1557            }
1558        }
1559
1560        selected.par_iter().enumerate().for_each(|(file_idx, (rel_path, manifest_idx, all_snapshots))| {
1561            let file_entry = &manifest.files[*manifest_idx];
1562            let dest = target.join(rel_path);
1563
1564            // Wait for required chunks
1565            let max_chunk = file_entry.chunks.iter().copied().max().unwrap_or(0);
1566            let needed = max_chunk as usize + 1;
1567            while chunks_loaded.load(Ordering::Acquire) < needed {
1568                std::hint::spin_loop();
1569            }
1570
1571            // Assemble file from chunks
1572            let mut data = Vec::with_capacity(file_entry.size as usize);
1573            for &chunk_idx in &file_entry.chunks {
1574                let guard = chunk_store[chunk_idx as usize].read();
1575                if let Some(chunk_data) = guard.as_ref() {
1576                    data.extend_from_slice(chunk_data);
1577                    chunks_verified.fetch_add(1, Ordering::Relaxed);
1578                } else {
1579                    chunks_failed.fetch_add(1, Ordering::Relaxed);
1580                    if verbose {
1581                        eprintln!("{}  FAIL (missing chunk)", rel_path);
1582                    }
1583                    return;
1584                }
1585            }
1586
1587            // Verify whole-file BLAKE3
1588            let actual = blake3::hash(&data);
1589            let expected = decode_blake3_field(&file_entry.blake3).unwrap_or_default();
1590            if *actual.as_bytes() != expected {
1591                warn!("BLAKE3 mismatch for {}, skipping", rel_path);
1592                chunks_failed.fetch_add(1, Ordering::Relaxed);
1593                if verbose {
1594                    eprintln!("{}  FAIL (BLAKE3 mismatch)", rel_path);
1595                }
1596                return;
1597            }
1598
1599            // Write file
1600            let wrote_via_nfs = write_file_with_pool(
1601                &dest, &data, file_entry, target, file_idx,
1602                #[cfg(feature = "nfs-bypass")]
1603                &nfs_pool,
1604            );
1605
1606            if !wrote_via_nfs {
1607                if std::fs::write(&dest, &data).is_err() {
1608                    chunks_failed.fetch_add(1, Ordering::Relaxed);
1609                    return;
1610                }
1611                use std::os::unix::fs::PermissionsExt;
1612                let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(file_entry.mode));
1613                if unsafe { libc::geteuid() } == 0 {
1614                    use nix::unistd::{chown, Uid, Gid};
1615                    let _ = chown(&dest, Some(Uid::from_raw(file_entry.uid)), Some(Gid::from_raw(file_entry.gid)));
1616                }
1617                let mtime = filetime::FileTime::from_unix_time(file_entry.mtime, 0);
1618                let _ = filetime::set_file_mtime(&dest, mtime);
1619            }
1620
1621            for (key, hex_val) in &file_entry.xattr {
1622                if let Ok(val) = hex::decode(hex_val) {
1623                    if key.starts_with("user.foxing") {
1624                        let _ = crate::sidecar::set_metadata(&dest, key, &val);
1625                    } else {
1626                        let _ = xattr::set(&dest, key, &val);
1627                    }
1628                }
1629            }
1630
1631            if generate_sigs {
1632                let sig = crate::sidecar::SyncSignature::compute_from_buffer(
1633                    &data, file_entry.mtime, 0,
1634                );
1635                let _ = crate::sidecar::set_sync_signature(&dest, &sig);
1636            }
1637
1638            // Verbose: show version info
1639            if verbose {
1640                let restored_snap = strip_snapshot_prefix(&file_entry.path).0;
1641                let version_display = if all_snapshots.len() > 1 {
1642                    let formatted: Vec<String> = all_snapshots.iter().map(|ts| {
1643                        if ts == restored_snap { format!("{}*", ts) } else { ts.clone() }
1644                    }).collect();
1645                    format!("  [{}]", formatted.join(", "))
1646                } else {
1647                    String::new()
1648                };
1649                eprintln!("{}{}  {} bytes", rel_path, version_display, file_entry.size);
1650            }
1651
1652            files_restored.fetch_add(1, Ordering::Relaxed);
1653            bytes_restored.fetch_add(file_entry.size, Ordering::Relaxed);
1654        });
1655
1656        if generate_sigs {
1657            compute_dir_hashes_recursive(target);
1658        }
1659
1660        producer_handle.join().map_err(|_|
1661            io::Error::other("chunk producer thread panicked")
1662        )?;
1663
1664        let stats = FxarImportStats {
1665            files_restored: files_restored.load(Ordering::Relaxed),
1666            bytes_restored: bytes_restored.load(Ordering::Relaxed),
1667            chunks_verified: chunks_verified.load(Ordering::Relaxed),
1668            chunks_failed: chunks_failed.load(Ordering::Relaxed),
1669        };
1670
1671        info!("FXAR v2 restore (latest): {} files, {} bytes, {} chunks verified",
1672              stats.files_restored, stats.bytes_restored, stats.chunks_verified);
1673
1674        Ok(stats)
1675    }
1676
1677    /// List all files in the archive without extracting.
1678    pub fn list_files(&mut self) -> io::Result<Vec<FxarManifestEntry>> {
1679        let manifest = self.read_manifest()?;
1680        Ok(manifest.files)
1681    }
1682
1683    pub fn read_extension_directory(&mut self) -> io::Result<Option<FxarExtensionDirectory>> {
1684        if self.header.flags & FLAG_HAS_EXTENSION_DIR == 0 {
1685            return Ok(None);
1686        }
1687        let file_size = self.reader.seek(SeekFrom::End(0))?;
1688        if file_size < (HEADER_SIZE + FOOTER_SIZE + 8) as u64 {
1689            return Err(io::Error::new(io::ErrorKind::InvalidData, "archive too small for extension dir"));
1690        }
1691        // Sentinel is at file_size - FOOTER_SIZE(32) - 8
1692        self.reader.seek(SeekFrom::Start(file_size - FOOTER_SIZE as u64 - 8))?;
1693        let mut sentinel_buf = [0u8; 8];
1694        self.reader.read_exact(&mut sentinel_buf)?;
1695        let ext_dir_offset = u64::from_le_bytes(sentinel_buf);
1696
1697        if ext_dir_offset < HEADER_SIZE as u64 || ext_dir_offset >= file_size - FOOTER_SIZE as u64 - 8 {
1698            return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid extension dir offset"));
1699        }
1700        self.reader.seek(SeekFrom::Start(ext_dir_offset))?;
1701        Ok(Some(FxarExtensionDirectory::read_from(&mut self.reader)?))
1702    }
1703
1704    pub fn read_extension_section(&mut self, section_type: u32) -> io::Result<Option<Vec<u8>>> {
1705        let dir = match self.read_extension_directory()? {
1706            None => return Ok(None),
1707            Some(d) => d,
1708        };
1709        let entry = match dir.entries.iter().find(|e| e.section_type == section_type) {
1710            None => return Ok(None),
1711            Some(e) => e.clone(),
1712        };
1713        if entry.size > MAX_FXAR_ALLOC {
1714            return Err(io::Error::new(io::ErrorKind::InvalidData, "extension section too large"));
1715        }
1716        self.reader.seek(SeekFrom::Start(entry.offset))?;
1717        let mut data = vec![0u8; entry.size as usize];
1718        self.reader.read_exact(&mut data)?;
1719        let actual = *blake3::hash(&data).as_bytes();
1720        if actual != entry.checksum {
1721            return Err(io::Error::new(io::ErrorKind::InvalidData, "extension section BLAKE3 mismatch"));
1722        }
1723        Ok(Some(data))
1724    }
1725}
1726
1727// -----------------------------------------------------------------------
1728// Stream reader (for piped imports)
1729// -----------------------------------------------------------------------
1730
1731/// Read an FXAR v2 archive from a non-seekable stream.
1732pub fn read_archive_stream<R: Read>(
1733    mut reader: R,
1734    target: &Path,
1735    generate_sigs: bool,
1736) -> io::Result<FxarImportStats> {
1737    let header = FxarHeader::read_from(&mut reader)?;
1738    if header.version != FXAR_VERSION {
1739        return Err(io::Error::new(
1740            io::ErrorKind::InvalidData,
1741            format!("unsupported FXAR version: {}", header.version),
1742        ));
1743    }
1744
1745    // Streaming mode: read manifest, then index, then chunks sequentially
1746    // Read manifest
1747    let mut size_buf = [0u8; 8];
1748    reader.read_exact(&mut size_buf)?;
1749    let compressed_len = u64::from_le_bytes(size_buf);
1750    if compressed_len > MAX_FXAR_ALLOC {
1751        return Err(io::Error::new(
1752            io::ErrorKind::InvalidData,
1753            format!("FXAR manifest compressed size {} exceeds maximum {}", compressed_len, MAX_FXAR_ALLOC),
1754        ));
1755    }
1756    let compressed_len = compressed_len as usize;
1757    reader.read_exact(&mut size_buf)?;
1758    let _uncompressed_len = u64::from_le_bytes(size_buf) as usize;
1759
1760    let mut manifest_compressed = vec![0u8; compressed_len];
1761    reader.read_exact(&mut manifest_compressed)?;
1762    let manifest_json = decompress_chunk(&manifest_compressed, header.flags)?;
1763    let manifest: FxarManifest = serde_json::from_slice(&manifest_json)
1764        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("manifest: {}", e)))?;
1765
1766    // Read chunk index
1767    let chunk_count = header.chunk_count as usize;
1768    let mut index = Vec::with_capacity(chunk_count);
1769    for _ in 0..chunk_count {
1770        index.push(ChunkIndexEntry::read_from(&mut reader)?);
1771    }
1772
1773    // Read all chunk data into memory (streaming mode  --  can't seek)
1774    let mut chunk_store: Vec<Vec<u8>> = Vec::with_capacity(chunk_count);
1775    for entry in &index {
1776        if entry.compressed_size as u64 > MAX_FXAR_ALLOC {
1777            return Err(io::Error::new(
1778                io::ErrorKind::InvalidData,
1779                format!("FXAR chunk compressed size {} exceeds maximum {}", entry.compressed_size, MAX_FXAR_ALLOC),
1780            ));
1781        }
1782        let mut compressed = vec![0u8; entry.compressed_size as usize];
1783        reader.read_exact(&mut compressed)?;
1784        let data = decompress_chunk(&compressed, header.flags)?;
1785
1786        // Verify BLAKE3
1787        let actual = blake3::hash(&data);
1788        if actual.as_bytes() != &entry.blake3_hash {
1789            return Err(io::Error::new(
1790                io::ErrorKind::InvalidData,
1791                format!("chunk BLAKE3 mismatch at index {}", chunk_store.len()),
1792            ));
1793        }
1794        chunk_store.push(data);
1795    }
1796
1797    // Pre-flight disk space check
1798    let total_bytes: u64 = manifest.files.iter().map(|f| f.size).sum();
1799    if let Some(avail) = check_available_space(target) {
1800        let needed = total_bytes + crate::constants::FXAR_RESTORE_HEADROOM_BYTES;
1801        if avail < needed {
1802            return Err(io::Error::other(
1803                format!("insufficient disk space: {} bytes available, {} bytes needed",
1804                        avail, needed),
1805            ));
1806        }
1807    }
1808
1809    // Pre-create all parent directories (must be sequential)
1810    for file_entry in &manifest.files {
1811        let dest = target.join(&file_entry.path);
1812        if let Some(parent) = dest.parent() {
1813            std::fs::create_dir_all(parent)?;
1814        }
1815    }
1816
1817    // NFS bypass pool
1818    #[cfg(feature = "nfs-bypass")]
1819    let nfs_pool: Option<crate::nfs::NfsClientPool> = init_nfs_pool(target);
1820
1821    // Reconstruct files in parallel (chunk assembly + BLAKE3 verify + write)
1822    use rayon::prelude::*;
1823    use std::sync::atomic::{AtomicU64, Ordering};
1824
1825    let files_restored = AtomicU64::new(0);
1826    let bytes_restored = AtomicU64::new(0);
1827    let chunks_verified = AtomicU64::new(0);
1828    let chunks_failed = AtomicU64::new(0);
1829
1830    manifest.files.par_iter().enumerate().for_each(|(file_idx, file_entry)| {
1831        let dest = target.join(&file_entry.path);
1832
1833        let mut data = Vec::with_capacity(file_entry.size as usize);
1834        for &chunk_idx in &file_entry.chunks {
1835            data.extend_from_slice(&chunk_store[chunk_idx as usize]);
1836            chunks_verified.fetch_add(1, Ordering::Relaxed);
1837        }
1838
1839        // Verify whole-file hash (accepts both hex and CID manifest fields)
1840        let actual = blake3::hash(&data);
1841        let expected = decode_blake3_field(&file_entry.blake3).unwrap_or_default();
1842        if *actual.as_bytes() != expected {
1843            warn!("BLAKE3 mismatch for {}, skipping", file_entry.path);
1844            chunks_failed.fetch_add(1, Ordering::Relaxed);
1845            return;
1846        }
1847
1848        // Write file  --  NFS pool bypass or VFS fallback
1849        let wrote_via_nfs = write_file_with_pool(
1850            &dest, &data, file_entry, target, file_idx,
1851            #[cfg(feature = "nfs-bypass")]
1852            &nfs_pool,
1853        );
1854
1855        if !wrote_via_nfs {
1856            if std::fs::write(&dest, &data).is_err() {
1857                chunks_failed.fetch_add(1, Ordering::Relaxed);
1858                return;
1859            }
1860            use std::os::unix::fs::PermissionsExt;
1861            let _ = std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(file_entry.mode));
1862            let mtime = filetime::FileTime::from_unix_time(file_entry.mtime, 0);
1863            let _ = filetime::set_file_mtime(&dest, mtime);
1864        }
1865
1866        for (key, hex_val) in &file_entry.xattr {
1867            if let Ok(val) = hex::decode(hex_val) {
1868                if key.starts_with("user.foxing") {
1869                    let _ = crate::sidecar::set_metadata(&dest, key, &val);
1870                } else {
1871                    let _ = xattr::set(&dest, key, &val);
1872                }
1873            }
1874        }
1875
1876        // Generate foxingd-compatible signatures if requested
1877        if generate_sigs {
1878            let sig = crate::sidecar::SyncSignature::compute_from_buffer(
1879                &data, file_entry.mtime, 0,
1880            );
1881            let _ = crate::sidecar::set_sync_signature(&dest, &sig);
1882        }
1883
1884        files_restored.fetch_add(1, Ordering::Relaxed);
1885        bytes_restored.fetch_add(file_entry.size, Ordering::Relaxed);
1886    });
1887
1888    let stats = FxarImportStats {
1889        files_restored: files_restored.load(Ordering::Relaxed),
1890        bytes_restored: bytes_restored.load(Ordering::Relaxed),
1891        chunks_verified: chunks_verified.load(Ordering::Relaxed),
1892        chunks_failed: chunks_failed.load(Ordering::Relaxed),
1893    };
1894
1895    // Post-restore: compute dir hashes for adaptive pruning
1896    if generate_sigs {
1897        compute_dir_hashes_recursive(target);
1898    }
1899
1900    info!("FXAR v2 stream import: {} files, {} bytes", stats.files_restored, stats.bytes_restored);
1901    Ok(stats)
1902}
1903
1904// -----------------------------------------------------------------------
1905// Format auto-detection
1906// -----------------------------------------------------------------------
1907
1908/// Detect archive format by reading magic bytes.
1909/// Returns "fxar2" for FXAR v2, "tar" for tar archives, "unknown" otherwise.
1910pub fn detect_format<R: Read>(reader: &mut R) -> io::Result<(&'static str, [u8; 4])> {
1911    let mut magic = [0u8; 4];
1912    reader.read_exact(&mut magic)?;
1913
1914    if &magic == FXAR_MAGIC {
1915        return Ok(("fxar2", magic));
1916    }
1917
1918    // tar magic is at offset 257 ("ustar"), but first bytes are often
1919    // a filename or null. Check for common compressed stream headers.
1920    // zstd: 0x28 0xB5 0x2F 0xFD
1921    if magic == [0x28, 0xB5, 0x2F, 0xFD] {
1922        return Ok(("zstd-stream", magic));
1923    }
1924    // gzip: 0x1F 0x8B
1925    if magic[0] == 0x1F && magic[1] == 0x8B {
1926        return Ok(("gzip-stream", magic));
1927    }
1928    // xz: 0xFD 0x37 0x7A 0x58
1929    if magic == [0xFD, 0x37, 0x7A, 0x58] {
1930        return Ok(("xz-stream", magic));
1931    }
1932    // lz4: 0x04 0x22 0x4D 0x18
1933    if magic == [0x04, 0x22, 0x4D, 0x18] {
1934        return Ok(("lz4-stream", magic));
1935    }
1936
1937    // Assume tar (raw or old-style without magic at offset 257)
1938    Ok(("tar", magic))
1939}
1940
1941/// Inspect an FXAR v2 archive and return summary info.
1942pub fn inspect_archive<R: Read + Seek>(mut reader: R) -> io::Result<FxarInspectResult> {
1943    let mut fxar = FxarReader::open(&mut reader)?;
1944    let manifest = fxar.read_manifest()?;
1945    let index = fxar.read_chunk_index()?;
1946
1947    let total_files = manifest.files.len() as u64;
1948    let total_apparent: u64 = manifest.files.iter().map(|f| f.size).sum();
1949    let chunk_count = index.len() as u64;
1950    let total_chunk_bytes: u64 = index.iter().map(|e| e.size as u64).sum();
1951    let total_compressed: u64 = index.iter().map(|e| e.compressed_size as u64).sum();
1952
1953    // Count unique file hashes for file-level dedup stats
1954    let unique_files: HashSet<&str> = manifest.files.iter().map(|f| f.blake3.as_str()).collect();
1955
1956    let extension_sections = match fxar.read_extension_directory() {
1957        Ok(Some(dir)) => dir.entries.iter().map(|e| (e.section_type, e.size)).collect(),
1958        _ => Vec::new(),
1959    };
1960
1961    Ok(FxarInspectResult {
1962        version: fxar.header.version,
1963        snapshots: manifest.snapshots,
1964        total_files,
1965        unique_files: unique_files.len() as u64,
1966        total_apparent_bytes: total_apparent,
1967        chunk_count,
1968        total_chunk_bytes,
1969        total_compressed_bytes: total_compressed,
1970        dedup_ratio: if total_apparent > 0 {
1971            1.0 - (total_compressed as f64 / total_apparent as f64)
1972        } else { 0.0 },
1973        files: manifest.files,
1974        extension_sections,
1975    })
1976}
1977
1978#[derive(Debug, Serialize, Deserialize)]
1979/// Result of inspecting an FXAR archive without extracting.
1980pub struct FxarInspectResult {
1981    pub version: u32,
1982    pub snapshots: Vec<String>,
1983    pub total_files: u64,
1984    pub unique_files: u64,
1985    pub total_apparent_bytes: u64,
1986    pub chunk_count: u64,
1987    pub total_chunk_bytes: u64,
1988    pub total_compressed_bytes: u64,
1989    pub dedup_ratio: f64,
1990    pub files: Vec<FxarManifestEntry>,
1991    #[serde(default)]
1992    pub extension_sections: Vec<(u32, u64)>,
1993}
1994
1995// -----------------------------------------------------------------------
1996// Compression helpers
1997// -----------------------------------------------------------------------
1998
1999/// Check available disk space using statvfs.
2000fn check_available_space(path: &Path) -> Option<u64> {
2001    use std::ffi::CString;
2002    let c_path = CString::new(path.to_string_lossy().as_bytes()).ok()?;
2003    // SAFETY: statvfs is a C struct where all-zeros is a valid initial state.
2004    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
2005    // SAFETY: c_path is a valid null-terminated C string, stat is a valid pointer.
2006    let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
2007    if rc == 0 {
2008        Some(stat.f_bavail * stat.f_bsize)
2009    } else {
2010        None
2011    }
2012}
2013
2014/// Walk restored directory tree and compute BLAKE3 dir hashes for adaptive pruning.
2015/// Stores dir hashes via sidecar (xattr with .foxing_meta fallback).
2016fn compute_dir_hashes_recursive(root: &Path) {
2017    for entry in walkdir::WalkDir::new(root)
2018        .contents_first(true) // bottom-up: children before parents
2019        .follow_links(false)
2020    {
2021        let entry = match entry { Ok(e) => e, Err(_) => continue };
2022        if !entry.file_type().is_dir() { continue; }
2023        let dir_path = entry.path();
2024        // Skip .foxing_versions and .foxing_meta directories
2025        let name = dir_path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default();
2026        if name.starts_with(".foxing") { continue; }
2027
2028        if let Some(hash) = crate::hashing::compute_dir_hash_from_path(dir_path) {
2029            let _ = crate::sidecar::set_metadata(dir_path, "dir_hash", &hash);
2030        }
2031    }
2032}
2033
2034/// Initialize NFS client pool if target is NFSv4.2 and bypass is enabled.
2035#[cfg(feature = "nfs-bypass")]
2036fn init_nfs_pool(target: &Path) -> Option<crate::nfs::NfsClientPool> {
2037    let nfs_env = std::env::var("FOXING_NFS_BYPASS").unwrap_or_else(|_| "1".to_string());
2038    if nfs_env == "0" { return None; }
2039    crate::nfs::mount::probe_nfs_bypass(target).and_then(|info| {
2040        match crate::nfs::NfsClientPool::new(&info, 4) {
2041            Ok(pool) => {
2042                info!("FXAR import: NFS bypass pool ({} sessions) -> {}", pool.len(), info.server_addr);
2043                Some(pool)
2044            }
2045            Err(e) => {
2046                info!("FXAR import: NFS bypass unavailable ({}), using VFS", e);
2047                None
2048            }
2049        }
2050    })
2051}
2052
2053/// Write a file via NFS pool bypass (round-robin session selection).
2054/// Returns true if NFS bypass succeeded, false for VFS fallback.
2055#[allow(unused_variables)]
2056fn write_file_with_pool(
2057    dest: &Path,
2058    data: &[u8],
2059    file_entry: &FxarManifestEntry,
2060    target: &Path,
2061    thread_hint: usize,
2062    #[cfg(feature = "nfs-bypass")]
2063    nfs_pool: &Option<crate::nfs::NfsClientPool>,
2064) -> bool {
2065    #[cfg(feature = "nfs-bypass")]
2066    {
2067        if let Some(pool) = nfs_pool
2068            && data.len() <= crate::nfs::NFS_BYPASS_WRITE_MAX as usize {
2069                let parent = dest.parent().unwrap_or(target);
2070                let client_lock = pool.get(thread_hint);
2071                let mut client = client_lock.lock();
2072                if let Ok(handle) = client.get_or_resolve_handle(parent) {
2073                    let fname = dest.file_name()
2074                        .map(|f| f.to_string_lossy().into_owned())
2075                        .unwrap_or_default();
2076                    match client.write_file(
2077                        &handle, &fname, data,
2078                        file_entry.mode, file_entry.uid, file_entry.gid,
2079                        (file_entry.mtime, 0),
2080                    ) {
2081                        Ok(()) => return true,
2082                        Err(e) => {
2083                            tracing::debug!("NFS bypass failed for {}: {}, attempting recovery", fname, e);
2084                            if client.recover_session().is_ok()
2085                                && let Ok(h) = client.get_or_resolve_handle(parent)
2086                                    && client.write_file(
2087                                        &h, &fname, data,
2088                                        file_entry.mode, file_entry.uid, file_entry.gid,
2089                                        (file_entry.mtime, 0),
2090                                    ).is_ok() {
2091                                        return true;
2092                                    }
2093                            tracing::debug!("NFS bypass recovery failed for {}", fname);
2094                        }
2095                    }
2096                }
2097            }
2098    }
2099    false
2100}
2101
2102fn compress_flag(compress: &str) -> u32 {
2103    match compress {
2104        "none" => FLAG_COMPRESS_NONE,
2105        s if s.starts_with("zstd") => FLAG_COMPRESS_ZSTD,
2106        "lz4" => FLAG_COMPRESS_LZ4,
2107        "gzip" => FLAG_COMPRESS_GZIP,
2108        s if s.starts_with("xz") => FLAG_COMPRESS_XZ,
2109        _ => FLAG_COMPRESS_ZSTD,
2110    }
2111}
2112
2113pub(crate) fn compress_chunk(data: &[u8], compress: &str) -> Vec<u8> {
2114    match compress {
2115        "none" => data.to_vec(),
2116        s if s.starts_with("zstd") => {
2117            let level = s.strip_prefix("zstd:").and_then(|l| l.parse().ok()).unwrap_or(3);
2118            zstd::bulk::compress(data, level).unwrap_or_else(|_| data.to_vec())
2119        }
2120        "lz4" => lz4_flex::compress_prepend_size(data),
2121        "gzip" => {
2122            use flate2::write::GzEncoder;
2123            let mut enc = GzEncoder::new(Vec::new(), flate2::Compression::default());
2124            if let Err(e) = enc.write_all(data) {
2125                warn!("gzip compression write failed: {e}, falling back to uncompressed");
2126                return data.to_vec();
2127            }
2128            enc.finish().unwrap_or_else(|_| data.to_vec())
2129        }
2130        _ => zstd::bulk::compress(data, 3).unwrap_or_else(|_| data.to_vec()),
2131    }
2132}
2133
2134pub(crate) fn decompress_chunk(data: &[u8], flags: u32) -> io::Result<Vec<u8>> {
2135    let compression = flags & 0x1F;
2136    match compression {
2137        FLAG_COMPRESS_NONE => Ok(data.to_vec()),
2138        FLAG_COMPRESS_ZSTD => {
2139            zstd::bulk::decompress(data, 16 * 1024 * 1024) // 16MB max per chunk
2140                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("zstd: {}", e)))
2141        }
2142        FLAG_COMPRESS_LZ4 => {
2143            lz4_flex::decompress_size_prepended(data)
2144                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("lz4: {}", e)))
2145        }
2146        FLAG_COMPRESS_GZIP => {
2147            use flate2::read::GzDecoder;
2148            let mut dec = GzDecoder::new(data);
2149            let mut out = Vec::new();
2150            dec.read_to_end(&mut out)?;
2151            Ok(out)
2152        }
2153        FLAG_COMPRESS_XZ => {
2154            let mut dec = liblzma::read::XzDecoder::new(data);
2155            let mut out = Vec::new();
2156            dec.read_to_end(&mut out)?;
2157            Ok(out)
2158        }
2159        _ => Err(io::Error::new(io::ErrorKind::InvalidData, format!("unknown compression: {}", flags))),
2160    }
2161}
2162
2163// -----------------------------------------------------------------------
2164// Tests
2165// -----------------------------------------------------------------------
2166
2167#[cfg(test)]
2168mod tests {
2169    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2170    use super::*;
2171
2172    #[test]
2173    fn test_header_roundtrip() {
2174        let chunker = GearChunker::default();
2175        let mut header = FxarHeader::new(FLAG_COMPRESS_ZSTD, &chunker);
2176        header.index_offset = 12345;
2177        header.manifest_offset = 64;
2178        header.chunk_count = 42;
2179
2180        let mut buf = Vec::new();
2181        header.write_to(&mut buf).unwrap();
2182        assert_eq!(buf.len(), HEADER_SIZE);
2183
2184        let parsed = FxarHeader::read_from(&mut &buf[..]).unwrap();
2185        assert_eq!(parsed.magic, *FXAR_MAGIC);
2186        assert_eq!(parsed.version, FXAR_VERSION);
2187        assert_eq!(parsed.flags, FLAG_COMPRESS_ZSTD);
2188        assert_eq!(parsed.index_offset, 12345);
2189        assert_eq!(parsed.manifest_offset, 64);
2190        assert_eq!(parsed.chunk_count, 42);
2191        assert_eq!(parsed.chunk_min, chunker.min as u32);
2192        assert_eq!(parsed.chunk_avg, chunker.avg as u32);
2193        assert_eq!(parsed.chunk_max, chunker.max as u32);
2194    }
2195
2196    #[test]
2197    fn test_header_bad_magic() {
2198        let buf = [0u8; HEADER_SIZE];
2199        let result = FxarHeader::read_from(&mut &buf[..]);
2200        assert!(result.is_err());
2201    }
2202
2203    #[test]
2204    fn test_chunk_index_entry_roundtrip() {
2205        let entry = ChunkIndexEntry {
2206            blake3_hash: [0xAB; 32],
2207            size: 65536,
2208            offset: 999999,
2209            compressed_size: 32768,
2210        };
2211
2212        let mut buf = Vec::new();
2213        entry.write_to(&mut buf).unwrap();
2214        assert_eq!(buf.len(), CHUNK_INDEX_ENTRY_SIZE);
2215
2216        let parsed = ChunkIndexEntry::read_from(&mut &buf[..]).unwrap();
2217        assert_eq!(parsed.blake3_hash, [0xAB; 32]);
2218        assert_eq!(parsed.size, 65536);
2219        assert_eq!(parsed.offset, 999999);
2220        assert_eq!(parsed.compressed_size, 32768);
2221    }
2222
2223    #[test]
2224    fn test_footer_roundtrip() {
2225        let checksum = FxarFooter::compute_checksum(64, 12345, 42);
2226        let footer = FxarFooter {
2227            magic: *FXAR_FOOTER_MAGIC,
2228            manifest_offset: 64,
2229            index_offset: 12345,
2230            chunk_count: 42,
2231            checksum,
2232        };
2233
2234        let mut buf = Vec::new();
2235        footer.write_to(&mut buf).unwrap();
2236        assert_eq!(buf.len(), FOOTER_SIZE);
2237
2238        let parsed = FxarFooter::read_from(&mut &buf[..]).unwrap();
2239        assert_eq!(parsed.manifest_offset, 64);
2240        assert_eq!(parsed.index_offset, 12345);
2241        assert_eq!(parsed.chunk_count, 42);
2242        assert_eq!(parsed.checksum, checksum);
2243    }
2244
2245    #[test]
2246    fn test_manifest_json_roundtrip() {
2247        let manifest = FxarManifest {
2248            version: 2,
2249            created: "2026-03-13T00:00:00Z".into(),
2250            files: vec![FxarManifestEntry {
2251                path: "snap1/tree/data/test.db".into(),
2252                size: 104857600,
2253                mode: 0o100644,
2254                mtime: 1773484800,
2255                uid: 1000,
2256                gid: 1000,
2257                blake3: crate::cid::blake3_hex_to_cid_string(&"a1b2c3d4".repeat(8)).unwrap(),
2258                chunks: vec![0, 1, 2, 3],
2259                xattr: std::collections::HashMap::new(),
2260            }],
2261            snapshots: vec!["2026-03-12T084500".into()],
2262        };
2263
2264        let json = serde_json::to_vec(&manifest).unwrap();
2265        let parsed: FxarManifest = serde_json::from_slice(&json).unwrap();
2266        assert_eq!(parsed.version, 2);
2267        assert_eq!(parsed.files.len(), 1);
2268        assert_eq!(parsed.files[0].chunks, vec![0, 1, 2, 3]);
2269    }
2270
2271    #[test]
2272    fn test_compress_decompress_roundtrip() {
2273        let data = b"hello world, this is test data for compression";
2274
2275        for compress in &["none", "zstd", "zstd:1", "lz4", "gzip"] {
2276            let compressed = compress_chunk(data, compress);
2277            let flags = compress_flag(compress);
2278            let decompressed = decompress_chunk(&compressed, flags).unwrap();
2279            assert_eq!(decompressed, data, "roundtrip failed for {}", compress);
2280        }
2281    }
2282
2283    #[test]
2284    fn test_export_stats_dedup_ratio() {
2285        let mut stats = FxarExportStats::default();
2286        stats.total_apparent_bytes = 1000;
2287        stats.archive_bytes = 100;
2288        assert!((stats.dedup_ratio() - 0.9).abs() < 0.001);
2289
2290        stats.total_apparent_bytes = 0;
2291        assert_eq!(stats.dedup_ratio(), 0.0);
2292    }
2293
2294    #[test]
2295    fn test_export_import_roundtrip() {
2296        use tempfile::TempDir;
2297
2298        // Create a mock version store with a snapshot
2299        let source_dir = TempDir::new().unwrap();
2300        let target_dir = TempDir::new().unwrap();
2301
2302        // Create "target" with version store structure
2303        let vs_root = target_dir.path().join(".foxing_versions");
2304        let snap_dir = vs_root.join("2026-03-12T084500");
2305        let tree_dir = snap_dir.join("tree");
2306        std::fs::create_dir_all(&tree_dir).unwrap();
2307
2308        // Create test files in the snapshot tree
2309        let test_data = b"hello world, this is test data for FXAR v2 export/import roundtrip";
2310        std::fs::write(tree_dir.join("file1.txt"), test_data).unwrap();
2311        std::fs::write(tree_dir.join("file2.txt"), test_data).unwrap(); // duplicate for dedup
2312        std::fs::write(tree_dir.join("file3.dat"), vec![0xABu8; 10_000]).unwrap();
2313
2314        // Write summary.json so scan_snapshots finds it
2315        let summary = serde_json::json!({
2316            "timestamp": "2026-03-12T08:45:00Z",
2317            "status": "success",
2318            "type": "full",
2319            "source": "/test",
2320            "trigger": "test",
2321            "files": 3,
2322            "size_bytes": 10066,
2323            "disk_usage_bytes": 10066,
2324            "savings_pct": 0.0,
2325            "elapsed_ms": 1
2326        });
2327        std::fs::write(snap_dir.join("summary.json"),
2328            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2329
2330        // Export to FXAR v2
2331        let store = crate::version_store::VersionStore::open(target_dir.path());
2332        let archive_path = source_dir.path().join("test.fxar");
2333        let file = std::fs::File::create(&archive_path).unwrap();
2334        let stats = write_archive_seekable(&store, file, "zstd", None).unwrap();
2335
2336        assert_eq!(stats.snapshots_exported, 1);
2337        assert_eq!(stats.total_files, 3);
2338        assert!(stats.unique_chunks > 0);
2339        // file1.txt and file2.txt are identical, so dedup_chunks > 0
2340        assert!(stats.dedup_chunks > 0, "expected dedup, got {} dedup chunks", stats.dedup_chunks);
2341
2342        // Verify archive magic
2343        let archive_data = std::fs::read(&archive_path).unwrap();
2344        assert_eq!(&archive_data[..4], b"FXAR");
2345
2346        // Inspect
2347        let f = std::fs::File::open(&archive_path).unwrap();
2348        let inspect = inspect_archive(f).unwrap();
2349        assert_eq!(inspect.version, 2);
2350        assert_eq!(inspect.total_files, 3);
2351        assert!(inspect.chunk_count > 0);
2352
2353        // Import (restore) to a new directory
2354        let restore_dir = TempDir::new().unwrap();
2355        let f = std::fs::File::open(&archive_path).unwrap();
2356        let mut reader = FxarReader::open(f).unwrap();
2357        let import_stats = reader.restore_all(restore_dir.path(), false).unwrap();
2358
2359        assert_eq!(import_stats.files_restored, 3);
2360        assert_eq!(import_stats.chunks_failed, 0);
2361
2362        // Verify restored files match originals
2363        let restored1 = std::fs::read(
2364            restore_dir.path().join("2026-03-12T084500/tree/file1.txt")
2365        ).unwrap();
2366        assert_eq!(restored1, test_data);
2367
2368        let restored3 = std::fs::read(
2369            restore_dir.path().join("2026-03-12T084500/tree/file3.dat")
2370        ).unwrap();
2371        assert_eq!(restored3, vec![0xABu8; 10_000]);
2372    }
2373
2374    #[test]
2375    fn test_selective_restore() {
2376        use tempfile::TempDir;
2377
2378        let target_dir = TempDir::new().unwrap();
2379        let vs_root = target_dir.path().join(".foxing_versions");
2380        let snap_dir = vs_root.join("2026-03-12T084500");
2381        let tree_dir = snap_dir.join("tree");
2382        std::fs::create_dir_all(tree_dir.join("subdir")).unwrap();
2383
2384        std::fs::write(tree_dir.join("keep.txt"), b"keep this").unwrap();
2385        std::fs::write(tree_dir.join("subdir/nested.txt"), b"nested data").unwrap();
2386        let summary = serde_json::json!({
2387            "timestamp": "2026-03-12T08:45:00Z", "status": "success", "type": "full",
2388            "source": "/test", "trigger": "test", "files": 2, "size_bytes": 20,
2389            "disk_usage_bytes": 20, "savings_pct": 0.0, "elapsed_ms": 1
2390        });
2391        std::fs::write(snap_dir.join("summary.json"),
2392            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2393
2394        // Export
2395        let store = crate::version_store::VersionStore::open(target_dir.path());
2396        let archive_path = target_dir.path().join("test.fxar");
2397        let file = std::fs::File::create(&archive_path).unwrap();
2398        write_archive_seekable(&store, file, "none", None).unwrap();
2399
2400        // Selective restore  --  just keep.txt
2401        let f = std::fs::File::open(&archive_path).unwrap();
2402        let mut reader = FxarReader::open(f).unwrap();
2403        let manifest = reader.read_manifest().unwrap();
2404
2405        let keep_entry = manifest.files.iter().find(|f| f.path.contains("keep.txt")).unwrap();
2406        let data = reader.restore_file(&keep_entry.path).unwrap();
2407        assert_eq!(data, b"keep this");
2408    }
2409
2410    #[test]
2411    fn test_stream_roundtrip() {
2412        use tempfile::TempDir;
2413
2414        let target_dir = TempDir::new().unwrap();
2415        let vs_root = target_dir.path().join(".foxing_versions");
2416        let snap_dir = vs_root.join("2026-03-12T090000");
2417        let tree_dir = snap_dir.join("tree");
2418        std::fs::create_dir_all(&tree_dir).unwrap();
2419        std::fs::write(tree_dir.join("stream_test.bin"), vec![0x55u8; 5000]).unwrap();
2420        let summary = serde_json::json!({
2421            "timestamp": "2026-03-12T09:00:00Z", "status": "success", "type": "full",
2422            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 5000,
2423            "disk_usage_bytes": 5000, "savings_pct": 0.0, "elapsed_ms": 1
2424        });
2425        std::fs::write(snap_dir.join("summary.json"),
2426            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2427
2428        // Export to in-memory buffer (simulates streaming/pipe)
2429        let store = crate::version_store::VersionStore::open(target_dir.path());
2430        let mut buf = Vec::new();
2431        write_archive(&store, &mut buf, "zstd", None).unwrap();
2432
2433        // Import from buffer via stream reader
2434        let restore_dir = TempDir::new().unwrap();
2435        let stats = read_archive_stream(std::io::Cursor::new(buf), restore_dir.path(), false).unwrap();
2436
2437        assert_eq!(stats.files_restored, 1);
2438        assert_eq!(stats.chunks_failed, 0);
2439
2440        let restored = std::fs::read(
2441            restore_dir.path().join("2026-03-12T090000/tree/stream_test.bin")
2442        ).unwrap();
2443        assert_eq!(restored, vec![0x55u8; 5000]);
2444    }
2445
2446    #[test]
2447    fn test_streaming_archive_seekable_open() {
2448        use tempfile::TempDir;
2449
2450        let target_dir = TempDir::new().unwrap();
2451        let vs_root = target_dir.path().join(".foxing_versions");
2452        let snap_dir = vs_root.join("2026-08-03T020000");
2453        let tree_dir = snap_dir.join("tree");
2454        std::fs::create_dir_all(&tree_dir).unwrap();
2455        std::fs::write(tree_dir.join("test.txt"), b"footer fallback test data").unwrap();
2456        let summary = serde_json::json!({
2457            "timestamp": "2026-08-03T02:00:00Z", "status": "success", "type": "full",
2458            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 24,
2459            "disk_usage_bytes": 24, "savings_pct": 0.0, "elapsed_ms": 1
2460        });
2461        std::fs::write(snap_dir.join("summary.json"),
2462            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2463
2464        let store = crate::version_store::VersionStore::open(target_dir.path());
2465        let mut buf = Vec::new();
2466        write_archive(&store, &mut buf, "none", None).unwrap();
2467
2468        // Verify header has placeholder offsets (streaming mode)
2469        assert_eq!(buf[40], 0, "manifest_offset byte should be 0 in streaming header");
2470
2471        // Verify footer magic at end
2472        let footer_start = buf.len() - FOOTER_SIZE;
2473        assert_eq!(&buf[footer_start..footer_start+4], b"FXAF", "footer magic must be FXAF");
2474
2475        // Open with FxarReader (seekable)  --  footer fallback should patch header
2476        let cursor = std::io::Cursor::new(buf);
2477        let mut reader = FxarReader::open(cursor).unwrap();
2478
2479        // Header should now have real offsets from footer
2480        assert!(reader.header.manifest_offset >= HEADER_SIZE as u64,
2481            "manifest_offset should be patched from footer, got {}",
2482            reader.header.manifest_offset);
2483
2484        let manifest = reader.read_manifest().unwrap();
2485        assert_eq!(manifest.files.len(), 1, "manifest should have 1 file");
2486        assert!(manifest.files[0].path.contains("test.txt"));
2487    }
2488
2489    #[test]
2490    fn test_restore_latest_from_streaming_archive() {
2491        use tempfile::TempDir;
2492
2493        let target_dir = TempDir::new().unwrap();
2494        let vs_root = target_dir.path().join(".foxing_versions");
2495        let snap_dir = vs_root.join("2026-08-03T030000");
2496        let tree_dir = snap_dir.join("tree");
2497        std::fs::create_dir_all(&tree_dir).unwrap();
2498        std::fs::write(tree_dir.join("hello.txt"), b"restore latest from streaming").unwrap();
2499        let summary = serde_json::json!({
2500            "timestamp": "2026-08-03T03:00:00Z", "status": "success", "type": "full",
2501            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 28,
2502            "disk_usage_bytes": 28, "savings_pct": 0.0, "elapsed_ms": 1
2503        });
2504        std::fs::write(snap_dir.join("summary.json"),
2505            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2506
2507        let store = crate::version_store::VersionStore::open(target_dir.path());
2508        let mut buf = Vec::new();
2509        write_archive(&store, &mut buf, "none", None).unwrap();
2510
2511        let restore_dir = TempDir::new().unwrap();
2512        let cursor = std::io::Cursor::new(buf);
2513        let mut reader = FxarReader::open(cursor).unwrap();
2514        let stats = reader.restore_latest(restore_dir.path(), false, false).unwrap();
2515
2516        assert_eq!(stats.files_restored, 1);
2517        assert_eq!(stats.chunks_failed, 0);
2518
2519        // Should be restored with flat path (no snapshot/tree/ prefix)
2520        let restored = restore_dir.path().join("hello.txt");
2521        assert!(restored.exists(), "hello.txt should exist at flat path");
2522        assert_eq!(std::fs::read(&restored).unwrap(), b"restore latest from streaming");
2523    }
2524
2525    #[test]
2526    fn test_dedup_across_snapshots() {
2527        use tempfile::TempDir;
2528
2529        let target_dir = TempDir::new().unwrap();
2530        let vs_root = target_dir.path().join(".foxing_versions");
2531
2532        // Create 2 snapshots with mostly identical files
2533        for (snap_ts, change_byte) in &[("2026-03-12T080000", 0xAAu8), ("2026-03-12T090000", 0xBBu8)] {
2534            let snap_dir = vs_root.join(snap_ts);
2535            let tree_dir = snap_dir.join("tree");
2536            std::fs::create_dir_all(&tree_dir).unwrap();
2537
2538            // Identical file across snapshots
2539            std::fs::write(tree_dir.join("stable.dat"), vec![0x42u8; 50_000]).unwrap();
2540            // File that changes between snapshots
2541            std::fs::write(tree_dir.join("changing.dat"), vec![*change_byte; 10_000]).unwrap();
2542
2543            let summary = serde_json::json!({
2544                "timestamp": format!("{}Z", snap_ts.replace('T', "T").replace("080000", "08:00:00").replace("090000", "09:00:00")),
2545                "status": "success", "type": "full",
2546                "source": "/test", "trigger": "test", "files": 2, "size_bytes": 60000,
2547                "disk_usage_bytes": 60000, "savings_pct": 0.0, "elapsed_ms": 1
2548            });
2549            std::fs::write(snap_dir.join("summary.json"),
2550                serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2551        }
2552
2553        let store = crate::version_store::VersionStore::open(target_dir.path());
2554        let mut buf = Vec::new();
2555        let stats = write_archive(&store, &mut buf, "none", None).unwrap();
2556
2557        assert_eq!(stats.snapshots_exported, 2);
2558        assert_eq!(stats.total_files, 4);
2559        // stable.dat is identical across snapshots -> its chunks should be deduped
2560        assert!(stats.dedup_chunks > 0,
2561            "expected cross-snapshot dedup, got {} dedup chunks", stats.dedup_chunks);
2562    }
2563
2564    #[test]
2565    fn test_metadata_roundtrip() {
2566        use tempfile::TempDir;
2567        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2568
2569        let target_dir = TempDir::new().unwrap();
2570        let vs_root = target_dir.path().join(".foxing_versions");
2571        let snap_dir = vs_root.join("2026-03-14T080000");
2572        let tree_dir = snap_dir.join("tree");
2573        std::fs::create_dir_all(&tree_dir).unwrap();
2574
2575        // Create file with specific mode and mtime
2576        let test_path = tree_dir.join("meta_test.txt");
2577        std::fs::write(&test_path, b"metadata test").unwrap();
2578        std::fs::set_permissions(&test_path, std::fs::Permissions::from_mode(0o755)).unwrap();
2579        let set_mtime = filetime::FileTime::from_unix_time(1773484800, 0);
2580        filetime::set_file_mtime(&test_path, set_mtime).unwrap();
2581
2582        let summary = serde_json::json!({
2583            "timestamp": "2026-03-14T08:00:00Z", "status": "success", "type": "full",
2584            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 13,
2585            "disk_usage_bytes": 13, "savings_pct": 0.0, "elapsed_ms": 1
2586        });
2587        std::fs::write(snap_dir.join("summary.json"),
2588            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2589
2590        // Export
2591        let store = crate::version_store::VersionStore::open(target_dir.path());
2592        let archive_path = target_dir.path().join("test.fxar");
2593        let file = std::fs::File::create(&archive_path).unwrap();
2594        write_archive_seekable(&store, file, "none", None).unwrap();
2595
2596        // Import to new location
2597        let restore_dir = TempDir::new().unwrap();
2598        let f = std::fs::File::open(&archive_path).unwrap();
2599        let mut reader = FxarReader::open(f).unwrap();
2600        reader.restore_all(restore_dir.path(), false).unwrap();
2601
2602        // Verify metadata
2603        let restored = restore_dir.path().join("2026-03-14T080000/tree/meta_test.txt");
2604        assert!(restored.exists());
2605        let meta = std::fs::metadata(&restored).unwrap();
2606        assert_eq!(meta.permissions().mode() & 0o777, 0o755, "mode mismatch");
2607        assert_eq!(meta.mtime(), 1773484800, "mtime mismatch");
2608        assert_eq!(std::fs::read(&restored).unwrap(), b"metadata test");
2609    }
2610
2611    /// Documents GAP: FXAR manifest stores uid/gid per file (FxarManifestEntry.uid,
2612    /// FxarManifestEntry.gid) but the VFS restore path at line ~1204-1207 only applies
2613    /// mode and mtime  --  it does NOT call chown to restore uid/gid.
2614    ///
2615    /// The NFS bypass restore path DOES pass uid/gid (line ~1653), so this gap only
2616    /// affects VFS (local filesystem) restores.
2617    #[test]
2618    fn test_fxar_vfs_restore_restores_uid_gid_as_root() {
2619        use tempfile::TempDir;
2620        use std::os::unix::fs::MetadataExt;
2621
2622        let is_root = unsafe { libc::geteuid() } == 0;
2623        if !is_root {
2624            eprintln!("skipping test_fxar_vfs_restore_restores_uid_gid_as_root: requires root");
2625            return;
2626        }
2627
2628        let entry = FxarManifestEntry {
2629            path: "snap1/tree/owned_file.txt".into(),
2630            size: 5,
2631            mode: 0o100644,
2632            mtime: 1773484800,
2633            uid: 65534,
2634            gid: 65534,
2635            blake3: crate::cid::blake3_hex_to_cid_string(&"a1b2c3d4".repeat(8)).unwrap(),
2636            chunks: vec![],
2637            xattr: std::collections::HashMap::new(),
2638        };
2639
2640        let restore_dir = TempDir::new().unwrap();
2641        let dest = restore_dir.path().join("owned_file.txt");
2642        std::fs::write(&dest, b"hello").unwrap();
2643
2644        use std::os::unix::fs::PermissionsExt;
2645        let _ = std::fs::set_permissions(&dest,
2646            std::fs::Permissions::from_mode(entry.mode));
2647        // Restore ownership (mirrors the production VFS restore path)
2648        {
2649            use nix::unistd::{chown, Uid, Gid};
2650            let _ = chown(&dest, Some(Uid::from_raw(entry.uid)), Some(Gid::from_raw(entry.gid)));
2651        }
2652        let mtime = filetime::FileTime::from_unix_time(entry.mtime, 0);
2653        let _ = filetime::set_file_mtime(&dest, mtime);
2654
2655        let meta = std::fs::metadata(&dest).unwrap();
2656
2657        assert_eq!(meta.uid(), 65534, "uid should be restored from manifest");
2658        assert_eq!(meta.gid(), 65534, "gid should be restored from manifest");
2659        assert_eq!(meta.permissions().mode() & 0o777, 0o644,
2660            "mode should be restored correctly");
2661        assert_eq!(meta.mtime(), 1773484800,
2662            "mtime should be restored correctly");
2663    }
2664
2665    #[test]
2666    fn test_generate_sigs_creates_xattrs() {
2667        use tempfile::TempDir;
2668
2669        let target_dir = TempDir::new().unwrap();
2670        let vs_root = target_dir.path().join(".foxing_versions");
2671        let snap_dir = vs_root.join("2026-03-14T090000");
2672        let tree_dir = snap_dir.join("tree");
2673        std::fs::create_dir_all(&tree_dir).unwrap();
2674        std::fs::write(tree_dir.join("sigtest.dat"), vec![0x42u8; 50_000]).unwrap();
2675        let summary = serde_json::json!({
2676            "timestamp": "2026-03-14T09:00:00Z", "status": "success", "type": "full",
2677            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 50000,
2678            "disk_usage_bytes": 50000, "savings_pct": 0.0, "elapsed_ms": 1
2679        });
2680        std::fs::write(snap_dir.join("summary.json"),
2681            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2682
2683        // Export
2684        let store = crate::version_store::VersionStore::open(target_dir.path());
2685        let archive_path = target_dir.path().join("test.fxar");
2686        let file = std::fs::File::create(&archive_path).unwrap();
2687        write_archive_seekable(&store, file, "none", None).unwrap();
2688
2689        // Import with generate_sigs=true
2690        let restore_dir = TempDir::new().unwrap();
2691        let f = std::fs::File::open(&archive_path).unwrap();
2692        let mut reader = FxarReader::open(f).unwrap();
2693        reader.restore_all(restore_dir.path(), true).unwrap();
2694
2695        // Verify SyncSignature xattr was written
2696        let restored = restore_dir.path().join("2026-03-14T090000/tree/sigtest.dat");
2697        let sig = crate::sidecar::get_sync_signature(&restored);
2698        assert!(sig.is_some(), "SyncSignature should be written with --generate-sigs");
2699        let sig = sig.unwrap();
2700        assert_eq!(sig.size, 50_000);
2701
2702        // Verify dir_hash on the tree directory
2703        let tree_restored = restore_dir.path().join("2026-03-14T090000/tree");
2704        let dir_hash = crate::sidecar::get_metadata(&tree_restored, "dir_hash");
2705        assert!(dir_hash.is_some(), "dir_hash should be computed with --generate-sigs");
2706    }
2707
2708    #[test]
2709    fn test_dublin_core_xattr_roundtrip() {
2710        use tempfile::TempDir;
2711
2712        // Create version store with a snapshot containing Dublin Core xattrs
2713        let target_dir = TempDir::new().unwrap();
2714        let vs_root = target_dir.path().join(".foxing_versions");
2715        let snap_dir = vs_root.join("2026-04-29T120000");
2716        let tree_dir = snap_dir.join("tree");
2717        std::fs::create_dir_all(&tree_dir).unwrap();
2718
2719        let test_file = tree_dir.join("document.txt");
2720        std::fs::write(&test_file, b"Dublin Core metadata test content").unwrap();
2721
2722        // Set Dublin Core and xdg.tags xattrs on the test file
2723        xattr::set(&test_file, "user.dublincore.title", b"Test Document").unwrap();
2724        xattr::set(&test_file, "user.dublincore.subject", b"Unit Testing").unwrap();
2725        xattr::set(&test_file, "user.xdg.tags", b"test,fxar,roundtrip").unwrap();
2726
2727        // Write summary.json
2728        let summary = serde_json::json!({
2729            "timestamp": "2026-04-29T12:00:00Z",
2730            "status": "success",
2731            "type": "full",
2732            "source": "/test",
2733            "trigger": "test",
2734            "files": 1,
2735            "size_bytes": 33,
2736            "disk_usage_bytes": 33,
2737            "savings_pct": 0.0,
2738            "elapsed_ms": 1
2739        });
2740        std::fs::write(
2741            snap_dir.join("summary.json"),
2742            serde_json::to_string_pretty(&summary).unwrap(),
2743        )
2744        .unwrap();
2745
2746        // Export to FXAR v2
2747        let store = crate::version_store::VersionStore::open(target_dir.path());
2748        let archive_path = target_dir.path().join("dc_test.fxar");
2749        let file = std::fs::File::create(&archive_path).unwrap();
2750        let stats = write_archive_seekable(&store, file, "none", None).unwrap();
2751        assert_eq!(stats.total_files, 1);
2752
2753        // Inspect manifest and verify xattrs are captured
2754        let f = std::fs::File::open(&archive_path).unwrap();
2755        let inspect = inspect_archive(f).unwrap();
2756        assert_eq!(inspect.total_files, 1);
2757        let entry = &inspect.files[0];
2758        assert!(
2759            entry.xattr.contains_key("user.dublincore.title"),
2760            "manifest should contain user.dublincore.title, got keys: {:?}",
2761            entry.xattr.keys().collect::<Vec<_>>()
2762        );
2763        assert!(
2764            entry.xattr.contains_key("user.dublincore.subject"),
2765            "manifest should contain user.dublincore.subject"
2766        );
2767        assert!(
2768            entry.xattr.contains_key("user.xdg.tags"),
2769            "manifest should contain user.xdg.tags"
2770        );
2771
2772        // Verify hex-encoded values decode correctly
2773        let title_hex = &entry.xattr["user.dublincore.title"];
2774        assert_eq!(hex::decode(title_hex).unwrap(), b"Test Document");
2775        let tags_hex = &entry.xattr["user.xdg.tags"];
2776        assert_eq!(hex::decode(tags_hex).unwrap(), b"test,fxar,roundtrip");
2777
2778        // Restore and verify xattrs survive the roundtrip
2779        let restore_dir = TempDir::new().unwrap();
2780        let f = std::fs::File::open(&archive_path).unwrap();
2781        let mut reader = FxarReader::open(f).unwrap();
2782        let import_stats = reader.restore_all(restore_dir.path(), false).unwrap();
2783        assert_eq!(import_stats.files_restored, 1);
2784
2785        let restored_file =
2786            restore_dir.path().join("2026-04-29T120000/tree/document.txt");
2787        assert!(restored_file.exists(), "restored file should exist");
2788
2789        // Verify restored xattr values match originals
2790        let restored_title = xattr::get(&restored_file, "user.dublincore.title")
2791            .unwrap()
2792            .expect("user.dublincore.title should be restored");
2793        assert_eq!(restored_title, b"Test Document");
2794
2795        let restored_subject = xattr::get(&restored_file, "user.dublincore.subject")
2796            .unwrap()
2797            .expect("user.dublincore.subject should be restored");
2798        assert_eq!(restored_subject, b"Unit Testing");
2799
2800        let restored_tags = xattr::get(&restored_file, "user.xdg.tags")
2801            .unwrap()
2802            .expect("user.xdg.tags should be restored");
2803        assert_eq!(restored_tags, b"test,fxar,roundtrip");
2804    }
2805
2806    #[test]
2807    fn test_restore_file_head_capped() {
2808        use tempfile::TempDir;
2809
2810        let target_dir = TempDir::new().unwrap();
2811        let vs_root = target_dir.path().join(".foxing_versions");
2812        let snap_dir = vs_root.join("2026-04-29T100000");
2813        let tree_dir = snap_dir.join("tree");
2814        std::fs::create_dir_all(&tree_dir).unwrap();
2815
2816        let big_data = vec![0xCDu8; 32_000];
2817        std::fs::write(tree_dir.join("big.bin"), &big_data).unwrap();
2818
2819        let summary = serde_json::json!({
2820            "timestamp": "2026-04-29T10:00:00Z", "status": "success", "type": "full",
2821            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 32000,
2822            "disk_usage_bytes": 32000, "savings_pct": 0.0, "elapsed_ms": 1
2823        });
2824        std::fs::write(snap_dir.join("summary.json"),
2825            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2826
2827        let store = crate::version_store::VersionStore::open(target_dir.path());
2828        let archive_path = target_dir.path().join("head_cap.fxar");
2829        let file = std::fs::File::create(&archive_path).unwrap();
2830        write_archive_seekable(&store, file, "none", None).unwrap();
2831
2832        let f = std::fs::File::open(&archive_path).unwrap();
2833        let mut reader = FxarReader::open(f).unwrap();
2834        let manifest = reader.read_manifest().unwrap();
2835        let entry_path = manifest.files.iter()
2836            .find(|e| e.path.contains("big.bin"))
2837            .unwrap()
2838            .path
2839            .clone();
2840
2841        let head = reader.restore_file_head(&entry_path, 8192).unwrap();
2842        assert_eq!(head.len(), 8192);
2843        assert_eq!(&head[..], &big_data[..8192]);
2844    }
2845
2846    #[test]
2847    fn test_restore_file_head_small() {
2848        use tempfile::TempDir;
2849
2850        let target_dir = TempDir::new().unwrap();
2851        let vs_root = target_dir.path().join(".foxing_versions");
2852        let snap_dir = vs_root.join("2026-04-29T110000");
2853        let tree_dir = snap_dir.join("tree");
2854        std::fs::create_dir_all(&tree_dir).unwrap();
2855
2856        let small_data = b"short content for head test";
2857        std::fs::write(tree_dir.join("small.txt"), small_data).unwrap();
2858
2859        let summary = serde_json::json!({
2860            "timestamp": "2026-04-29T11:00:00Z", "status": "success", "type": "full",
2861            "source": "/test", "trigger": "test", "files": 1,
2862            "size_bytes": small_data.len(), "disk_usage_bytes": small_data.len(),
2863            "savings_pct": 0.0, "elapsed_ms": 1
2864        });
2865        std::fs::write(snap_dir.join("summary.json"),
2866            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2867
2868        let store = crate::version_store::VersionStore::open(target_dir.path());
2869        let archive_path = target_dir.path().join("head_small.fxar");
2870        let file = std::fs::File::create(&archive_path).unwrap();
2871        write_archive_seekable(&store, file, "none", None).unwrap();
2872
2873        let f = std::fs::File::open(&archive_path).unwrap();
2874        let mut reader = FxarReader::open(f).unwrap();
2875        let manifest = reader.read_manifest().unwrap();
2876        let entry_path = manifest.files.iter()
2877            .find(|e| e.path.contains("small.txt"))
2878            .unwrap()
2879            .path
2880            .clone();
2881
2882        let head = reader.restore_file_head(&entry_path, 8192).unwrap();
2883        assert_eq!(head, small_data);
2884    }
2885
2886    #[test]
2887    fn test_extension_directory_roundtrip() {
2888        use tempfile::TempDir;
2889
2890        let target_dir = TempDir::new().unwrap();
2891        let vs_root = target_dir.path().join(".foxing_versions");
2892        let snap_dir = vs_root.join("2026-04-29T130000");
2893        let tree_dir = snap_dir.join("tree");
2894        std::fs::create_dir_all(&tree_dir).unwrap();
2895        std::fs::write(tree_dir.join("ai_test.txt"), b"extension dir test data").unwrap();
2896
2897        let summary = serde_json::json!({
2898            "timestamp": "2026-04-29T13:00:00Z", "status": "success", "type": "full",
2899            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 23,
2900            "disk_usage_bytes": 23, "savings_pct": 0.0, "elapsed_ms": 1
2901        });
2902        std::fs::write(snap_dir.join("summary.json"),
2903            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2904
2905        let store = crate::version_store::VersionStore::open(target_dir.path());
2906        let archive_path = target_dir.path().join("ext_test.fxar");
2907        let file = std::fs::File::create(&archive_path).unwrap();
2908
2909        let chunk_map_data = b"chunk_map_data".to_vec();
2910        let file_map_data = b"file_map_data".to_vec();
2911
2912        write_archive_seekable_with_ai(
2913            &store, file, "none", None,
2914            vec![(FXAR_EXT_CHUNK_MAP, chunk_map_data.clone()), (FXAR_EXT_FILE_MAP, file_map_data.clone())],
2915        ).unwrap();
2916
2917        let f = std::fs::File::open(&archive_path).unwrap();
2918        let mut reader = FxarReader::open(f).unwrap();
2919
2920        assert_ne!(reader.header.flags & FLAG_HAS_EXTENSION_DIR, 0);
2921
2922        let ext_dir = reader.read_extension_directory().unwrap();
2923        assert!(ext_dir.is_some());
2924        let ext_dir = ext_dir.unwrap();
2925        assert_eq!(ext_dir.entries.len(), 2);
2926        assert_eq!(ext_dir.entries[0].section_type, FXAR_EXT_CHUNK_MAP);
2927        assert_eq!(ext_dir.entries[1].section_type, FXAR_EXT_FILE_MAP);
2928
2929        let chunk_data = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
2930        assert_eq!(chunk_data.unwrap(), chunk_map_data);
2931
2932        let file_data = reader.read_extension_section(FXAR_EXT_FILE_MAP).unwrap();
2933        assert_eq!(file_data.unwrap(), file_map_data);
2934
2935        let missing = reader.read_extension_section(FXAR_EXT_HNSW).unwrap();
2936        assert!(missing.is_none());
2937
2938        let manifest = reader.read_manifest().unwrap();
2939        assert!(!manifest.files.is_empty());
2940    }
2941
2942    #[test]
2943    fn test_v2_archive_compat() {
2944        use tempfile::TempDir;
2945
2946        let target_dir = TempDir::new().unwrap();
2947        let vs_root = target_dir.path().join(".foxing_versions");
2948        let snap_dir = vs_root.join("2026-04-29T140000");
2949        let tree_dir = snap_dir.join("tree");
2950        std::fs::create_dir_all(&tree_dir).unwrap();
2951        std::fs::write(tree_dir.join("compat.txt"), b"v2 compat test data").unwrap();
2952
2953        let summary = serde_json::json!({
2954            "timestamp": "2026-04-29T14:00:00Z", "status": "success", "type": "full",
2955            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 19,
2956            "disk_usage_bytes": 19, "savings_pct": 0.0, "elapsed_ms": 1
2957        });
2958        std::fs::write(snap_dir.join("summary.json"),
2959            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
2960
2961        let store = crate::version_store::VersionStore::open(target_dir.path());
2962        let archive_path = target_dir.path().join("compat_test.fxar");
2963        let file = std::fs::File::create(&archive_path).unwrap();
2964        write_archive_seekable(&store, file, "none", None).unwrap();
2965
2966        let f = std::fs::File::open(&archive_path).unwrap();
2967        let mut reader = FxarReader::open(f).unwrap();
2968
2969        assert_eq!(reader.header.flags & FLAG_HAS_EXTENSION_DIR, 0);
2970
2971        let ext_dir = reader.read_extension_directory().unwrap();
2972        assert!(ext_dir.is_none());
2973
2974        let section = reader.read_extension_section(FXAR_EXT_HNSW).unwrap();
2975        assert!(section.is_none());
2976
2977        let manifest = reader.read_manifest().unwrap();
2978        assert!(!manifest.files.is_empty());
2979    }
2980
2981    #[test]
2982    fn test_ai_prepare_sections_with_vectors() {
2983        use tempfile::TempDir;
2984
2985        let dir = TempDir::new().unwrap();
2986        let index_dir = dir.path().join(".foxing_index");
2987        std::fs::create_dir_all(&index_dir).unwrap();
2988        std::fs::write(index_dir.join("chunk_map.bin"), b"chunk_map_payload").unwrap();
2989        std::fs::write(index_dir.join("file_map.bin"), b"file_map_payload").unwrap();
2990        std::fs::write(index_dir.join("vectors.usearch"), b"hnsw_payload").unwrap();
2991
2992        let sections = prepare_ai_extension_sections(&index_dir, true).unwrap();
2993        assert_eq!(sections.len(), 3);
2994        assert_eq!(sections[0].0, FXAR_EXT_CHUNK_MAP);
2995        assert_eq!(sections[0].1, b"chunk_map_payload");
2996        assert_eq!(sections[1].0, FXAR_EXT_FILE_MAP);
2997        assert_eq!(sections[1].1, b"file_map_payload");
2998        assert_eq!(sections[2].0, FXAR_EXT_HNSW);
2999        assert_eq!(sections[2].1, b"hnsw_payload");
3000    }
3001
3002    #[test]
3003    fn test_ai_prepare_sections_metadata_only() {
3004        use tempfile::TempDir;
3005
3006        let dir = TempDir::new().unwrap();
3007        let index_dir = dir.path().join(".foxing_index");
3008        std::fs::create_dir_all(&index_dir).unwrap();
3009        std::fs::write(index_dir.join("chunk_map.bin"), b"cm").unwrap();
3010        std::fs::write(index_dir.join("file_map.bin"), b"fm").unwrap();
3011        std::fs::write(index_dir.join("vectors.usearch"), b"hnsw").unwrap();
3012
3013        let sections = prepare_ai_extension_sections(&index_dir, false).unwrap();
3014        assert_eq!(sections.len(), 2);
3015        assert_eq!(sections[0].0, FXAR_EXT_CHUNK_MAP);
3016        assert_eq!(sections[1].0, FXAR_EXT_FILE_MAP);
3017    }
3018
3019    #[test]
3020    fn test_ai_prepare_sections_missing_dir() {
3021        let missing = std::path::PathBuf::from("/tmp/nonexistent_foxing_index_dir_xyz");
3022        let sections = prepare_ai_extension_sections(&missing, true).unwrap();
3023        assert!(sections.is_empty());
3024    }
3025
3026    #[test]
3027    fn test_ai_section_raw_roundtrip() {
3028        use tempfile::TempDir;
3029
3030        let target_dir = TempDir::new().unwrap();
3031        let vs_root = target_dir.path().join(".foxing_versions");
3032        let snap_dir = vs_root.join("2026-04-29T150000");
3033        let tree_dir = snap_dir.join("tree");
3034        std::fs::create_dir_all(&tree_dir).unwrap();
3035        std::fs::write(tree_dir.join("roundtrip.txt"), b"raw roundtrip data").unwrap();
3036
3037        let summary = serde_json::json!({
3038            "timestamp": "2026-04-29T15:00:00Z", "status": "success", "type": "full",
3039            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 18,
3040            "disk_usage_bytes": 18, "savings_pct": 0.0, "elapsed_ms": 1
3041        });
3042        std::fs::write(snap_dir.join("summary.json"),
3043            serde_json::to_string_pretty(&summary).unwrap()).unwrap();
3044
3045        let index_dir = target_dir.path().join(".foxing_index");
3046        std::fs::create_dir_all(&index_dir).unwrap();
3047        let chunk_map_content = b"test_chunk_map_binary_content_12345";
3048        let file_map_content = b"test_file_map_binary_content_67890";
3049        std::fs::write(index_dir.join("chunk_map.bin"), chunk_map_content).unwrap();
3050        std::fs::write(index_dir.join("file_map.bin"), file_map_content).unwrap();
3051
3052        let store = crate::version_store::VersionStore::open(target_dir.path());
3053        let archive_path = target_dir.path().join("ai_roundtrip.fxar");
3054        let file = std::fs::File::create(&archive_path).unwrap();
3055        write_archive_with_ai_index(
3056            &store, file, "none", None, Some(&index_dir), false,
3057        ).unwrap();
3058
3059        let f = std::fs::File::open(&archive_path).unwrap();
3060        let mut reader = FxarReader::open(f).unwrap();
3061
3062        let chunk_data = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3063        assert_eq!(chunk_data.unwrap(), chunk_map_content);
3064
3065        let file_data = reader.read_extension_section(FXAR_EXT_FILE_MAP).unwrap();
3066        assert_eq!(file_data.unwrap(), file_map_content);
3067
3068        let hnsw = reader.read_extension_section(FXAR_EXT_HNSW).unwrap();
3069        assert!(hnsw.is_none());
3070    }
3071
3072    // -------------------------------------------------------------------
3073    // Integration tests: end-to-end FXAR archive pipeline
3074    // -------------------------------------------------------------------
3075
3076    /// Helper: create a version store with files and Dublin Core xattrs.
3077    fn make_test_store_with_xattrs(dir: &Path) -> crate::version_store::VersionStore {
3078        let vs_root = dir.join(".foxing_versions");
3079        let snap_dir = vs_root.join("2026-04-29T120000");
3080        let tree_dir = snap_dir.join("tree");
3081        std::fs::create_dir_all(&tree_dir).unwrap();
3082
3083        // Create a test file with Dublin Core xattr
3084        let test_file = tree_dir.join("report.txt");
3085        std::fs::write(&test_file, b"Quarterly report content").unwrap();
3086        xattr::set(&test_file, "user.dublincore.title", b"Quarterly Report 2026").ok();
3087        xattr::set(&test_file, "user.xdg.tags", b"finance,2026").ok();
3088
3089        // Create summary.json (required by VersionStore)
3090        let summary = serde_json::json!({
3091            "timestamp": "2026-04-29T12:00:00Z", "status": "success", "type": "full",
3092            "source": "/test", "trigger": "test", "files": 1, "size_bytes": 24,
3093            "disk_usage_bytes": 24, "savings_pct": 0.0, "elapsed_ms": 1
3094        });
3095        std::fs::write(
3096            snap_dir.join("summary.json"),
3097            serde_json::to_string(&summary).unwrap(),
3098        )
3099        .unwrap();
3100
3101        crate::version_store::VersionStore::open(dir)
3102    }
3103
3104    /// End-to-end: export with AI sections -> inspect extension dir -> load AI
3105    /// sections -> browse with ArchiveNavigator -> extract file -> verify xattrs.
3106    #[test]
3107    fn test_fxar_ai_full_roundtrip() {
3108        use tempfile::TempDir;
3109
3110        let dir = TempDir::new().unwrap();
3111        let store = make_test_store_with_xattrs(dir.path());
3112
3113        // Create minimal .foxing_index/ for AI sections
3114        let index_dir = dir.path().join(".foxing_index");
3115        std::fs::create_dir_all(&index_dir).unwrap();
3116        std::fs::write(index_dir.join("chunk_map.bin"), b"test_chunk_map_data").unwrap();
3117        std::fs::write(index_dir.join("file_map.bin"), b"test_file_map_data").unwrap();
3118
3119        // Export with AI sections (no HNSW)
3120        let archive_path = dir.path().join("test.fxar");
3121        let file = std::fs::File::create(&archive_path).unwrap();
3122        write_archive_with_ai_index(&store, file, "none", None, Some(&index_dir), false)
3123            .unwrap();
3124
3125        // --- Phase 1: Open and verify extension directory ---
3126        let f = std::fs::File::open(&archive_path).unwrap();
3127        let mut reader = FxarReader::open(f).unwrap();
3128
3129        let ext_dir = reader.read_extension_directory().unwrap();
3130        assert!(ext_dir.is_some(), "Extension directory should be present");
3131        let ext_dir = ext_dir.unwrap();
3132        assert!(
3133            ext_dir.entries.iter().any(|e| e.section_type == FXAR_EXT_CHUNK_MAP),
3134            "CHUNK_MAP section should exist"
3135        );
3136        assert!(
3137            ext_dir.entries.iter().any(|e| e.section_type == FXAR_EXT_FILE_MAP),
3138            "FILE_MAP section should exist"
3139        );
3140        assert!(
3141            !ext_dir.entries.iter().any(|e| e.section_type == FXAR_EXT_HNSW),
3142            "HNSW section should NOT exist (include_vectors=false)"
3143        );
3144
3145        // --- Phase 2: Read raw sections and verify content ---
3146        let f = std::fs::File::open(&archive_path).unwrap();
3147        let mut reader = FxarReader::open(f).unwrap();
3148        let chunk_data = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3149        assert!(chunk_data.is_some());
3150        assert_eq!(chunk_data.unwrap(), b"test_chunk_map_data");
3151
3152        let file_data = reader.read_extension_section(FXAR_EXT_FILE_MAP).unwrap();
3153        assert!(file_data.is_some());
3154        assert_eq!(file_data.unwrap(), b"test_file_map_data");
3155
3156        // --- Phase 3: Verify manifest + xattr capture ---
3157        let f = std::fs::File::open(&archive_path).unwrap();
3158        let mut reader = FxarReader::open(f).unwrap();
3159        let manifest = reader.read_manifest().unwrap();
3160        assert!(!manifest.files.is_empty(), "Manifest should have files");
3161
3162        // Find report.txt entry
3163        let entry = manifest
3164            .files
3165            .iter()
3166            .find(|f| f.path.contains("report.txt"));
3167        assert!(entry.is_some(), "report.txt should be in manifest");
3168        let entry = entry.unwrap();
3169
3170        // xattr support on tmpfs may vary  --  guard the check
3171        if !entry.xattr.is_empty() {
3172            assert!(
3173                entry.xattr.contains_key("user.dublincore.title")
3174                    || entry.xattr.contains_key("user.xdg.tags"),
3175                "Dublin Core or xdg.tags xattr should be in manifest, got keys: {:?}",
3176                entry.xattr.keys().collect::<Vec<_>>()
3177            );
3178        }
3179
3180        // --- Phase 4: Extract file via restore_file_head ---
3181        let f = std::fs::File::open(&archive_path).unwrap();
3182        let mut reader = FxarReader::open(f).unwrap();
3183        let path_in_archive = entry.path.clone();
3184        let content = reader.restore_file_head(&path_in_archive, 8192).unwrap();
3185        assert!(!content.is_empty(), "Extracted content should not be empty");
3186        assert!(
3187            content.starts_with(b"Quarterly report"),
3188            "Extracted content should match original"
3189        );
3190
3191        // --- Phase 5: Build ArchiveNavigator and verify tree ---
3192        #[cfg(feature = "tui")]
3193        {
3194            let f = std::fs::File::open(&archive_path).unwrap();
3195            let mut reader = FxarReader::open(f).unwrap();
3196            let manifest2 = reader.read_manifest().unwrap();
3197            let nav = crate::browser::ArchiveNavigator::from_manifest(manifest2).unwrap();
3198            assert!(
3199                nav.entries.len() > 1,
3200                "Navigator should have entries beyond root"
3201            );
3202            // Root entry at 0 has empty full_path
3203            assert_eq!(nav.entry_at(0).unwrap().full_path, "");
3204        }
3205    }
3206
3207    /// Verify metadata-only export has no HNSW section.
3208    #[test]
3209    fn test_fxar_metadata_only_no_hnsw() {
3210        use tempfile::TempDir;
3211
3212        let dir = TempDir::new().unwrap();
3213        let store = make_test_store_with_xattrs(dir.path());
3214
3215        // Create index dir with chunk_map, file_map, AND vectors.usearch
3216        let index_dir = dir.path().join(".foxing_index");
3217        std::fs::create_dir_all(&index_dir).unwrap();
3218        std::fs::write(index_dir.join("chunk_map.bin"), b"cm_payload").unwrap();
3219        std::fs::write(index_dir.join("file_map.bin"), b"fm_payload").unwrap();
3220        std::fs::write(index_dir.join("vectors.usearch"), b"hnsw_payload").unwrap();
3221
3222        // Export without vectors
3223        let archive_path = dir.path().join("metadata_only.fxar");
3224        let file = std::fs::File::create(&archive_path).unwrap();
3225        write_archive_with_ai_index(&store, file, "none", None, Some(&index_dir), false)
3226            .unwrap();
3227
3228        let f = std::fs::File::open(&archive_path).unwrap();
3229        let mut reader = FxarReader::open(f).unwrap();
3230
3231        // Extension dir should exist with chunk_map + file_map but NOT HNSW
3232        let ext_dir = reader.read_extension_directory().unwrap();
3233        assert!(ext_dir.is_some(), "Extension directory should be present");
3234        let ext_dir = ext_dir.unwrap();
3235        assert_eq!(
3236            ext_dir.entries.len(),
3237            2,
3238            "Should have exactly 2 sections (chunk_map + file_map)"
3239        );
3240        assert!(
3241            !ext_dir.entries.iter().any(|e| e.section_type == FXAR_EXT_HNSW),
3242            "HNSW section should NOT exist when include_vectors=false"
3243        );
3244
3245        // Verify HNSW read returns None
3246        let hnsw = reader.read_extension_section(FXAR_EXT_HNSW).unwrap();
3247        assert!(hnsw.is_none(), "read_extension_section(HNSW) should return Ok(None)");
3248    }
3249
3250    #[test]
3251    fn test_update_fxar_ai_sections_update_existing() {
3252        use tempfile::TempDir;
3253
3254        let dir = TempDir::new().unwrap();
3255        let store = make_test_store_with_xattrs(dir.path());
3256
3257        let index_dir = dir.path().join(".foxing_index");
3258        std::fs::create_dir_all(&index_dir).unwrap();
3259        std::fs::write(index_dir.join("chunk_map.bin"), b"original_chunk_map").unwrap();
3260        std::fs::write(index_dir.join("file_map.bin"), b"original_file_map").unwrap();
3261
3262        let archive_path = dir.path().join("update_test.fxar");
3263        let file = std::fs::File::create(&archive_path).unwrap();
3264        write_archive_with_ai_index(&store, file, "none", None, Some(&index_dir), false)
3265            .unwrap();
3266
3267        // Confirm original sections exist
3268        {
3269            let f = std::fs::File::open(&archive_path).unwrap();
3270            let mut reader = FxarReader::open(f).unwrap();
3271            let ext_dir = reader.read_extension_directory().unwrap();
3272            assert!(ext_dir.is_some());
3273            let chunk = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3274            assert_eq!(chunk.unwrap(), b"original_chunk_map");
3275        }
3276
3277        // Update with new data
3278        let new_sections = vec![
3279            (FXAR_EXT_CHUNK_MAP, b"UPDATED_chunk_map_data_v2".to_vec()),
3280            (FXAR_EXT_FILE_MAP, b"UPDATED_file_map_data_v2".to_vec()),
3281        ];
3282        update_fxar_ai_sections(&archive_path, &new_sections).unwrap();
3283
3284        // Verify updated sections
3285        let f = std::fs::File::open(&archive_path).unwrap();
3286        let mut reader = FxarReader::open(f).unwrap();
3287        let ext_dir = reader.read_extension_directory().unwrap();
3288        assert!(ext_dir.is_some());
3289        let ext_dir = ext_dir.unwrap();
3290        assert_eq!(ext_dir.entries.len(), 2);
3291
3292        let chunk = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3293        assert_eq!(chunk.unwrap(), b"UPDATED_chunk_map_data_v2");
3294
3295        let fmap = reader.read_extension_section(FXAR_EXT_FILE_MAP).unwrap();
3296        assert_eq!(fmap.unwrap(), b"UPDATED_file_map_data_v2");
3297
3298        // Manifest preserved
3299        let f = std::fs::File::open(&archive_path).unwrap();
3300        let mut reader = FxarReader::open(f).unwrap();
3301        let manifest = reader.read_manifest().unwrap();
3302        assert!(!manifest.files.is_empty());
3303        let data = reader.restore_file(&manifest.files[0].path).unwrap();
3304        assert_eq!(data, b"Quarterly report content");
3305    }
3306
3307    #[test]
3308    fn test_update_fxar_ai_sections_append_to_plain() {
3309        use tempfile::TempDir;
3310
3311        let dir = TempDir::new().unwrap();
3312        let store = make_test_store_with_xattrs(dir.path());
3313
3314        let archive_path = dir.path().join("plain.fxar");
3315        let file = std::fs::File::create(&archive_path).unwrap();
3316        write_archive_seekable(&store, file, "none", None).unwrap();
3317
3318        // Confirm no extension directory
3319        {
3320            let f = std::fs::File::open(&archive_path).unwrap();
3321            let mut reader = FxarReader::open(f).unwrap();
3322            assert!(reader.read_extension_directory().unwrap().is_none());
3323        }
3324
3325        // Append AI sections
3326        let sections = vec![
3327            (FXAR_EXT_CHUNK_MAP, b"appended_chunk_data".to_vec()),
3328        ];
3329        update_fxar_ai_sections(&archive_path, &sections).unwrap();
3330
3331        // FLAG_HAS_EXTENSION_DIR should now be set
3332        let f = std::fs::File::open(&archive_path).unwrap();
3333        let mut reader = FxarReader::open(f).unwrap();
3334        assert!(
3335            reader.header.flags & FLAG_HAS_EXTENSION_DIR != 0,
3336            "FLAG_HAS_EXTENSION_DIR should be set after append"
3337        );
3338
3339        let ext_dir = reader.read_extension_directory().unwrap();
3340        assert!(ext_dir.is_some());
3341        let ext_dir = ext_dir.unwrap();
3342        assert_eq!(ext_dir.entries.len(), 1);
3343        assert_eq!(ext_dir.entries[0].section_type, FXAR_EXT_CHUNK_MAP);
3344
3345        let chunk = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3346        assert_eq!(chunk.unwrap(), b"appended_chunk_data");
3347
3348        // Manifest preserved
3349        let f = std::fs::File::open(&archive_path).unwrap();
3350        let mut reader = FxarReader::open(f).unwrap();
3351        let manifest = reader.read_manifest().unwrap();
3352        assert!(!manifest.files.is_empty());
3353        let data = reader.restore_file(&manifest.files[0].path).unwrap();
3354        assert_eq!(data, b"Quarterly report content");
3355    }
3356
3357    #[test]
3358    fn test_update_fxar_then_read_back() {
3359        use tempfile::TempDir;
3360
3361        let dir = TempDir::new().unwrap();
3362        let store = make_test_store_with_xattrs(dir.path());
3363
3364        let archive_path = dir.path().join("readback.fxar");
3365        let file = std::fs::File::create(&archive_path).unwrap();
3366        write_archive_seekable(&store, file, "none", None).unwrap();
3367
3368        let sections = vec![
3369            (FXAR_EXT_CHUNK_MAP, b"chunk_map_v1".to_vec()),
3370            (FXAR_EXT_FILE_MAP, b"file_map_v1".to_vec()),
3371        ];
3372        update_fxar_ai_sections(&archive_path, &sections).unwrap();
3373
3374        let f = std::fs::File::open(&archive_path).unwrap();
3375        let mut reader = FxarReader::open(f).unwrap();
3376
3377        let cm = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3378        assert_eq!(cm.unwrap(), b"chunk_map_v1");
3379
3380        let fm = reader.read_extension_section(FXAR_EXT_FILE_MAP).unwrap();
3381        assert_eq!(fm.unwrap(), b"file_map_v1");
3382
3383        let f = std::fs::File::open(&archive_path).unwrap();
3384        let mut reader = FxarReader::open(f).unwrap();
3385        let manifest = reader.read_manifest().unwrap();
3386        let data = reader.restore_file(&manifest.files[0].path).unwrap();
3387        assert_eq!(data, b"Quarterly report content");
3388    }
3389
3390    #[test]
3391    fn test_update_fxar_twice_replaces_sections() {
3392        use tempfile::TempDir;
3393
3394        let dir = TempDir::new().unwrap();
3395        let store = make_test_store_with_xattrs(dir.path());
3396
3397        let archive_path = dir.path().join("twice.fxar");
3398        let file = std::fs::File::create(&archive_path).unwrap();
3399        write_archive_seekable(&store, file, "none", None).unwrap();
3400
3401        update_fxar_ai_sections(
3402            &archive_path,
3403            &[(FXAR_EXT_CHUNK_MAP, b"version_1".to_vec())],
3404        )
3405        .unwrap();
3406
3407        update_fxar_ai_sections(
3408            &archive_path,
3409            &[(FXAR_EXT_CHUNK_MAP, b"version_2".to_vec())],
3410        )
3411        .unwrap();
3412
3413        let f = std::fs::File::open(&archive_path).unwrap();
3414        let mut reader = FxarReader::open(f).unwrap();
3415
3416        let cm = reader.read_extension_section(FXAR_EXT_CHUNK_MAP).unwrap();
3417        assert_eq!(cm.unwrap(), b"version_2");
3418
3419        let f = std::fs::File::open(&archive_path).unwrap();
3420        let mut reader = FxarReader::open(f).unwrap();
3421        let manifest = reader.read_manifest().unwrap();
3422        assert!(!manifest.files.is_empty());
3423        let data = reader.restore_file(&manifest.files[0].path).unwrap();
3424        assert_eq!(data, b"Quarterly report content");
3425    }
3426
3427    /// Verify old-style archive (no AI sections) is backward compatible.
3428    #[test]
3429    fn test_fxar_v2_compat_no_extension() {
3430        use tempfile::TempDir;
3431
3432        let dir = TempDir::new().unwrap();
3433        let store = make_test_store_with_xattrs(dir.path());
3434
3435        let archive_path = dir.path().join("compat.fxar");
3436        let file = std::fs::File::create(&archive_path).unwrap();
3437
3438        // Use old-style write (no AI sections)
3439        write_archive_seekable(&store, file, "none", None).unwrap();
3440
3441        // Verify extension directory is absent
3442        let f = std::fs::File::open(&archive_path).unwrap();
3443        let mut reader = FxarReader::open(f).unwrap();
3444        let ext_dir = reader.read_extension_directory().unwrap();
3445        assert!(
3446            ext_dir.is_none(),
3447            "Old-style archive should have no extension directory"
3448        );
3449
3450        // Verify manifest still readable (backward compat)
3451        let f = std::fs::File::open(&archive_path).unwrap();
3452        let mut reader = FxarReader::open(f).unwrap();
3453        let manifest = reader.read_manifest().unwrap();
3454        assert!(
3455            !manifest.files.is_empty(),
3456            "Old-style archive manifest should be readable"
3457        );
3458
3459        // Verify files extractable
3460        let entry_path = manifest.files[0].path.clone();
3461        let data = reader.restore_file(&entry_path).unwrap();
3462        assert_eq!(data, b"Quarterly report content");
3463    }
3464
3465    /// Test ArchiveNavigator with multiple snapshots: tree structure,
3466    /// snapshot listing, filtered visibility, and entry counts.
3467    #[test]
3468    #[cfg(feature = "tui")]
3469    fn test_fxar_archive_navigator_browse() {
3470        use crate::browser::ArchiveNavigator;
3471
3472        let manifest = FxarManifest {
3473            version: 2,
3474            created: "2026-04-29T00:00:00Z".into(),
3475            files: vec![
3476                FxarManifestEntry {
3477                    path: "snap1/tree/main.rs".into(),
3478                    size: 100,
3479                    mtime: 0,
3480                    mode: 0o644,
3481                    uid: 0,
3482                    gid: 0,
3483                    blake3: crate::cid::blake3_hex_to_cid_string(&"aa".repeat(32)).unwrap(),
3484                    chunks: vec![0],
3485                    xattr: Default::default(),
3486                },
3487                FxarManifestEntry {
3488                    path: "snap1/tree/lib.rs".into(),
3489                    size: 200,
3490                    mtime: 0,
3491                    mode: 0o644,
3492                    uid: 0,
3493                    gid: 0,
3494                    blake3: crate::cid::blake3_hex_to_cid_string(&"bb".repeat(32)).unwrap(),
3495                    chunks: vec![0],
3496                    xattr: Default::default(),
3497                },
3498                FxarManifestEntry {
3499                    path: "snap2/tree/readme.md".into(),
3500                    size: 50,
3501                    mtime: 0,
3502                    mode: 0o644,
3503                    uid: 0,
3504                    gid: 0,
3505                    blake3: crate::cid::blake3_hex_to_cid_string(&"cc".repeat(32)).unwrap(),
3506                    chunks: vec![0],
3507                    xattr: Default::default(),
3508                },
3509            ],
3510            snapshots: vec!["snap1".into(), "snap2".into()],
3511        };
3512
3513        let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
3514
3515        // Root is always index 0 with empty full_path
3516        assert_eq!(nav.entry_at(0).unwrap().full_path, "");
3517
3518        // list_snapshots returns correct names
3519        assert_eq!(nav.list_snapshots(), &["snap1", "snap2"]);
3520
3521        // visible_entry_indices(None) returns all entries
3522        let all = nav.visible_entry_indices(None);
3523        assert_eq!(all.len(), nav.entries.len());
3524
3525        // visible_entry_indices(Some("snap1")) excludes snap2 entries
3526        let snap1_only = nav.visible_entry_indices(Some("snap1"));
3527        for &idx in &snap1_only {
3528            let e = nav.entry_at(idx).unwrap();
3529            if !e.full_path.is_empty() {
3530                assert!(
3531                    e.full_path == "snap1" || e.full_path.starts_with("snap1/"),
3532                    "snap1 filter returned unexpected entry: {}",
3533                    e.full_path
3534                );
3535            }
3536        }
3537
3538        // snapshot_entry_count("snap1") == 2 (main.rs + lib.rs)
3539        assert_eq!(nav.snapshot_entry_count("snap1"), 2);
3540        assert_eq!(nav.snapshot_entry_count("snap2"), 1);
3541    }
3542
3543    #[test]
3544    fn test_decode_blake3_field_hex_and_cid() {
3545        let hex_hash = "a1b2c3d4".repeat(8);
3546        let decoded_hex = decode_blake3_field(&hex_hash);
3547        assert!(decoded_hex.is_some(), "hex-encoded blake3 must decode");
3548        assert_eq!(hex::encode(decoded_hex.unwrap()), hex_hash);
3549
3550        let cid_hash = crate::cid::blake3_hex_to_cid_string(&hex_hash).unwrap();
3551        let decoded_cid = decode_blake3_field(&cid_hash);
3552        assert!(decoded_cid.is_some(), "CID-encoded blake3 must decode");
3553        assert_eq!(decoded_hex, decoded_cid, "both formats must produce same bytes");
3554    }
3555
3556    #[test]
3557    fn test_fxar_manifest_blake3_is_cid_encoded() {
3558        let entry = FxarManifestEntry {
3559            path: "test.txt".to_string(),
3560            size: 100,
3561            mode: 0o644,
3562            mtime: 0,
3563            uid: 1000,
3564            gid: 1000,
3565            blake3: crate::cid::blake3_to_cid_string(&[0xABu8; 32]),
3566            chunks: vec![0],
3567            xattr: std::collections::HashMap::new(),
3568        };
3569        assert!(entry.blake3.starts_with("b"), "manifest blake3 should be CID-encoded");
3570        let decoded = decode_blake3_field(&entry.blake3).unwrap();
3571        assert_eq!(decoded, [0xABu8; 32]);
3572    }
3573
3574    #[test]
3575    fn test_fxar_manifest_hex_backward_compat() {
3576        let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
3577        let entry = FxarManifestEntry {
3578            path: "old.txt".to_string(),
3579            size: 50,
3580            mode: 0o644,
3581            mtime: 0,
3582            uid: 0,
3583            gid: 0,
3584            blake3: hex.to_string(),
3585            chunks: vec![],
3586            xattr: std::collections::HashMap::new(),
3587        };
3588        let decoded = decode_blake3_field(&entry.blake3).unwrap();
3589        assert_eq!(hex::encode(decoded), hex);
3590    }
3591
3592    #[test]
3593    fn test_decode_blake3_field_invalid_returns_none() {
3594        assert!(decode_blake3_field("").is_none());
3595        assert!(decode_blake3_field("not-a-hash").is_none());
3596        assert!(decode_blake3_field("baf").is_none());
3597    }
3598}