1use 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
19const 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
30const MAX_FXAR_ALLOC: u64 = 64 * 1024 * 1024;
32
33const 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
40pub const FLAG_HAS_EXTENSION_DIR: u32 = 0x8000_0000;
43
44pub const FXAR_EXT_HNSW: u32 = 1;
46pub const FXAR_EXT_CHUNK_MAP: u32 = 2;
48pub const FXAR_EXT_FILE_MAP: u32 = 3;
50
51const EXTENSION_ENTRY_SIZE: usize = 56;
52
53#[derive(Debug, Clone, Copy)]
58#[repr(C)]
59pub 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}
73impl 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#[derive(Debug, Clone)]
137pub 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#[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 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
311pub 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)]
321pub 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#[derive(Debug, Default, Serialize, Deserialize)]
340pub 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 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)]
360pub struct FxarImportStats {
362 pub files_restored: u64,
363 pub bytes_restored: u64,
364 pub chunks_verified: u64,
365 pub chunks_failed: u64,
366}
367
368struct ProcessedFile {
374 archive_path: String,
375 size: u64,
376 mode: u32,
377 mtime: i64,
378 uid: u32,
379 gid: u32,
380 blake3: [u8; 32],
382 chunks: Vec<([u8; 32], u32, Vec<u8>)>,
384 xattr: std::collections::HashMap<String, String>,
385}
386
387fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct FileVersionInfo {
404 pub rel_path: String,
406 pub restored_snapshot: String,
408 pub available_snapshots: Vec<String>,
410 pub size: u64,
412}
413
414fn 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
423fn 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 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 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 let file_hash = *blake3::hash(&file_data).as_bytes();
459
460 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 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
502fn 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
553fn 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 header.write_to(&mut writer)?;
578 let mut offset = HEADER_SIZE as u64;
579
580 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 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 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
630pub 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
661pub 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 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 writer.write_all(&ext_dir_offset.to_le_bytes())?;
744
745 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
773pub 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
809pub 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
828pub 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
860pub 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
883fn 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 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
952pub 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 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 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 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
1079pub struct FxarReader<R: Read + Seek> {
1085 reader: R,
1086 pub header: FxarHeader,
1087}
1088
1089impl<R: Read + Seek> FxarReader<R> {
1090 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 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 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 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 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 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 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 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 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 pub fn restore_all(&mut self, target: &Path, generate_sigs: bool) -> io::Result<FxarImportStats> {
1245 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 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 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 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 let producer_store = chunk_store.clone();
1287 let producer_loaded = chunks_loaded.clone();
1288 let producer_index = index.clone();
1289 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() }
1314 };
1315 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 #[cfg(feature = "nfs-bypass")]
1327 let nfs_pool: Option<crate::nfs::NfsClientPool> = init_nfs_pool(target);
1328
1329 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 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 let needed = max_chunk as usize + 1;
1345 while chunks_loaded.load(Ordering::Acquire) < needed {
1346 std::hint::spin_loop();
1347 }
1348
1349 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 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 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 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 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 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 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 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 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)); 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 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 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 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 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 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 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 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 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 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 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 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
1727pub 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 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 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 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 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 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 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 #[cfg(feature = "nfs-bypass")]
1819 let nfs_pool: Option<crate::nfs::NfsClientPool> = init_nfs_pool(target);
1820
1821 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 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 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 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 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
1904pub 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 if magic == [0x28, 0xB5, 0x2F, 0xFD] {
1922 return Ok(("zstd-stream", magic));
1923 }
1924 if magic[0] == 0x1F && magic[1] == 0x8B {
1926 return Ok(("gzip-stream", magic));
1927 }
1928 if magic == [0xFD, 0x37, 0x7A, 0x58] {
1930 return Ok(("xz-stream", magic));
1931 }
1932 if magic == [0x04, 0x22, 0x4D, 0x18] {
1934 return Ok(("lz4-stream", magic));
1935 }
1936
1937 Ok(("tar", magic))
1939}
1940
1941pub 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 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)]
1979pub 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
1995fn 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 let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
2005 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
2014fn compute_dir_hashes_recursive(root: &Path) {
2017 for entry in walkdir::WalkDir::new(root)
2018 .contents_first(true) .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 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#[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#[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) .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#[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 let source_dir = TempDir::new().unwrap();
2300 let target_dir = TempDir::new().unwrap();
2301
2302 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 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(); std::fs::write(tree_dir.join("file3.dat"), vec![0xABu8; 10_000]).unwrap();
2313
2314 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 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 assert!(stats.dedup_chunks > 0, "expected dedup, got {} dedup chunks", stats.dedup_chunks);
2341
2342 let archive_data = std::fs::read(&archive_path).unwrap();
2344 assert_eq!(&archive_data[..4], b"FXAR");
2345
2346 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 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 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 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 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 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 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 assert_eq!(buf[40], 0, "manifest_offset byte should be 0 in streaming header");
2470
2471 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 let cursor = std::io::Cursor::new(buf);
2477 let mut reader = FxarReader::open(cursor).unwrap();
2478
2479 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 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 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 std::fs::write(tree_dir.join("stable.dat"), vec![0x42u8; 50_000]).unwrap();
2540 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 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 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 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 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 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 #[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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 #[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 assert_eq!(nav.entry_at(0).unwrap().full_path, "");
3204 }
3205 }
3206
3207 #[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 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 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 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 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 {
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 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 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 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 {
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 let sections = vec![
3327 (FXAR_EXT_CHUNK_MAP, b"appended_chunk_data".to_vec()),
3328 ];
3329 update_fxar_ai_sections(&archive_path, §ions).unwrap();
3330
3331 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 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, §ions).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 #[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 write_archive_seekable(&store, file, "none", None).unwrap();
3440
3441 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 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 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]
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 assert_eq!(nav.entry_at(0).unwrap().full_path, "");
3517
3518 assert_eq!(nav.list_snapshots(), &["snap1", "snap2"]);
3520
3521 let all = nav.visible_entry_indices(None);
3523 assert_eq!(all.len(), nav.entries.len());
3524
3525 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 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}