Skip to main content

fxcp_core/
sidecar.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! Xattr/sidecar metadata  --  SyncSignature, MerkleSignature, dir_hash.
5//!
6//! Metadata/dirty-flag management via xattr (primary) with JSON sidecar fallback.
7// Architecture: xattrs are the canonical store. Sidecar JSON files are ONLY
8// used as a fallback for filesystems that lack xattr support (exfat, vfat, etc.).
9#![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);
28/// Set once xattr is confirmed unsupported  --  all subsequent writes go to sidecar
29static XATTR_UNSUPPORTED: AtomicBool = AtomicBool::new(false);
30
31// --- foxing xattr key namespace ---
32
33/// xattr key for similarity metadata (SimHash, entropy class).
34pub const XATTR_SIMILARITY: &str = "user.foxing.sim";
35/// xattr key for file-level similarity fingerprint.
36pub const XATTR_FILE_SIMILARITY: &str = "user.foxing.fsim";
37/// xattr key for embedding metadata (model, dimensions, chunk count).
38pub const XATTR_EMBEDDING: &str = "user.foxing.emb";
39/// xattr key for LLM enrichment data (Dublin Core, APP envelope).
40pub const XATTR_ENRICHMENT: &str = "user.foxing.enriched";
41/// xattr key for extracted media metadata (EXIF, ID3, PDF info).
42pub const XATTR_MEDIA_META: &str = "user.foxing.media_meta";
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45/// File sync signature stored in xattr/sidecar for change detection
46pub struct SyncSignature {
47    /// File size in bytes
48    pub size: u64,
49    /// Modification time (seconds since epoch)
50    pub mtime_sec: i64,
51    /// Modification time nanosecond component
52    pub mtime_nsec: i64,
53    /// Hex-encoded BLAKE3 lite hash (head+tail sampling)
54    pub hash: Option<String>,
55    /// Hex-encoded BLAKE3 full Merkle root
56    pub merkle_root: Option<String>,
57    /// Chunk size used for Merkle tree construction
58    pub chunk_size: Option<u64>,
59    /// Number of Merkle leaves (v4+)
60    pub leaf_count: Option<u32>,
61    /// Average Shannon entropy across all chunks (v5+, 0.0-8.0 bits/byte)
62    #[serde(default)]
63    pub avg_entropy: Option<f32>,
64    /// Entropy classification label (v5+, e.g. "low", "medium", "high", "encrypted")
65    #[serde(default)]
66    pub entropy_class: Option<String>,
67    /// SimHash-64 whole-file fingerprint stored as i64 for pgvector BIGINT compat (v5+)
68    #[serde(default)]
69    pub file_simhash: Option<i64>,
70    /// Signature schema version
71    pub version: u8,
72}
73
74/// V4 layout: identical to SyncSignature but without avg_entropy, entropy_class, file_simhash.
75/// Used ONLY for deserializing legacy xattr blobs from pre-v5 deployments.
76#[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/// V3 layout: identical to SyncSignature but without leaf_count.
89/// Used ONLY for deserializing legacy xattr blobs from pre-v4 deployments.
90#[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
101/// Compute byte-weighted average entropy and entropy class label from Merkle leaves.
102///
103/// Entropy bands follow ADR S4 thresholds: Sparse (<0.5), Text (<5.5),
104/// Binary (<7.0), Compressed (<7.5), Encrypted (>=7.5). Dominant band must
105/// exceed 66% of total bytes, otherwise "mixed".
106fn 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    /// Compute a sync signature from a file's current metadata and content hashes
150    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                    // Compute byte-weighted average entropy from Merkle leaves
169                    let (avg_ent, ent_class) = tree.as_ref()
170                        .map(|t| compute_entropy_fields(&t.leaves))
171                        .unwrap_or((None, None));
172
173                    // Compute file-level SimHash (similarity feature only)
174                    #[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    /// Compute a signature from an in-memory buffer. Used by the NFS compound
203    /// bypass path where file data is already in memory.
204    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    /// Serialize to bincode bytes for xattr storage
232    pub fn serialize(&self) -> Vec<u8> {
233        bincode::serialize(self).unwrap_or_default()
234    }
235
236    /// Deserialize from bincode bytes (returns None on invalid data)
237    pub fn deserialize(data: &[u8]) -> Option<Self> {
238        bincode::deserialize(data).ok()
239    }
240
241    /// Version-aware deserialization. Handles v5 (current), v4 (missing
242    /// entropy/simhash fields), v3 (missing leaf_count), and unknown/corrupt
243    /// blobs (returns None with warn! log).
244    pub fn deserialize_versioned(data: &[u8]) -> Option<Self> {
245        if data.is_empty() {
246            return None;
247        }
248
249        // Try v5 first (current format)
250        if let Ok(sig) = bincode::deserialize::<SyncSignature>(data)
251            && sig.version == Self::CURRENT_VERSION {
252                return Some(sig);
253            }
254
255        // Try v4 (without avg_entropy, entropy_class, file_simhash)
256        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        // Try v3 (without leaf_count)
278        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        // Unknown format
300        warn!(
301            "sidecar: failed to deserialize SyncSignature ({} bytes, first byte: 0x{:02x})",
302            data.len(),
303            data[0]
304        );
305        None
306    }
307
308    /// Check if two signatures represent the same file state (merkle_root takes priority over mtime)
309    pub fn matches(&self, other: &Self) -> bool {
310        if self.size != other.size { return false; }
311
312        // BUGFIX BUG-007: Verify mtime before trusting merkle_root comparison.
313        // A file modified in-place (same size, conv=notrunc dd) after its sig was
314        // stored will have a newer mtime on the source but the same size.
315        // Without this check, the merkle_root early-return silently skips it.
316        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    /// Return the lite hash as a CID string (computed on demand from hex).
329    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    /// Return the Merkle root as a CID string (computed on demand from hex).
334    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
351/// Returns true if the error indicates xattrs are not supported on this filesystem.
352fn 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
361// --- Sidecar fallback (only for filesystems without xattr support) ---
362
363/// Compute the `.foxing_meta` sidecar path for a given file
364pub 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    // SAFETY: fd is a valid open file descriptor from file.as_raw_fd().
374    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    // SAFETY: fd is a valid open file descriptor from file.as_raw_fd().
381    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    // Atomic write: tmp -> fsync -> rename
396    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
484// --- Public API ---
485
486/// Try xattr set (trusted then user namespace). Returns Ok if xattr worked,
487/// Err if xattr unsupported and caller should use sidecar fallback.
488fn 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                // trusted namespace not available (unprivileged)  --  try user namespace
495                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                        // EPERM on user namespace likely means filesystem doesn't support xattrs at all
505                        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                // EPERM on trusted  --  expected for non-root, try user namespace
515                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
538/// Store a metadata key-value pair via xattr (primary) or JSON sidecar (fallback)
539pub 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    // Fast path: if we already know xattr is unsupported, go straight to sidecar
545    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 not supported on this filesystem  --  mark and use sidecar
553            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
560/// Write multiple metadata keys. Uses xattr per-key (kernel can't batch),
561/// but avoids sidecar entirely unless xattr is unsupported.
562pub 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
590/// Retrieve a metadata value by key from xattr or sidecar fallback
591pub 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    // Fast path: if xattr is known-unsupported, go straight to sidecar
597    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    // Try trusted xattr first
604    match xattr::get(path, &trusted_key) {
605        Ok(Some(val)) => return Some(val),
606        Ok(None) => {},
607        Err(_) => {}
608    }
609    // Try user xattr
610    match xattr::get(path, &user_key) {
611        Ok(Some(val)) => return Some(val),
612        Ok(None) => {},
613        Err(_) => {}
614    }
615
616    // Fallback: check sidecar (for migration from old dual-write or xattr-less fs)
617    sidecar_get(path, key)
618}
619
620/// Remove a metadata key from both xattr namespaces and sidecar
621pub 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    // Also clean up any legacy sidecar entry
627    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
644/// Check if a file has the dirty flag set (indicates an in-progress copy)
645pub 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
652/// Set or clear the dirty flag on a file
653pub 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
661/// Store a sync signature in the file's xattr/sidecar metadata
662pub fn set_sync_signature(path: &Path, sig: &SyncSignature) -> std::io::Result<()> {
663    set_metadata(path, "sig", &sig.serialize())
664}
665
666/// Retrieve the stored sync signature for a file
667pub fn get_sync_signature(path: &Path) -> Option<SyncSignature> {
668    let raw = get_metadata(path, "sig")?;
669    SyncSignature::deserialize_versioned(&raw)
670}
671
672/// Store a directory-level Merkle hash via xattr on the target directory.
673pub fn set_dir_hash(path: &Path, hash: &[u8; 32]) -> std::io::Result<()> {
674    set_metadata(path, "dir_hash", hash)
675}
676
677/// Retrieve a stored directory hash from xattr.
678pub 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
689/// Clear a stored directory hash (invalidation).
690pub fn clear_dir_hash(path: &Path) -> std::io::Result<()> {
691    remove_metadata(path, "dir_hash")
692}
693
694/// Store a Merkle signature in xattr (small files) or per-target index (large files)
695pub 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        // Fits in xattr  --  fast path
700        set_metadata(path, "merkle", &data)
701    } else {
702        // Too large for xattr  --  store in per-target Merkle index
703        set_merkle_in_index(path, sig)
704    }
705}
706
707/// Store large Merkle signature in the per-target index.
708/// Resolves the target root from the file path by walking up to find .foxing_meta
709/// or the mount point.
710fn 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
720/// Retrieve a stored Merkle signature from xattr or per-target index
721pub fn get_merkle_signature(path: &Path) -> Option<hashing::MerkleSignature> {
722    // Try xattr first (fast, covers small files)
723    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    // Fall back to per-target Merkle index (large files)
731    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
737/// Walk up from a file path to find the target root directory.
738/// Heuristic: look for `.foxing_versions`, `.foxing_meta`, or `.foxing_cas` marker.
739/// Falls back to 2 levels up (target/subdir/file).
740fn 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    // Walk up looking for foxing metadata directories
745    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    // Fallback: assume parent of parent is target root
757    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/// Background thread for non-blocking dirty flag I/O
774#[derive(Clone)]
775pub struct AsyncSidecar {
776    tx: mpsc::UnboundedSender<SidecarOp>,
777}
778
779impl AsyncSidecar {
780    /// Spawn the background sidecar I/O thread
781    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    /// Set the dirty flag asynchronously and wait for confirmation
808    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    /// Set the dirty flag without waiting for completion (fire-and-forget)
819    pub fn set_dirty_blind(&self, path: PathBuf) {
820        let _ = self.tx.send(SidecarOp::SetDirty { path, response: None });
821    }
822    /// Clear the dirty flag asynchronously
823    pub fn clear_dirty(&self, path: PathBuf) {
824        let _ = self.tx.send(SidecarOp::ClearDirty { path });
825    }
826
827    /// Clear the dirty flag when a file is skipped (already in sync)
828    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    /// Mutex to serialize tests that mutate the global `XATTR_UNSUPPORTED` flag.
841    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        // BUG-007: mtime is checked BEFORE merkle_root. A file modified in-place
959        // (same size, different mtime) must NOT match even if merkle_root is identical.
960        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, // different mtime
975            ..sig1.clone()
976        };
977        // Different mtime -> no match, even with same merkle_root (BUG-007 fix)
978        assert!(!sig1.matches(&sig2), "different mtime must not match even with same merkle_root");
979
980        // Same mtime + same merkle_root -> match
981        let sig2b = SyncSignature {
982            mtime_sec: 100, // same mtime
983            ..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        // set_sync_signature may fail if xattr is unsupported (e.g. tmpfs in some CI)
1030        // In that case it falls back to sidecar JSON, which should also work
1031        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; // skip rest of test
1036            }
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        // user.foxing.X -> (trusted.foxing.X, user.foxing.X)
1110        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        // trusted.foxing.X -> (trusted.foxing.X, user.foxing.X)
1115        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        // bare key -> both prefixed
1120        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        // After sidecar fallback fix: EPERM on /dev/null (device node where
1307        // both trusted.* and user.* xattr writes fail) now correctly propagates
1308        // as Err from try_xattr_set, triggering sidecar fallback via set_metadata.
1309        // This sets XATTR_UNSUPPORTED = true, which is the intended behavior —
1310        // any unrecoverable xattr error should trigger sidecar fallback.
1311        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}