1#![allow(clippy::unwrap_used, clippy::expect_used)]
10use std::path::{Path, PathBuf};
11use std::fs::{self, File};
12use std::collections::HashMap;
13use serde_json;
14use std::os::unix::io::AsRawFd;
15use libc;
16use std::io::{self};
17use xattr;
18use tracing::{error, debug, warn};
19use tokio::sync::{mpsc, oneshot};
20use std::thread;
21use std::sync::atomic::{AtomicBool, Ordering};
22use crate::hashing;
23use std::os::unix::fs::MetadataExt;
24
25const NS_USER_PREFIX: &str = "user.foxing.";
26const NS_TRUSTED_PREFIX: &str = "trusted.foxing.";
27static FALLBACK_WARNED: AtomicBool = AtomicBool::new(false);
28static XATTR_UNSUPPORTED: AtomicBool = AtomicBool::new(false);
30
31pub const XATTR_SIMILARITY: &str = "user.foxing.sim";
35pub const XATTR_FILE_SIMILARITY: &str = "user.foxing.fsim";
37pub const XATTR_EMBEDDING: &str = "user.foxing.emb";
39pub const XATTR_ENRICHMENT: &str = "user.foxing.enriched";
41pub const XATTR_MEDIA_META: &str = "user.foxing.media_meta";
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct SyncSignature {
47 pub size: u64,
49 pub mtime_sec: i64,
51 pub mtime_nsec: i64,
53 pub hash: Option<String>,
55 pub merkle_root: Option<String>,
57 pub chunk_size: Option<u64>,
59 pub leaf_count: Option<u32>,
61 #[serde(default)]
63 pub avg_entropy: Option<f32>,
64 #[serde(default)]
66 pub entropy_class: Option<String>,
67 #[serde(default)]
69 pub file_simhash: Option<i64>,
70 pub version: u8,
72}
73
74#[derive(serde::Serialize, serde::Deserialize)]
77struct SyncSignatureV4 {
78 size: u64,
79 mtime_sec: i64,
80 mtime_nsec: i64,
81 hash: Option<String>,
82 merkle_root: Option<String>,
83 chunk_size: Option<u64>,
84 leaf_count: Option<u32>,
85 version: u8,
86}
87
88#[derive(serde::Serialize, serde::Deserialize)]
91struct SyncSignatureV3 {
92 size: u64,
93 mtime_sec: i64,
94 mtime_nsec: i64,
95 hash: Option<String>,
96 merkle_root: Option<String>,
97 chunk_size: Option<u64>,
98 version: u8,
99}
100
101fn compute_entropy_fields(leaves: &[hashing::ChunkHash]) -> (Option<f32>, Option<String>) {
107 let total_bytes: u64 = leaves.iter().map(|c| c.length as u64).sum();
108 if total_bytes == 0 {
109 return (None, None);
110 }
111 let weighted_sum: f64 = leaves.iter()
112 .map(|c| c.entropy as f64 * c.length as f64)
113 .sum();
114 let avg = (weighted_sum / total_bytes as f64) as f32;
115
116 let mut band_bytes = [0u64; 5];
117 for chunk in leaves {
118 let band = match () {
119 _ if chunk.entropy < 0.5 => 0,
120 _ if chunk.entropy < 5.5 => 1,
121 _ if chunk.entropy < 7.0 => 2,
122 _ if chunk.entropy < 7.5 => 3,
123 _ => 4,
124 };
125 band_bytes[band] += chunk.length as u64;
126 }
127 let (dominant_band, &dominant_bytes) = band_bytes
128 .iter()
129 .enumerate()
130 .max_by_key(|(_, b)| **b)
131 .expect("band_bytes is non-empty");
132 let class_label = if dominant_bytes * 100 > total_bytes * 66 {
133 match dominant_band {
134 0 => "sparse",
135 1 => "text",
136 2 => "binary",
137 3 => "compressed",
138 _ => "encrypted",
139 }
140 } else {
141 "mixed"
142 };
143 (Some(avg), Some(class_label.to_string()))
144}
145
146impl SyncSignature {
147 pub const CURRENT_VERSION: u8 = 5;
148
149 pub fn compute(path: &Path) -> crate::error::Result<Self> {
151 let meta = std::fs::metadata(path)?;
152 let size = meta.len();
153 let chunk_size = hashing::calculate_adaptive_chunk_size(size);
154
155 #[cfg(feature = "similarity")]
156 let compute_mode = hashing::ComputeMode::WithSimilarity;
157 #[cfg(not(feature = "similarity"))]
158 let compute_mode = hashing::ComputeMode::WithEntropy;
159
160 let (hash, merkle_root, avg_entropy, entropy_class, file_simhash) =
161 if hashing::is_hashing_enabled() {
162 if size >= hashing::get_lite_threshold_bytes() {
163 let lite = hashing::hash_file_lite(path, size)?
164 .map(|h| h.to_hex().to_string());
165 let tree = hashing::MerkleTree::from_file(path, chunk_size, compute_mode).ok();
166 let merkle = tree.as_ref().map(|t| t.root.to_hex().to_string());
167
168 let (avg_ent, ent_class) = tree.as_ref()
170 .map(|t| compute_entropy_fields(&t.leaves))
171 .unwrap_or((None, None));
172
173 #[cfg(feature = "similarity")]
175 let sim = tree.as_ref().map(|t| t.file_simhash() as i64);
176 #[cfg(not(feature = "similarity"))]
177 let sim: Option<i64> = None;
178
179 (lite, merkle, avg_ent, ent_class, sim)
180 } else {
181 (None, None, None, None, None)
182 }
183 } else {
184 (None, None, None, None, None)
185 };
186
187 Ok(Self {
188 size,
189 mtime_sec: meta.mtime(),
190 mtime_nsec: meta.mtime_nsec(),
191 hash,
192 merkle_root,
193 chunk_size: Some(chunk_size),
194 leaf_count: None,
195 avg_entropy,
196 entropy_class,
197 file_simhash,
198 version: Self::CURRENT_VERSION,
199 })
200 }
201
202 pub fn compute_from_buffer(data: &[u8], mtime_sec: i64, mtime_nsec: i64) -> Self {
205 let size = data.len() as u64;
206 let (hash, merkle_root) = if hashing::is_hashing_enabled() {
207 if size >= hashing::get_lite_threshold_bytes() {
208 let lite = hashing::hash_buffer_lite(data, size)
209 .map(|h| h.to_hex().to_string());
210 let full = hashing::hash_buffer_full(data)
211 .map(|h| h.to_hex().to_string());
212 (lite, full)
213 } else {
214 (None, None)
215 }
216 } else {
217 (None, None)
218 };
219 Self {
220 size, mtime_sec, mtime_nsec,
221 hash, merkle_root,
222 chunk_size: Some(hashing::calculate_adaptive_chunk_size(size)),
223 leaf_count: None,
224 avg_entropy: None,
225 entropy_class: None,
226 file_simhash: None,
227 version: Self::CURRENT_VERSION,
228 }
229 }
230
231 pub fn serialize(&self) -> Vec<u8> {
233 bincode::serialize(self).unwrap_or_default()
234 }
235
236 pub fn deserialize(data: &[u8]) -> Option<Self> {
238 bincode::deserialize(data).ok()
239 }
240
241 pub fn deserialize_versioned(data: &[u8]) -> Option<Self> {
245 if data.is_empty() {
246 return None;
247 }
248
249 if let Ok(sig) = bincode::deserialize::<SyncSignature>(data)
251 && sig.version == Self::CURRENT_VERSION {
252 return Some(sig);
253 }
254
255 if let Ok(v4) = bincode::deserialize::<SyncSignatureV4>(data)
257 && v4.version == 4 {
258 warn!(
259 "sidecar: migrated SyncSignature from v{} to v{} (entropy/simhash fields not present)",
260 v4.version, Self::CURRENT_VERSION
261 );
262 return Some(SyncSignature {
263 size: v4.size,
264 mtime_sec: v4.mtime_sec,
265 mtime_nsec: v4.mtime_nsec,
266 hash: v4.hash,
267 merkle_root: v4.merkle_root,
268 chunk_size: v4.chunk_size,
269 leaf_count: v4.leaf_count,
270 avg_entropy: None,
271 entropy_class: None,
272 file_simhash: None,
273 version: Self::CURRENT_VERSION,
274 });
275 }
276
277 if let Ok(v3) = bincode::deserialize::<SyncSignatureV3>(data)
279 && v3.version <= 3 {
280 warn!(
281 "sidecar: migrated SyncSignature from v{} to v{} (leaf_count not present)",
282 v3.version, Self::CURRENT_VERSION
283 );
284 return Some(SyncSignature {
285 size: v3.size,
286 mtime_sec: v3.mtime_sec,
287 mtime_nsec: v3.mtime_nsec,
288 hash: v3.hash,
289 merkle_root: v3.merkle_root,
290 chunk_size: v3.chunk_size,
291 leaf_count: None,
292 avg_entropy: None,
293 entropy_class: None,
294 file_simhash: None,
295 version: Self::CURRENT_VERSION,
296 });
297 }
298
299 warn!(
301 "sidecar: failed to deserialize SyncSignature ({} bytes, first byte: 0x{:02x})",
302 data.len(),
303 data[0]
304 );
305 None
306 }
307
308 pub fn matches(&self, other: &Self) -> bool {
310 if self.size != other.size { return false; }
311
312 if self.mtime_sec != other.mtime_sec || self.mtime_nsec != other.mtime_nsec {
317 return false;
318 }
319
320 if let (Some(a), Some(b)) = (&self.merkle_root, &other.merkle_root) { return a == b }
321
322 match (&self.hash, &other.hash) {
323 (Some(a), Some(b)) => a == b,
324 _ => true,
325 }
326 }
327
328 pub fn hash_cid(&self) -> Option<String> {
330 self.hash.as_ref().and_then(|h| crate::cid::blake3_hex_to_cid_string(h).ok())
331 }
332
333 pub fn merkle_root_cid(&self) -> Option<String> {
335 self.merkle_root.as_ref().and_then(|h| crate::cid::blake3_hex_to_cid_string(h).ok())
336 }
337}
338
339fn resolve_key_variants(key: &str) -> (String, String) {
340 if key.starts_with("user.foxing.") {
341 let suffix = key.strip_prefix("user.foxing.").unwrap();
342 (format!("{}{}", NS_TRUSTED_PREFIX, suffix), key.to_string())
343 } else if key.starts_with("trusted.foxing.") {
344 let suffix = key.strip_prefix("trusted.foxing.").unwrap();
345 (key.to_string(), format!("{}{}", NS_USER_PREFIX, suffix))
346 } else {
347 (format!("{}{}", NS_TRUSTED_PREFIX, key), format!("{}{}", NS_USER_PREFIX, key))
348 }
349}
350
351fn is_xattr_unsupported(e: &io::Error) -> bool {
353 if let Some(code) = e.raw_os_error() {
354 code == libc::EOPNOTSUPP || code == libc::ENOTSUP || code == libc::ENOSYS
355 || code == libc::EACCES
356 } else {
357 false
358 }
359}
360
361pub fn get_sidecar_path(target_path: &Path) -> Option<PathBuf> {
365 let file_name = target_path.file_name()?.to_str()?;
366 let sidecar_name = format!(".{}.foxing_meta", file_name);
367 Some(target_path.with_file_name(sidecar_name))
368}
369
370fn lock_file(file: &File, exclusive: bool) -> std::io::Result<()> {
371 let fd = file.as_raw_fd();
372 let op = if exclusive { libc::LOCK_EX } else { libc::LOCK_SH };
373 let ret = unsafe { libc::flock(fd, op) };
375 if ret == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) }
376}
377
378fn unlock_file(file: &File) -> std::io::Result<()> {
379 let fd = file.as_raw_fd();
380 let ret = unsafe { libc::flock(fd, libc::LOCK_UN) };
382 if ret == 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) }
383}
384
385fn sidecar_set(path: &Path, key: &str, value: &[u8]) -> std::io::Result<()> {
386 let sp = match get_sidecar_path(path) {
387 Some(p) => p,
388 None => return Err(io::Error::new(io::ErrorKind::InvalidInput, "Cannot determine sidecar path")),
389 };
390 let (_trusted_key, user_key) = resolve_key_variants(key);
391 let file = fs::OpenOptions::new().read(true).write(true).create(true).open(&sp)?;
392 lock_file(&file, true)?;
393 let mut map: HashMap<String, String> = serde_json::from_reader(&file).unwrap_or_default();
394 map.insert(user_key, hex::encode(value));
395 let tmp_path = {
397 let mut t = sp.as_os_str().to_os_string();
398 t.push(".tmp");
399 std::path::PathBuf::from(t)
400 };
401 {
402 let tmp_file = File::create(&tmp_path)?;
403 serde_json::to_writer(&tmp_file, &map)?;
404 tmp_file.sync_data()?;
405 }
406 std::fs::rename(&tmp_path, &sp)?;
407 unlock_file(&file)?;
408 crate::metrics::SIDECAR_FILES_CREATED.inc();
409 Ok(())
410}
411
412fn sidecar_set_batch(path: &Path, entries: &[(&str, &[u8])]) -> std::io::Result<()> {
413 let sp = match get_sidecar_path(path) {
414 Some(p) => p,
415 None => return Err(io::Error::new(io::ErrorKind::InvalidInput, "Cannot determine sidecar path")),
416 };
417 let file = fs::OpenOptions::new().read(true).write(true).create(true).open(&sp)?;
418 lock_file(&file, true)?;
419 let mut map: HashMap<String, String> = serde_json::from_reader(&file).unwrap_or_default();
420 for &(key, value) in entries {
421 let (_trusted_key, user_key) = resolve_key_variants(key);
422 map.insert(user_key, hex::encode(value));
423 }
424 let tmp_path = {
425 let mut t = sp.as_os_str().to_os_string();
426 t.push(".tmp");
427 std::path::PathBuf::from(t)
428 };
429 {
430 let tmp_file = File::create(&tmp_path)?;
431 serde_json::to_writer(&tmp_file, &map)?;
432 tmp_file.sync_data()?;
433 }
434 std::fs::rename(&tmp_path, &sp)?;
435 unlock_file(&file)?;
436 crate::metrics::SIDECAR_FILES_CREATED.inc();
437 Ok(())
438}
439
440fn sidecar_get(path: &Path, key: &str) -> Option<Vec<u8>> {
441 let sp = get_sidecar_path(path)?;
442 let file = File::open(&sp).ok()?;
443 if lock_file(&file, false).is_err() { return None; }
444 let map: HashMap<String, String> = serde_json::from_reader(&file).unwrap_or_default();
445 let _ = unlock_file(&file);
446 let (trusted_key, user_key) = resolve_key_variants(key);
447 let val_str = map.get(&trusted_key).or_else(|| map.get(&user_key))?;
448 hex::decode(val_str).ok()
449}
450
451fn sidecar_remove(path: &Path, key: &str) -> std::io::Result<()> {
452 let sp = match get_sidecar_path(path) {
453 Some(p) => p,
454 None => return Ok(()),
455 };
456 if !sp.exists() { return Ok(()); }
457 let file = fs::OpenOptions::new().read(true).write(true).open(&sp)?;
458 if lock_file(&file, true).is_err() { return Ok(()); }
459 let mut map: HashMap<String, String> = serde_json::from_reader(&file).unwrap_or_default();
460 let (trusted_key, user_key) = resolve_key_variants(key);
461 let rem1 = map.remove(&trusted_key).is_some();
462 let rem2 = map.remove(&user_key).is_some();
463 if rem1 || rem2 {
464 if map.is_empty() {
465 let _ = fs::remove_file(&sp);
466 } else {
467 let tmp_path = {
468 let mut t = sp.as_os_str().to_os_string();
469 t.push(".tmp");
470 std::path::PathBuf::from(t)
471 };
472 {
473 let tmp_file = File::create(&tmp_path)?;
474 serde_json::to_writer(&tmp_file, &map)?;
475 tmp_file.sync_data()?;
476 }
477 std::fs::rename(&tmp_path, &sp)?;
478 }
479 }
480 let _ = unlock_file(&file);
481 Ok(())
482}
483
484fn try_xattr_set(path: &Path, key: &str, value: &[u8]) -> std::result::Result<(), io::Error> {
489 let (trusted_key, user_key) = resolve_key_variants(key);
490 match xattr::set(path, &trusted_key, value) {
491 Ok(_) => Ok(()),
492 Err(e) => {
493 if is_xattr_unsupported(&e) {
494 match xattr::set(path, &user_key, value) {
496 Ok(_) => {
497 if !FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
498 warn!("Security: 'trusted' xattr namespace unavailable. Using 'user' namespace.");
499 }
500 Ok(())
501 }
502 Err(e2) if is_xattr_unsupported(&e2) => Err(e2),
503 Err(e2) => {
504 if e2.raw_os_error() == Some(libc::EPERM) {
506 Err(e2)
507 } else {
508 warn!("xattr set failed for {:?}: {}, falling back to sidecar", path, e2);
509 Err(e2)
510 }
511 }
512 }
513 } else {
514 if e.raw_os_error() == Some(libc::EPERM) {
516 match xattr::set(path, &user_key, value) {
517 Ok(_) => {
518 if !FALLBACK_WARNED.swap(true, Ordering::Relaxed) {
519 warn!("Security: 'trusted' xattr namespace unavailable. Using 'user' namespace.");
520 }
521 Ok(())
522 }
523 Err(e2) if is_xattr_unsupported(&e2) => Err(e2),
524 Err(e2) => {
525 warn!("xattr set failed for {:?}: {}, falling back to sidecar", path, e2);
526 Err(e2)
527 }
528 }
529 } else {
530 warn!("xattr set failed for {:?}: {}, falling back to sidecar", path, e);
531 Err(e)
532 }
533 }
534 }
535 }
536}
537
538pub fn set_metadata(path: &Path, key: &str, value: &[u8]) -> std::io::Result<()> {
540 if let Ok(meta) = fs::symlink_metadata(path) {
541 if meta.is_symlink() { return Ok(()); }
542 } else { return Ok(()); }
543
544 if XATTR_UNSUPPORTED.load(Ordering::Relaxed) {
546 return sidecar_set(path, key, value);
547 }
548
549 match try_xattr_set(path, key, value) {
550 Ok(()) => Ok(()),
551 Err(_) => {
552 XATTR_UNSUPPORTED.store(true, Ordering::Relaxed);
554 warn!("xattr unsupported on filesystem for {:?}. Falling back to sidecar JSON.", path);
555 sidecar_set(path, key, value)
556 }
557 }
558}
559
560pub fn set_metadata_batch(path: &Path, entries: &[(&str, &[u8])]) -> std::io::Result<()> {
563 if entries.is_empty() { return Ok(()); }
564 if let Ok(meta) = fs::symlink_metadata(path) {
565 if meta.is_symlink() { return Ok(()); }
566 } else { return Ok(()); }
567
568 if XATTR_UNSUPPORTED.load(Ordering::Relaxed) {
569 return sidecar_set_batch(path, entries);
570 }
571
572 for &(key, value) in entries {
573 if let Err(e) = try_xattr_set(path, key, value) {
574 let is_permanent = e.raw_os_error().is_some_and(|errno| {
575 errno == libc::EOPNOTSUPP || errno == libc::ENOTSUP
576 || errno == libc::EPERM || errno == libc::EACCES
577 });
578 if is_permanent {
579 XATTR_UNSUPPORTED.store(true, Ordering::Relaxed);
580 warn!("xattr unsupported on filesystem for {:?} ({}). Falling back to sidecar JSON.", path, e);
581 } else {
582 warn!("xattr write failed for {:?} key={} ({}), falling back to sidecar for this file", path, key, e);
583 }
584 return sidecar_set_batch(path, entries);
585 }
586 }
587 Ok(())
588}
589
590pub fn get_metadata(path: &Path, key: &str) -> Option<Vec<u8>> {
592 if let Ok(meta) = fs::symlink_metadata(path) {
593 if meta.is_symlink() { return None; }
594 } else { return None; }
595
596 if XATTR_UNSUPPORTED.load(Ordering::Relaxed) {
598 return sidecar_get(path, key);
599 }
600
601 let (trusted_key, user_key) = resolve_key_variants(key);
602
603 match xattr::get(path, &trusted_key) {
605 Ok(Some(val)) => return Some(val),
606 Ok(None) => {},
607 Err(_) => {}
608 }
609 match xattr::get(path, &user_key) {
611 Ok(Some(val)) => return Some(val),
612 Ok(None) => {},
613 Err(_) => {}
614 }
615
616 sidecar_get(path, key)
618}
619
620pub fn remove_metadata(path: &Path, key: &str) -> std::io::Result<()> {
622 let (trusted_key, user_key) = resolve_key_variants(key);
623 let trusted_res = xattr::remove(path, &trusted_key);
624 let user_res = xattr::remove(path, &user_key);
625
626 let _ = sidecar_remove(path, key);
628
629 if trusted_res.is_err() && user_res.is_err() {
630 let err = trusted_res.unwrap_err();
631 if err.kind() == io::ErrorKind::NotFound {
632 return Ok(());
633 }
634 if let Some(code) = err.raw_os_error()
635 && (code == libc::EOPNOTSUPP || code == libc::ENOTSUP || code == libc::ENOSYS || code == libc::EPERM || code == libc::ENODATA || code == libc::EPROTONOSUPPORT) {
636 return Ok(());
637 }
638 error!("Failed to remove xattr {}: {}", key, err);
639 return Err(err);
640 }
641 Ok(())
642}
643
644pub fn is_dirty(path: &Path) -> bool {
646 if let Some(val) = get_metadata(path, "dirty") {
647 return val == vec![1];
648 }
649 false
650}
651
652pub fn set_dirty_flag(path: &Path, active: bool, _reason: &str) -> std::io::Result<()> {
654 if active {
655 set_metadata(path, "dirty", &[1])
656 } else {
657 remove_metadata(path, "dirty")
658 }
659}
660
661pub fn set_sync_signature(path: &Path, sig: &SyncSignature) -> std::io::Result<()> {
663 set_metadata(path, "sig", &sig.serialize())
664}
665
666pub fn get_sync_signature(path: &Path) -> Option<SyncSignature> {
668 let raw = get_metadata(path, "sig")?;
669 SyncSignature::deserialize_versioned(&raw)
670}
671
672pub fn set_dir_hash(path: &Path, hash: &[u8; 32]) -> std::io::Result<()> {
674 set_metadata(path, "dir_hash", hash)
675}
676
677pub fn get_dir_hash(path: &Path) -> Option<[u8; 32]> {
679 let bytes = get_metadata(path, "dir_hash")?;
680 if bytes.len() == 32 {
681 let mut arr = [0u8; 32];
682 arr.copy_from_slice(&bytes);
683 Some(arr)
684 } else {
685 None
686 }
687}
688
689pub fn clear_dir_hash(path: &Path) -> std::io::Result<()> {
691 remove_metadata(path, "dir_hash")
692}
693
694pub fn set_merkle_signature(path: &Path, sig: &hashing::MerkleSignature) -> std::io::Result<()> {
696 let data = bincode::serialize(sig)
697 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
698 if data.len() <= crate::constants::MERKLE_XATTR_MAX_BYTES {
699 set_metadata(path, "merkle", &data)
701 } else {
702 set_merkle_in_index(path, sig)
704 }
705}
706
707fn set_merkle_in_index(path: &Path, sig: &hashing::MerkleSignature) -> std::io::Result<()> {
711 if let Some((target_root, rel_path)) = resolve_target_root(path) {
712 crate::merkle_index::set(&target_root, &rel_path, sig)
713 } else {
714 Err(std::io::Error::other(
715 format!("merkle_index: Cannot resolve target root for {:?}", path),
716 ))
717 }
718}
719
720pub fn get_merkle_signature(path: &Path) -> Option<hashing::MerkleSignature> {
722 if let Some(bytes) = get_metadata(path, "merkle") {
724 let sig: hashing::MerkleSignature = bincode::deserialize(&bytes).ok()?;
725 if sig.chunk_size == 0 { return None; }
726 let expected_max = sig.file_size / sig.chunk_size + 2;
727 if sig.leaf_hashes.len() as u64 > expected_max { return None; }
728 return Some(sig);
729 }
730 if let Some((target_root, rel_path)) = resolve_target_root(path) {
732 return crate::merkle_index::get(&target_root, &rel_path);
733 }
734 None
735}
736
737fn resolve_target_root(path: &Path) -> Option<(PathBuf, PathBuf)> {
741 let path = path.canonicalize().ok().unwrap_or_else(|| path.to_path_buf());
742 let mut current = path.parent()?;
743
744 for _ in 0..10 {
746 if current.join(".foxing_versions").exists()
747 || current.join(".foxing_meta").exists()
748 || current.join(".foxing_cas").exists()
749 {
750 let rel = path.strip_prefix(current).ok()?;
751 return Some((current.to_path_buf(), rel.to_path_buf()));
752 }
753 current = current.parent()?;
754 }
755
756 let parent = path.parent()?;
758 let grandparent = parent.parent()?;
759 let rel = path.strip_prefix(grandparent).ok()?;
760 Some((grandparent.to_path_buf(), rel.to_path_buf()))
761}
762
763enum SidecarOp {
764 SetDirty {
765 path: PathBuf,
766 response: Option<oneshot::Sender<io::Result<()>>>,
767 },
768 ClearDirty {
769 path: PathBuf,
770 },
771}
772
773#[derive(Clone)]
775pub struct AsyncSidecar {
776 tx: mpsc::UnboundedSender<SidecarOp>,
777}
778
779impl AsyncSidecar {
780 pub fn new() -> Self {
782 let (tx, mut rx) = mpsc::unbounded_channel::<SidecarOp>();
783 thread::Builder::new()
784 .name("foxing-sidecar-io".into())
785 .spawn(move || {
786 debug!("AsyncSidecar: Background thread started.");
787 while let Some(op) = rx.blocking_recv() {
788 match op {
789 SidecarOp::SetDirty { path, response } => {
790 let res = set_dirty_flag(&path, true, "ASYNC_WRITE");
791 if let Some(resp_tx) = response {
792 let _ = resp_tx.send(res);
793 }
794 },
795 SidecarOp::ClearDirty { path } => {
796 if let Err(e) = set_dirty_flag(&path, false, "ASYNC_CLEAR") {
797 debug!("AsyncSidecar: Failed to clear dirty flag for {:?}: {}", path, e);
798 }
799 }
800 }
801 }
802 debug!("AsyncSidecar: Background thread stopping.");
803 })
804 .expect("Failed to spawn sidecar thread");
805 Self { tx }
806 }
807 pub async fn set_dirty(&self, path: PathBuf) -> io::Result<()> {
809 let (resp_tx, resp_rx) = oneshot::channel();
810 if self.tx.send(SidecarOp::SetDirty { path, response: Some(resp_tx) }).is_err() {
811 return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Sidecar worker dead"));
812 }
813 match resp_rx.await {
814 Ok(res) => res,
815 Err(_) => Err(io::Error::new(io::ErrorKind::BrokenPipe, "Sidecar worker dropped response")),
816 }
817 }
818 pub fn set_dirty_blind(&self, path: PathBuf) {
820 let _ = self.tx.send(SidecarOp::SetDirty { path, response: None });
821 }
822 pub fn clear_dirty(&self, path: PathBuf) {
824 let _ = self.tx.send(SidecarOp::ClearDirty { path });
825 }
826
827 pub fn clear_dirty_on_skip(&self, target_path: PathBuf) {
829 let _ = self.tx.send(SidecarOp::ClearDirty { path: target_path });
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_in_result, clippy::unwrap_used)]
836 use super::*;
837 use tempfile::TempDir;
838 use std::io::Write;
839
840 static TEST_XATTR_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
842
843 #[test]
844 fn test_sidecar_sync_signature_v5_roundtrip() {
845 let sig = SyncSignature {
846 size: 123456,
847 mtime_sec: 1700000000,
848 mtime_nsec: 999999999,
849 hash: Some("abcdef0123456789".to_string()),
850 merkle_root: Some("fedcba9876543210".to_string()),
851 chunk_size: Some(65536),
852 leaf_count: Some(42),
853 avg_entropy: None,
854 entropy_class: None,
855 file_simhash: None,
856 version: SyncSignature::CURRENT_VERSION,
857 };
858
859 let bytes = sig.serialize();
860 assert!(!bytes.is_empty(), "serialize should produce non-empty output");
861
862 let deserialized = SyncSignature::deserialize(&bytes)
863 .expect("deserialize should succeed on valid data");
864
865 assert_eq!(deserialized.size, 123456);
866 assert_eq!(deserialized.mtime_sec, 1700000000);
867 assert_eq!(deserialized.mtime_nsec, 999999999);
868 assert_eq!(deserialized.hash.as_deref(), Some("abcdef0123456789"));
869 assert_eq!(deserialized.merkle_root.as_deref(), Some("fedcba9876543210"));
870 assert_eq!(deserialized.chunk_size, Some(65536));
871 assert_eq!(deserialized.leaf_count, Some(42));
872 assert_eq!(deserialized.version, SyncSignature::CURRENT_VERSION);
873 }
874
875 #[test]
876 fn test_sidecar_sync_signature_v5_roundtrip_none_fields() {
877 let sig = SyncSignature {
878 size: 0,
879 mtime_sec: 0,
880 mtime_nsec: 0,
881 hash: None,
882 merkle_root: None,
883 chunk_size: None,
884 leaf_count: None,
885 avg_entropy: None,
886 entropy_class: None,
887 file_simhash: None,
888 version: SyncSignature::CURRENT_VERSION,
889 };
890
891 let bytes = sig.serialize();
892 let deserialized = SyncSignature::deserialize(&bytes).expect("roundtrip with None fields");
893 assert_eq!(deserialized.size, 0);
894 assert!(deserialized.hash.is_none());
895 assert!(deserialized.merkle_root.is_none());
896 assert!(deserialized.chunk_size.is_none());
897 assert!(deserialized.leaf_count.is_none());
898 }
899
900 #[test]
901 fn test_sidecar_sync_signature_compute_on_file() {
902 let dir = TempDir::new().unwrap();
903 let file_path = dir.path().join("testfile.bin");
904 {
905 let mut f = File::create(&file_path).unwrap();
906 f.write_all(&[0xAA; 4096]).unwrap();
907 }
908
909 let sig = SyncSignature::compute(&file_path).expect("compute should succeed");
910 assert_eq!(sig.size, 4096);
911 assert_ne!(sig.mtime_sec, 0, "mtime_sec should be non-zero for a real file");
912 assert_eq!(sig.version, SyncSignature::CURRENT_VERSION);
913 }
914
915 #[test]
916 fn test_sidecar_sync_signature_matches_equal() {
917 let sig1 = SyncSignature {
918 size: 1000,
919 mtime_sec: 1700000000,
920 mtime_nsec: 500,
921 hash: Some("abc123".to_string()),
922 merkle_root: None,
923 chunk_size: None,
924 leaf_count: None,
925 avg_entropy: None,
926 entropy_class: None,
927 file_simhash: None,
928 version: 5,
929 };
930 let sig2 = sig1.clone();
931 assert!(sig1.matches(&sig2), "identical signatures should match");
932 }
933
934 #[test]
935 fn test_sidecar_sync_signature_matches_different_size() {
936 let sig1 = SyncSignature {
937 size: 1000,
938 mtime_sec: 1700000000,
939 mtime_nsec: 500,
940 hash: None,
941 merkle_root: None,
942 chunk_size: None,
943 leaf_count: None,
944 avg_entropy: None,
945 entropy_class: None,
946 file_simhash: None,
947 version: 5,
948 };
949 let sig2 = SyncSignature {
950 size: 2000,
951 ..sig1.clone()
952 };
953 assert!(!sig1.matches(&sig2), "different sizes should not match");
954 }
955
956 #[test]
957 fn test_sidecar_sync_signature_matches_merkle_root_priority() {
958 let sig1 = SyncSignature {
961 size: 1000,
962 mtime_sec: 100,
963 mtime_nsec: 0,
964 hash: None,
965 merkle_root: Some("same_root".to_string()),
966 chunk_size: None,
967 leaf_count: None,
968 avg_entropy: None,
969 entropy_class: None,
970 file_simhash: None,
971 version: 5,
972 };
973 let sig2 = SyncSignature {
974 mtime_sec: 200, ..sig1.clone()
976 };
977 assert!(!sig1.matches(&sig2), "different mtime must not match even with same merkle_root");
979
980 let sig2b = SyncSignature {
982 mtime_sec: 100, ..sig1.clone()
984 };
985 assert!(sig1.matches(&sig2b), "same mtime + same merkle_root should match");
986
987 let sig3 = SyncSignature {
988 merkle_root: Some("different_root".to_string()),
989 ..sig1.clone()
990 };
991 assert!(!sig1.matches(&sig3), "different merkle_root should not match");
992 }
993
994 #[test]
995 fn test_sidecar_sync_signature_deserialize_empty() {
996 let result = SyncSignature::deserialize(&[]);
997 assert!(result.is_none(), "deserialize of empty slice should return None");
998 }
999
1000 #[test]
1001 fn test_sidecar_sync_signature_deserialize_garbage() {
1002 let result = SyncSignature::deserialize(&[0xFF, 0x00, 0x42, 0x99]);
1003 assert!(result.is_none(), "deserialize of garbage should return None");
1004 }
1005
1006 #[test]
1007 fn test_sidecar_set_get_sync_signature_roundtrip() {
1008 let dir = TempDir::new().unwrap();
1009 let file_path = dir.path().join("sigtest.txt");
1010 {
1011 let mut f = File::create(&file_path).unwrap();
1012 f.write_all(b"signature roundtrip test").unwrap();
1013 }
1014
1015 let sig = SyncSignature {
1016 size: 23,
1017 mtime_sec: 1700000000,
1018 mtime_nsec: 123456789,
1019 hash: Some("deadbeef".to_string()),
1020 merkle_root: None,
1021 chunk_size: Some(4096),
1022 leaf_count: None,
1023 avg_entropy: None,
1024 entropy_class: None,
1025 file_simhash: None,
1026 version: SyncSignature::CURRENT_VERSION,
1027 };
1028
1029 match set_sync_signature(&file_path, &sig) {
1032 Ok(()) => {}
1033 Err(e) => {
1034 eprintln!("set_sync_signature failed (expected on some filesystems): {}", e);
1035 return; }
1037 }
1038
1039 let retrieved = get_sync_signature(&file_path);
1040 let retrieved = match retrieved {
1041 Some(s) => s,
1042 None => {
1043 eprintln!("get_sync_signature returned None -- skipping on this filesystem");
1044 return;
1045 }
1046 };
1047
1048 assert_eq!(retrieved.size, sig.size);
1049 assert_eq!(retrieved.mtime_sec, sig.mtime_sec);
1050 assert_eq!(retrieved.mtime_nsec, sig.mtime_nsec);
1051 assert_eq!(retrieved.hash, sig.hash);
1052 assert_eq!(retrieved.chunk_size, sig.chunk_size);
1053 assert_eq!(retrieved.version, sig.version);
1054 }
1055
1056 #[test]
1057 fn test_sidecar_set_get_metadata_generic_api() {
1058 let dir = TempDir::new().unwrap();
1059 let file_path = dir.path().join("meta_api_test.txt");
1060 {
1061 let mut f = File::create(&file_path).unwrap();
1062 f.write_all(b"metadata api test").unwrap();
1063 }
1064
1065 let key = "user.foxing.test_key";
1066 let value = b"test_value_bytes";
1067
1068 match set_metadata(&file_path, key, value) {
1069 Ok(()) => {}
1070 Err(e) => {
1071 eprintln!("set_metadata failed (expected on some filesystems): {}", e);
1072 return;
1073 }
1074 }
1075
1076 let retrieved = get_metadata(&file_path, key);
1077 match retrieved {
1078 Some(v) => assert_eq!(v, value, "get_metadata should return the value we set"),
1079 None => {
1080 eprintln!("get_metadata returned None -- skipping on this filesystem");
1081 }
1082 }
1083 }
1084
1085 #[test]
1086 fn test_sidecar_sync_signature_version_field_is_5() {
1087 let dir = TempDir::new().unwrap();
1088 let file_path = dir.path().join("version_check.bin");
1089 {
1090 let mut f = File::create(&file_path).unwrap();
1091 f.write_all(&[0x55; 128]).unwrap();
1092 }
1093
1094 let sig = SyncSignature::compute(&file_path).expect("compute should succeed");
1095 assert_eq!(SyncSignature::CURRENT_VERSION, 5, "CURRENT_VERSION constant should be 5");
1096 assert_eq!(sig.version, SyncSignature::CURRENT_VERSION,
1097 "computed signature version should equal CURRENT_VERSION");
1098 }
1099
1100 #[test]
1101 fn test_sidecar_get_sidecar_path() {
1102 let p = Path::new("/some/dir/myfile.txt");
1103 let sp = get_sidecar_path(p).expect("should produce sidecar path");
1104 assert_eq!(sp, PathBuf::from("/some/dir/.myfile.txt.foxing_meta"));
1105 }
1106
1107 #[test]
1108 fn test_sidecar_resolve_key_variants() {
1109 let (trusted, user) = resolve_key_variants("user.foxing.sig");
1111 assert_eq!(trusted, "trusted.foxing.sig");
1112 assert_eq!(user, "user.foxing.sig");
1113
1114 let (trusted, user) = resolve_key_variants("trusted.foxing.dirty");
1116 assert_eq!(trusted, "trusted.foxing.dirty");
1117 assert_eq!(user, "user.foxing.dirty");
1118
1119 let (trusted, user) = resolve_key_variants("sig");
1121 assert_eq!(trusted, "trusted.foxing.sig");
1122 assert_eq!(user, "user.foxing.sig");
1123 }
1124
1125 #[test]
1126 fn test_sync_signature_v3_migration() {
1127 let v3 = SyncSignatureV3 {
1128 size: 999,
1129 mtime_sec: 12345,
1130 mtime_nsec: 678,
1131 hash: Some("aabbcc".to_string()),
1132 merkle_root: None,
1133 chunk_size: Some(65536),
1134 version: 3,
1135 };
1136 let v3_bytes = bincode::serialize(&v3).expect("v3 serialize");
1137
1138 let migrated = SyncSignature::deserialize_versioned(&v3_bytes)
1139 .expect("v3 migration should succeed");
1140
1141 assert_eq!(migrated.size, 999);
1142 assert_eq!(migrated.mtime_sec, 12345);
1143 assert_eq!(migrated.hash.as_deref(), Some("aabbcc"));
1144 assert_eq!(migrated.leaf_count, None);
1145 assert_eq!(migrated.avg_entropy, None);
1146 assert_eq!(migrated.entropy_class, None);
1147 assert_eq!(migrated.file_simhash, None);
1148 assert_eq!(migrated.version, SyncSignature::CURRENT_VERSION);
1149 }
1150
1151 #[test]
1152 fn test_sync_signature_v4_backward_compat() {
1153 let v4 = SyncSignatureV4 {
1154 size: 4096,
1155 mtime_sec: 1700000000,
1156 mtime_nsec: 500,
1157 hash: Some("deadbeef".to_string()),
1158 merkle_root: Some("cafebabe".to_string()),
1159 chunk_size: Some(65536),
1160 leaf_count: Some(10),
1161 version: 4,
1162 };
1163 let v4_bytes = bincode::serialize(&v4).expect("v4 serialize");
1164
1165 let migrated = SyncSignature::deserialize_versioned(&v4_bytes)
1166 .expect("v4 backward compat migration should succeed");
1167
1168 assert_eq!(migrated.size, 4096);
1169 assert_eq!(migrated.mtime_sec, 1700000000);
1170 assert_eq!(migrated.mtime_nsec, 500);
1171 assert_eq!(migrated.hash.as_deref(), Some("deadbeef"));
1172 assert_eq!(migrated.merkle_root.as_deref(), Some("cafebabe"));
1173 assert_eq!(migrated.chunk_size, Some(65536));
1174 assert_eq!(migrated.leaf_count, Some(10));
1175 assert_eq!(migrated.avg_entropy, None);
1176 assert_eq!(migrated.entropy_class, None);
1177 assert_eq!(migrated.file_simhash, None);
1178 assert_eq!(migrated.version, SyncSignature::CURRENT_VERSION);
1179 }
1180
1181 #[test]
1182 fn test_sync_signature_v5_roundtrip_with_entropy_simhash() {
1183 let sig = SyncSignature {
1184 size: 1048576,
1185 mtime_sec: 1700000000,
1186 mtime_nsec: 42,
1187 hash: Some("abc123".to_string()),
1188 merkle_root: Some("def456".to_string()),
1189 chunk_size: Some(131072),
1190 leaf_count: Some(8),
1191 avg_entropy: Some(7.85),
1192 entropy_class: Some("high".to_string()),
1193 file_simhash: Some(-1234567890_i64),
1194 version: SyncSignature::CURRENT_VERSION,
1195 };
1196
1197 let bytes = sig.serialize();
1198 let deserialized = SyncSignature::deserialize_versioned(&bytes)
1199 .expect("v5 roundtrip should succeed");
1200
1201 assert_eq!(deserialized.size, 1048576);
1202 assert_eq!(deserialized.mtime_sec, 1700000000);
1203 assert_eq!(deserialized.hash.as_deref(), Some("abc123"));
1204 assert_eq!(deserialized.merkle_root.as_deref(), Some("def456"));
1205 assert_eq!(deserialized.chunk_size, Some(131072));
1206 assert_eq!(deserialized.leaf_count, Some(8));
1207 assert_eq!(deserialized.avg_entropy, Some(7.85));
1208 assert_eq!(deserialized.entropy_class.as_deref(), Some("high"));
1209 assert_eq!(deserialized.file_simhash, Some(-1234567890_i64));
1210 assert_eq!(deserialized.version, SyncSignature::CURRENT_VERSION);
1211 }
1212
1213 #[test]
1214 fn test_sidecar_set_atomic_write() {
1215 let _guard = TEST_XATTR_MUTEX.lock().unwrap();
1216 let dir = TempDir::new().unwrap();
1217 let file_path = dir.path().join("atomic_test.txt");
1218 File::create(&file_path).unwrap();
1219 XATTR_UNSUPPORTED.store(true, std::sync::atomic::Ordering::Relaxed);
1220
1221 set_metadata(&file_path, "user.foxing.test", b"hello").unwrap();
1222
1223 let sp = get_sidecar_path(&file_path).unwrap();
1224 assert!(sp.exists(), ".foxing_meta should exist");
1225 let content = std::fs::read_to_string(&sp).unwrap();
1226 let _: std::collections::HashMap<String, String> = serde_json::from_str(&content)
1227 .expect("sidecar must be valid JSON");
1228
1229 let mut tmp = sp.as_os_str().to_os_string();
1230 tmp.push(".tmp");
1231 assert!(!std::path::PathBuf::from(&tmp).exists(), "tmp file should not remain");
1232
1233 XATTR_UNSUPPORTED.store(false, std::sync::atomic::Ordering::Relaxed);
1234 }
1235
1236 #[test]
1237 fn test_sidecar_set_survives_concurrent_read() {
1238 let _guard = TEST_XATTR_MUTEX.lock().unwrap();
1239 let dir = TempDir::new().unwrap();
1240 let file_path = dir.path().join("concurrent.txt");
1241 File::create(&file_path).unwrap();
1242 XATTR_UNSUPPORTED.store(true, std::sync::atomic::Ordering::Relaxed);
1243 set_metadata(&file_path, "user.foxing.init", b"seed").unwrap();
1244
1245 let sp = get_sidecar_path(&file_path).unwrap();
1246 let sp2 = sp.clone();
1247 let errors = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1248 let errors2 = errors.clone();
1249
1250 let reader = std::thread::spawn(move || {
1251 for _ in 0..100 {
1252 if let Ok(content) = std::fs::read_to_string(&sp2)
1253 && !content.is_empty()
1254 && serde_json::from_str::<std::collections::HashMap<String, String>>(&content).is_err() {
1255 errors2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1256 }
1257 std::thread::sleep(std::time::Duration::from_micros(100));
1258 }
1259 });
1260
1261 let fp2 = file_path.clone();
1262 let writer = std::thread::spawn(move || {
1263 for i in 0..100 {
1264 let key = format!("user.foxing.key{}", i);
1265 let _ = set_metadata(&fp2, &key, b"value");
1266 }
1267 });
1268
1269 writer.join().unwrap();
1270 reader.join().unwrap();
1271 assert_eq!(errors.load(std::sync::atomic::Ordering::Relaxed), 0,
1272 "reader should never see partial/invalid JSON");
1273 XATTR_UNSUPPORTED.store(false, std::sync::atomic::Ordering::Relaxed);
1274 }
1275
1276 #[test]
1277 fn test_is_xattr_unsupported_classification() {
1278 let enotsup = io::Error::from_raw_os_error(libc::ENOTSUP);
1279 assert!(is_xattr_unsupported(&enotsup), "ENOTSUP should be xattr unsupported");
1280
1281 let eopnotsupp = io::Error::from_raw_os_error(libc::EOPNOTSUPP);
1282 assert!(is_xattr_unsupported(&eopnotsupp), "EOPNOTSUPP should be xattr unsupported");
1283
1284 let enosys = io::Error::from_raw_os_error(libc::ENOSYS);
1285 assert!(is_xattr_unsupported(&enosys), "ENOSYS should be xattr unsupported");
1286
1287 let eperm = io::Error::from_raw_os_error(libc::EPERM);
1288 assert!(!is_xattr_unsupported(&eperm), "EPERM should NOT be xattr unsupported");
1289
1290 let eio = io::Error::from_raw_os_error(libc::EIO);
1291 assert!(!is_xattr_unsupported(&eio), "EIO should NOT be xattr unsupported");
1292
1293 let eacces = io::Error::from_raw_os_error(libc::EACCES);
1294 assert!(is_xattr_unsupported(&eacces), "EACCES should be xattr unsupported");
1295
1296 let custom = io::Error::other("custom error");
1297 assert!(!is_xattr_unsupported(&custom), "custom error should NOT be xattr unsupported");
1298 }
1299
1300 #[test]
1301 fn test_eperm_does_not_disable_xattr_globally() {
1302 let _guard = TEST_XATTR_MUTEX.lock().unwrap();
1303 XATTR_UNSUPPORTED.store(false, Ordering::Relaxed);
1304 FALLBACK_WARNED.store(false, Ordering::Relaxed);
1305
1306 let dev_null = Path::new("/dev/null");
1312 let _result = set_metadata_batch(dev_null, &[("user.foxing.test", b"value" as &[u8])]);
1313 assert!(
1314 XATTR_UNSUPPORTED.load(Ordering::Relaxed),
1315 "EPERM on /dev/null should trigger sidecar fallback (XATTR_UNSUPPORTED=true)"
1316 );
1317
1318 XATTR_UNSUPPORTED.store(false, Ordering::Relaxed);
1319
1320 let _result2 = set_metadata(dev_null, "user.foxing.test", b"value");
1321 assert!(
1322 XATTR_UNSUPPORTED.load(Ordering::Relaxed),
1323 "EPERM on /dev/null in set_metadata should trigger sidecar fallback"
1324 );
1325
1326 XATTR_UNSUPPORTED.store(false, Ordering::Relaxed);
1327 }
1328
1329 #[test]
1330 fn test_sync_signature_merkle_root_matches_merkle_tree() {
1331 hashing::set_hashing_enabled(true);
1332 hashing::set_lite_threshold_kb(0);
1333
1334 let dir = TempDir::new().unwrap();
1335 let file_path = dir.path().join("merkle_root_test.bin");
1336 let file_data = vec![0xABu8; 2 * 1024 * 1024];
1337 let mut f = std::fs::File::create(&file_path).unwrap();
1338 f.write_all(&file_data).unwrap();
1339 drop(f);
1340
1341 let sig = SyncSignature::compute(&file_path)
1342 .expect("compute should succeed");
1343 let chunk_size = hashing::calculate_adaptive_chunk_size(2 * 1024 * 1024);
1344 let tree = hashing::MerkleTree::from_file(&file_path, chunk_size, hashing::ComputeMode::Blake3Only)
1345 .expect("MerkleTree::from_file should succeed");
1346 let expected_root = tree.root.to_hex().to_string();
1347
1348 assert_eq!(
1349 sig.merkle_root.as_deref(),
1350 Some(expected_root.as_str()),
1351 );
1352 }
1353
1354 #[test]
1355 fn test_hash_cid_returns_valid_cid() {
1356 let sig = SyncSignature {
1357 size: 100,
1358 mtime_sec: 1700000000,
1359 mtime_nsec: 0,
1360 hash: Some("af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262".to_string()),
1361 merkle_root: None,
1362 chunk_size: None,
1363 leaf_count: None,
1364 avg_entropy: None,
1365 entropy_class: None,
1366 file_simhash: None,
1367 version: 5,
1368 };
1369 let cid = sig.hash_cid().expect("hash_cid should return Some for valid hex");
1370 assert!(cid.starts_with("b"), "CID should start with 'b' multibase prefix");
1371 let decoded_hex = crate::cid::cid_string_to_hex(&cid).unwrap();
1372 assert_eq!(decoded_hex, sig.hash.unwrap());
1373 }
1374
1375 #[test]
1376 fn test_merkle_root_cid_returns_valid_cid() {
1377 let sig = SyncSignature {
1378 size: 100,
1379 mtime_sec: 1700000000,
1380 mtime_nsec: 0,
1381 hash: None,
1382 merkle_root: Some("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string()),
1383 chunk_size: None,
1384 leaf_count: None,
1385 avg_entropy: None,
1386 entropy_class: None,
1387 file_simhash: None,
1388 version: 5,
1389 };
1390 let cid = sig.merkle_root_cid().expect("merkle_root_cid should return Some");
1391 assert!(cid.starts_with("b"));
1392 }
1393
1394 #[test]
1395 fn test_hash_cid_returns_none_when_hash_is_none() {
1396 let sig = SyncSignature {
1397 size: 0,
1398 mtime_sec: 0,
1399 mtime_nsec: 0,
1400 hash: None,
1401 merkle_root: None,
1402 chunk_size: None,
1403 leaf_count: None,
1404 avg_entropy: None,
1405 entropy_class: None,
1406 file_simhash: None,
1407 version: 5,
1408 };
1409 assert!(sig.hash_cid().is_none());
1410 }
1411
1412 #[test]
1413 fn test_hash_cid_returns_none_for_invalid_hex() {
1414 let sig = SyncSignature {
1415 size: 0,
1416 mtime_sec: 0,
1417 mtime_nsec: 0,
1418 hash: Some("not-valid-hex".to_string()),
1419 merkle_root: None,
1420 chunk_size: None,
1421 leaf_count: None,
1422 avg_entropy: None,
1423 entropy_class: None,
1424 file_simhash: None,
1425 version: 5,
1426 };
1427 assert!(sig.hash_cid().is_none());
1428 }
1429}