Skip to main content

blit_fssync/
lib.rs

1//! Filesystem state sync engine (docs/fs-watch.md).
2//!
3//! The server side of `FEATURE_FS`, split in two:
4//!
5//! - A **shared root** per watched `(path, recursive, cross_filesystem)`,
6//!   refcounted across every sync of that root on every connection: one
7//!   native watcher, one hint-driven reconciler owning the canonical
8//!   metadata index, publishing immutable `Arc<Index>` snapshots.
9//! - A **per-sync engine** holding only client state: the shadow snapshot
10//!   (what the client holds), the held-content map for delta bases, the
11//!   ack window, and staged `RESET … SYNC` update assembly.
12//!
13//! Content flows through the process-wide content-addressed blob store:
14//! once any sync reads and hashes a file, the reconciler adopts the hash
15//! and every other sync serves those bytes from memory. Native backends
16//! deliver *hints* (a path may have changed / rescan everything); all
17//! protocol-visible behavior lives here, so the three platforms behave
18//! identically by construction.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
23use std::sync::{Arc, Mutex, OnceLock};
24use std::time::{Duration, Instant, SystemTime};
25use std::{fs, io};
26
27use blit_remote::fs::{
28    FS_CLOSED_CLIENT_REQUEST, FS_CLOSED_RESOURCE_LIMIT, FS_CLOSED_ROOT_GONE, FS_DONE_CONFLICT,
29    FS_DONE_INVALID, FS_DONE_NOT_FOUND, FS_DONE_OK, FS_DONE_OTHER, FS_DONE_PERMISSION,
30    FS_DONE_TOO_LARGE, FS_DONE_WRONG_TYPE, FS_ENTRY_DIR, FS_ENTRY_FILE, FS_ENTRY_FILTERED,
31    FS_ENTRY_LINK_DIR, FS_ENTRY_NO_CONTENT, FS_ENTRY_OTHER, FS_ENTRY_SYMLINK, FS_ENTRY_TYPE_MASK,
32    FS_ENTRY_UNREADABLE, FS_ENTRY_UNSTABLE, FS_FILE_NOT_FOUND, FS_FILE_OK, FS_FILE_UNREADABLE,
33    FS_OP_HARDLINK, FS_OP_MKDIR, FS_OP_MKPARENTS, FS_OP_NO_CAS, FS_OP_REMOVE, FS_OP_RENAME,
34    FS_OP_SYMLINK, FS_UPDATE_RESET, FS_UPDATE_SYNC, FS_WRITE_DURABLE, FS_WRITE_FOLLOW_SYMLINK,
35    FS_WRITE_MKPARENTS, FS_WRITE_NO_CAS, FsContent, FsRecord, append_fs_record, msg_fs_closed,
36    msg_fs_done, msg_fs_file, msg_fs_update,
37};
38
39pub mod backend;
40pub mod ignores;
41
42pub use ignores::{IgnoreSpec, MAX_PATTERNS as MAX_IGNORE_PATTERNS};
43
44// ---------------------------------------------------------------------------
45// Options and handles
46// ---------------------------------------------------------------------------
47
48#[derive(Clone, Debug)]
49pub struct SyncOptions {
50    pub recursive: bool,
51    pub content: bool,
52    pub cross_filesystem: bool,
53    /// Settle/batching window.
54    pub latency: Duration,
55    /// Per-file inline content cap in bytes.
56    pub inline_max: u64,
57    /// Unacknowledged-byte credit window.
58    pub window_bytes: usize,
59    /// Uncompressed records target per update.
60    pub batch_target: usize,
61    /// Hard cap on indexed entries.
62    pub max_entries: usize,
63}
64
65impl Default for SyncOptions {
66    fn default() -> Self {
67        Self {
68            recursive: true,
69            content: false,
70            cross_filesystem: false,
71            latency: env_ms("BLIT_FS_LATENCY_MS", 20),
72            inline_max: env_u64("BLIT_FS_INLINE_MAX", 16 * 1024 * 1024),
73            window_bytes: env_u64("BLIT_FS_WINDOW", 1024 * 1024) as usize,
74            batch_target: 64 * 1024,
75            max_entries: env_u64("BLIT_FS_MAX_ENTRIES", 1_000_000) as usize,
76        }
77    }
78}
79
80fn env_ms(name: &str, default: u64) -> Duration {
81    Duration::from_millis(env_u64(name, default).clamp(1, 1000))
82}
83
84fn env_u64(name: &str, default: u64) -> u64 {
85    std::env::var(name)
86        .ok()
87        .and_then(|v| v.parse().ok())
88        .unwrap_or(default)
89}
90
91/// A hint from a native backend. Hints are unreliable and duplicated; the
92/// reconciler verifies everything against the filesystem before emitting.
93#[derive(Clone, Debug)]
94pub enum Hint {
95    /// Something at or under this absolute path may have changed.
96    Dirty(PathBuf),
97    /// Events may have been lost; re-verify the whole tree.
98    Rescan,
99}
100
101/// Per-connection in-flight write accounting. The server inserts a
102/// request's nonce before dispatch — rejecting a duplicate (`INVALID`) or
103/// an over-cap request (`BUDGET`) — and attaches this guard to the request;
104/// the engine drops it once the request is answered, removing the nonce and
105/// freeing a slot. Bounds the otherwise-unbounded engine channel depth (and
106/// thus resident inbound content) to the in-flight cap.
107#[derive(Debug)]
108pub struct InflightGuard {
109    set: Arc<Mutex<std::collections::HashSet<u16>>>,
110    nonce: u16,
111}
112
113impl InflightGuard {
114    pub fn new(set: Arc<Mutex<std::collections::HashSet<u16>>>, nonce: u16) -> Self {
115        InflightGuard { set, nonce }
116    }
117}
118
119impl Drop for InflightGuard {
120    fn drop(&mut self) {
121        if let Ok(mut set) = self.set.lock() {
122            set.remove(&self.nonce);
123        }
124    }
125}
126
127/// A content write forwarded to the engine (docs/design/fs-write.md).
128/// `path` is the escaped wire path; `flags` are `FS_WRITE_*`.
129#[derive(Clone, Debug)]
130pub struct WriteReq {
131    pub nonce: u16,
132    pub path: String,
133    pub base: u128,
134    pub mode: u32,
135    pub flags: u8,
136    pub content_kind: u8,
137    pub content: Vec<u8>,
138    /// Freed (nonce slot released) when this request is dropped after the
139    /// engine answers it. `None` in tests and embedders without accounting.
140    pub inflight: Option<Arc<InflightGuard>>,
141}
142
143/// A metadata op forwarded to the engine. `op` is `FS_OP_*`; `a`/`b` are
144/// escaped wire paths (`b` empty except for `RENAME`).
145#[derive(Clone, Debug)]
146pub struct OpReq {
147    pub nonce: u16,
148    pub op: u8,
149    pub a: String,
150    pub b: String,
151    pub base: u128,
152    pub mode: u32,
153    pub flags: u8,
154    pub inflight: Option<Arc<InflightGuard>>,
155}
156
157/// Commands forwarded from the client connection.
158#[derive(Clone, Debug)]
159pub enum Command {
160    Ack(u32),
161    Fetch { nonce: u16, path: String },
162    Write(WriteReq),
163    Op(OpReq),
164    Stop,
165}
166
167/// Registration interface a backend exposes to the reconciler so the set
168/// of watched directories tracks the set of *indexed* ones (inotify, where
169/// a recursive watch is a descriptor per directory and an excluded subtree
170/// would otherwise still cost them all). FSEvents/RDCW cover a tree with
171/// one object and use the no-op default, as does any unfiltered root.
172pub trait BackendHandle: Send {
173    /// Arm a watch on a directory about to be enumerated. `false` means
174    /// watch descriptors are exhausted: the caller closes the root rather
175    /// than serve a mirror with a silently stale subtree in it.
176    fn add_dir(&self, _dir: &Path) -> bool {
177        true
178    }
179    /// Arm a directory *outside* the synced tree, because it holds an
180    /// ignore source the matcher consulted. Unlike [`BackendHandle::add_dir`]
181    /// this is not covered by a recursive root watch, so every filtered
182    /// root needs it however the tree itself is watched.
183    fn watch_outside(&self, _dir: &Path) {}
184    /// Disarm a directory and everything under it — deleted, or newly
185    /// excluded.
186    fn remove_dir(&self, _dir: &Path) {}
187    /// Disarm every armed directory `keep` rejects, after a full rescan
188    /// replaces the index wholesale.
189    fn retain_dirs(&self, _keep: &dyn Fn(&Path) -> bool) {}
190}
191
192pub struct NoopBackend;
193impl BackendHandle for NoopBackend {}
194
195// ---------------------------------------------------------------------------
196// Shared roots: one native watcher + one canonical index per watched root,
197// shared by every sync of that root across all connections.
198// ---------------------------------------------------------------------------
199
200/// Identity of a shared root. Enumeration scope is part of the identity:
201/// recursive and non-recursive syncs of the same directory index different
202/// trees and cannot share a reconciler — and neither do two syncs that
203/// exclude different things, for exactly the same reason.
204#[derive(Clone, Debug, PartialEq, Eq, Hash)]
205pub struct RootKey {
206    /// Canonical root path (see [`validate_root`]).
207    pub path: PathBuf,
208    pub recursive: bool,
209    pub cross_filesystem: bool,
210    /// What this root excludes from enumeration, watching, hashing, and
211    /// records (docs/design/fs-watch.md "Ignoring"). Default excludes
212    /// nothing, and an empty spec costs nothing: no matcher is built.
213    pub ignores: IgnoreSpec,
214}
215
216/// Reconciler inbox.
217enum RootMsg {
218    Hint(Hint),
219    Subscribe {
220        id: u64,
221        tx: Sender<SyncMsg>,
222        latency: Duration,
223    },
224    Unsubscribe {
225        id: u64,
226    },
227    /// An engine read and hashed a file's content; the reconciler adopts
228    /// the hash if the stat still matches, so other syncs can serve the
229    /// bytes straight from the blob store.
230    HashLearned {
231        path: String,
232        meta: NodeMeta,
233    },
234}
235
236/// What the reconciler publishes to subscribed engines.
237enum RootUpdate {
238    /// A new immutable snapshot of the canonical index. `settled` is when
239    /// the reconciler's batch began settling, so the engine can honor the
240    /// requested window without adding a second one on top (the reconciler
241    /// already waited `latency`). `None` = already settled, emit at once.
242    Snapshot {
243        index: Arc<Index>,
244        settled: Option<Instant>,
245        /// Keys that differ from the previously published snapshot, so
246        /// engines diff only these instead of walking both maps. `None` =
247        /// unknown (a subscriber's first snapshot): diff everything.
248        changed: Option<Arc<std::collections::BTreeSet<String>>>,
249        /// Keys whose stat is unchanged but whose mtime is too recent to
250        /// prove it (docs/design/fs-watch.md "Racily-clean entries"). The
251        /// reconciler cannot settle these; engines can, by hashing.
252        recheck: Arc<std::collections::BTreeSet<String>>,
253    },
254    /// The root is gone or over budget; the sync must close with `reason`.
255    Closed(u8),
256}
257
258/// Per-sync engine inbox.
259enum SyncMsg {
260    Cmd(Command),
261    Root(RootUpdate),
262}
263
264/// A shared root: keeps the native watcher armed and the reconciler
265/// reachable. Engines hold an `Arc`; when the last one drops, the watcher
266/// disarms, the reconciler's inbox disconnects, and its thread exits.
267pub struct SharedRootHandle {
268    key: RootKey,
269    /// `FS_SYNC_SINGLE` root: `key.path` is a FILE, the index holds exactly
270    /// one entry (""), and the native watch sits on the file's parent
271    /// directory, non-recursive, filtered to the file's name
272    /// (docs/design/fs-watch.md "Single-file sync").
273    single: bool,
274    tx: Sender<RootMsg>,
275    /// Set to the close reason once the reconciler shuts the root down
276    /// (root gone, permission lost, resource limit). A closed root is dead
277    /// forever; a later `open_root` of the same key must not join it.
278    closed: Arc<OnceLock<u8>>,
279    /// Hashes engines learned recently, keyed by wire path with the stat
280    /// each was verified against. Bridges the hash-publish coalescing
281    /// window: a content sync joining while another is still reading the
282    /// tree finds the hash here (stat re-checked against its snapshot)
283    /// and serves from the blob store instead of re-reading every file.
284    /// Coarsely bounded: cleared when over cap — entries only matter
285    /// until the next hash publish.
286    learned: Mutex<std::collections::HashMap<String, NodeMeta>>,
287    /// Keeps the native watch alive for the root's lifetime.
288    _backend: Mutex<Option<backend::WatchBackend>>,
289}
290
291impl SharedRootHandle {
292    pub fn key(&self) -> &RootKey {
293        &self.key
294    }
295
296    /// True for an `FS_SYNC_SINGLE` root (the root is a single file).
297    pub fn is_single(&self) -> bool {
298        self.single
299    }
300
301    /// A hint sender for tests and embedders with their own change source.
302    pub fn hint_sender(&self) -> HintSender {
303        HintSender {
304            tx: self.tx.clone(),
305        }
306    }
307
308    fn is_closed(&self) -> bool {
309        self.closed.get().is_some()
310    }
311}
312
313/// Registry key: sharing is per `(RootKey, single)` — the flag set is part
314/// of the identity, so a SINGLE sync of a path can never join a directory
315/// root of the same path (or vice versa), while two SINGLE syncs of one
316/// file share a reconciler and watcher.
317#[derive(Clone, Debug, PartialEq, Eq, Hash)]
318struct RegKey {
319    root: RootKey,
320    single: bool,
321}
322
323type Registry = std::collections::HashMap<RegKey, std::sync::Weak<SharedRootHandle>>;
324
325fn registry() -> &'static Mutex<Registry> {
326    static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
327    REGISTRY.get_or_init(Default::default)
328}
329
330/// Open (or join) the shared root for `key`, arming a native watcher on
331/// first open — before the initial enumeration, so nothing slips between
332/// scan and event delivery. On failure returns an `FS_STATUS_*` code plus
333/// diagnostic, so the server can answer `FS_SYNCED` accurately.
334pub fn open_root(key: RootKey) -> Result<Arc<SharedRootHandle>, (u8, String)> {
335    open_root_inner(key, false, true)
336}
337
338/// Open (or join) a shared root without a native watcher; hints come from
339/// [`SharedRootHandle::hint_sender`]. For tests and embedders.
340pub fn open_root_unwatched(key: RootKey) -> Arc<SharedRootHandle> {
341    open_root_inner(key, false, false).expect("unwatched open cannot fail")
342}
343
344/// Open (or join) the shared root for an `FS_SYNC_SINGLE` sync of `path`
345/// (a canonical FILE path from [`validate_single_root`]). The native watch
346/// arms on the file's PARENT directory, non-recursive — a watch on the
347/// file itself would follow its inode and go silent after a delete or a
348/// rename-over, exactly the transitions a single-file sync must deliver.
349/// `recursive`/`cross_filesystem`/`ignores` do not apply (nothing is
350/// enumerated — the client named the one file it wants), so every SINGLE
351/// sync of one file shares a single normalized key.
352pub fn open_single_root(path: PathBuf) -> Result<Arc<SharedRootHandle>, (u8, String)> {
353    open_root_inner(single_root_key(path), true, true)
354}
355
356/// [`open_single_root`] without a native watcher; hints come from
357/// [`SharedRootHandle::hint_sender`]. For tests and embedders.
358pub fn open_single_root_unwatched(path: PathBuf) -> Arc<SharedRootHandle> {
359    open_root_inner(single_root_key(path), true, false).expect("unwatched open cannot fail")
360}
361
362fn single_root_key(path: PathBuf) -> RootKey {
363    RootKey {
364        path,
365        recursive: false,
366        cross_filesystem: false,
367        ignores: IgnoreSpec::default(),
368    }
369}
370
371/// Map a native-watch arming failure to an `FS_STATUS_*` code.
372fn watch_error_status(err: &notify::Error) -> u8 {
373    use blit_remote::fs::{
374        FS_STATUS_NOT_FOUND, FS_STATUS_OTHER, FS_STATUS_PERMISSION_DENIED, FS_STATUS_RESOURCE_LIMIT,
375    };
376    match &err.kind {
377        notify::ErrorKind::MaxFilesWatch => FS_STATUS_RESOURCE_LIMIT,
378        notify::ErrorKind::PathNotFound => FS_STATUS_NOT_FOUND,
379        notify::ErrorKind::Io(e) => match e.raw_os_error() {
380            // ENFILE / EMFILE / ENOSPC — descriptor or watch exhaustion.
381            Some(23) | Some(24) | Some(28) => FS_STATUS_RESOURCE_LIMIT,
382            _ => match e.kind() {
383                io::ErrorKind::PermissionDenied => FS_STATUS_PERMISSION_DENIED,
384                io::ErrorKind::NotFound => FS_STATUS_NOT_FOUND,
385                _ => FS_STATUS_OTHER,
386            },
387        },
388        _ => FS_STATUS_OTHER,
389    }
390}
391
392fn open_root_inner(
393    key: RootKey,
394    single: bool,
395    watched: bool,
396) -> Result<Arc<SharedRootHandle>, (u8, String)> {
397    let reg_key = RegKey {
398        root: key.clone(),
399        single,
400    };
401    // Join an existing live, open root under the lock.
402    {
403        let mut map = registry().lock().unwrap();
404        map.retain(|_, weak| weak.strong_count() > 0);
405        if let Some(existing) = map
406            .get(&reg_key)
407            .and_then(std::sync::Weak::upgrade)
408            .filter(|h| !h.is_closed())
409        {
410            return Ok(existing);
411        }
412    }
413    // Arm the native watcher *outside* the registry lock: `inotify_add_watch`
414    // / FSEvents stream creation can be slow, and holding the global lock
415    // across it would serialize every connection opening any root. Arming
416    // before the reconciler spawns preserves the arm-before-scan contract.
417    let (tx, rx) = mpsc::channel();
418    let backend = if watched {
419        let hints = HintSender { tx: tx.clone() };
420        // A SINGLE root watches the file's parent directory, non-recursive:
421        // a watch armed on the file itself follows its inode and misses the
422        // delete / rename-over / recreate transitions a single-file sync
423        // exists to deliver. The reconciler filters hints to the file's
424        // name, so sibling churn never reaches the index.
425        let (watch_path, recursive) = if single {
426            let parent = key
427                .path
428                .parent()
429                .ok_or_else(|| {
430                    use blit_remote::fs::FS_STATUS_OTHER;
431                    (FS_STATUS_OTHER, "single root has no parent".to_string())
432                })?
433                .to_path_buf();
434            (parent, false)
435        } else {
436            (key.path.clone(), key.recursive)
437        };
438        // A filtered root arms per directory so excluded subtrees cost no
439        // watch descriptors; the reconciler drives it from enumeration
440        // (backend::PerDirWatch).
441        let per_dir =
442            backend::per_dir_watching_pays(key.recursive, single, !key.ignores.is_empty());
443        Some(
444            backend::watch(&watch_path, recursive, per_dir, hints)
445                .map_err(|e| (watch_error_status(&e), e.to_string()))?,
446        )
447    } else {
448        None
449    };
450    // Cloned before the handle takes ownership: the reconciler registers
451    // and retires directories through it for the root's whole lifetime.
452    let registrar: Box<dyn BackendHandle> = match &backend {
453        Some(backend) => Box::new(backend.watches.clone()),
454        None => Box::new(NoopBackend),
455    };
456    let mut map = registry().lock().unwrap();
457    map.retain(|_, weak| weak.strong_count() > 0);
458    // Another thread may have created (and armed) the same root while we
459    // were arming; prefer theirs and drop our now-redundant watcher.
460    if let Some(existing) = map
461        .get(&reg_key)
462        .and_then(std::sync::Weak::upgrade)
463        .filter(|h| !h.is_closed())
464    {
465        return Ok(existing);
466    }
467    let closed: Arc<OnceLock<u8>> = Arc::new(OnceLock::new());
468    let handle = Arc::new(SharedRootHandle {
469        key: key.clone(),
470        single,
471        tx,
472        closed: closed.clone(),
473        learned: Mutex::new(Default::default()),
474        _backend: Mutex::new(backend),
475    });
476    std::thread::Builder::new()
477        .name("blit-fsroot".into())
478        .spawn(move || Reconciler::new(key, single, rx, registrar, closed).run())
479        .expect("spawn fssync reconciler");
480    map.insert(reg_key, Arc::downgrade(&handle));
481    Ok(handle)
482}
483
484/// Handle owned by the client connection. Dropping it stops the engine
485/// (and, transitively, releases its share of the root).
486pub struct SyncHandle {
487    tx: Sender<SyncMsg>,
488    /// Set once the engine thread has exited (client gone, stopped, or an
489    /// engine-initiated `FS_CLOSED`). Lets the server reap dead entries
490    /// whose id it never saw a `FS_STOP` for.
491    done: Arc<std::sync::atomic::AtomicBool>,
492}
493
494impl SyncHandle {
495    pub fn command(&self, cmd: Command) -> bool {
496        self.tx.send(SyncMsg::Cmd(cmd)).is_ok()
497    }
498
499    /// True once the engine thread has exited. The `FS_CLOSED` it may have
500    /// emitted is already in the FIFO outbox before this flips, so reaping
501    /// after observing `true` can never reorder it against a reused id.
502    pub fn is_done(&self) -> bool {
503        self.done.load(std::sync::atomic::Ordering::Acquire)
504    }
505}
506
507impl Drop for SyncHandle {
508    fn drop(&mut self) {
509        let _ = self.tx.send(SyncMsg::Cmd(Command::Stop));
510    }
511}
512
513/// Wrap the reconciler inbox for a hint source (native backend or test).
514#[derive(Clone)]
515pub struct HintSender {
516    tx: Sender<RootMsg>,
517}
518
519impl HintSender {
520    pub fn send(&self, hint: Hint) -> bool {
521        self.tx.send(RootMsg::Hint(hint)).is_ok()
522    }
523}
524
525/// Messages the engine emits, ready for the client outbox. Returns `false`
526/// when the client is gone; the engine then exits.
527pub type Outbox = Box<dyn FnMut(Vec<u8>) -> bool + Send>;
528
529/// Validate and canonicalize a requested root. Returns the canonical path
530/// or an `FS_STATUS_*` code plus diagnostic.
531pub fn validate_root(path: &str) -> Result<PathBuf, (u8, String)> {
532    use blit_remote::fs::{FS_STATUS_NOT_FOUND, FS_STATUS_OTHER, FS_STATUS_PERMISSION_DENIED};
533    if path.is_empty() || path.contains('\0') {
534        return Err((FS_STATUS_OTHER, "invalid path".into()));
535    }
536    let err = match fs::canonicalize(path) {
537        Ok(p) => return Ok(p),
538        Err(e) => e,
539    };
540    // A root can arrive in either of two encodings, and they are not
541    // distinguishable by inspection:
542    //
543    //   * raw, as a CLI or a user types it (`/tmp/50%.txt`);
544    //   * wire-escaped, because FS_SYNCED echoes `escape_path(canonical_root)`
545    //     and clients legitimately build further sync roots from that echo
546    //     (js/ui/src/ide/session.ts) — where a literal `%` came back as `%25`.
547    //
548    // Escaping on the way out without decoding on the way in meant any path
549    // containing `%` (or non-UTF-8 bytes) could be listed but never re-opened:
550    // the round trip returned a string that no longer named the file, and the
551    // client reported it as missing. Try the literal reading first so a file
552    // genuinely named `50%25.txt` still wins, then the decoded one.
553    if err.kind() == io::ErrorKind::NotFound
554        && path.contains('%')
555        && let Some(decoded) = wire_to_os(path)
556        && let Ok(p) = fs::canonicalize(&decoded)
557    {
558        return Ok(p);
559    }
560    let status = match err.kind() {
561        io::ErrorKind::NotFound => FS_STATUS_NOT_FOUND,
562        io::ErrorKind::PermissionDenied => FS_STATUS_PERMISSION_DENIED,
563        _ => FS_STATUS_OTHER,
564    };
565    Err((status, err.to_string()))
566}
567
568/// Validate and canonicalize an `FS_SYNC_SINGLE` root: the same
569/// canonicalization as [`validate_root`], plus the path must not be a
570/// directory — a directory root answers the existing invalid-path error
571/// (docs/design/fs-watch.md "Single-file sync"). Canonicalization resolves
572/// symlinks, so the returned path is the file itself, never a link to it.
573pub fn validate_single_root(path: &str) -> Result<PathBuf, (u8, String)> {
574    use blit_remote::fs::FS_STATUS_OTHER;
575    let canon = validate_root(path)?;
576    match fs::symlink_metadata(&canon) {
577        Ok(md) if md.is_dir() => Err((
578            FS_STATUS_OTHER,
579            "single sync root is a directory".to_string(),
580        )),
581        _ => Ok(canon),
582    }
583}
584
585/// Spawn a sync engine subscribed to `shared`, streaming to `outbox`.
586/// The engine's initial `RESET … SYNC` series is cut from the root's
587/// current snapshot — later syncs of an already-watched root never rescan.
588pub fn start_sync(
589    shared: &Arc<SharedRootHandle>,
590    sync_id: u16,
591    opts: SyncOptions,
592    outbox: Outbox,
593) -> SyncHandle {
594    static SUB_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
595    let sub_id = SUB_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
596    let (tx, rx) = mpsc::channel();
597    let _ = shared.tx.send(RootMsg::Subscribe {
598        id: sub_id,
599        tx: tx.clone(),
600        latency: opts.latency,
601    });
602    let engine = SyncEngine::new(sync_id, shared.clone(), sub_id, opts, rx, outbox);
603    let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
604    let done_thread = done.clone();
605    std::thread::Builder::new()
606        .name(format!("blit-fssync-{sync_id}"))
607        .spawn(move || {
608            engine.run();
609            // run() has already queued any FS_CLOSED into the outbox FIFO.
610            done_thread.store(true, std::sync::atomic::Ordering::Release);
611        })
612        .expect("spawn fssync engine");
613    SyncHandle { tx, done }
614}
615
616// ---------------------------------------------------------------------------
617// Path escaping: every wire path is valid UTF-8; non-UTF-8 bytes become %XX,
618// literal '%' becomes %25. Deterministic and reversible.
619// ---------------------------------------------------------------------------
620
621pub fn escape_bytes(bytes: &[u8]) -> String {
622    let mut out = String::with_capacity(bytes.len());
623    let mut rest = bytes;
624    loop {
625        match std::str::from_utf8(rest) {
626            Ok(s) => {
627                push_escaping_percent(&mut out, s);
628                return out;
629            }
630            Err(e) => {
631                let (valid, after) = rest.split_at(e.valid_up_to());
632                push_escaping_percent(&mut out, unsafe { std::str::from_utf8_unchecked(valid) });
633                let bad = e.error_len().unwrap_or(after.len());
634                for &b in &after[..bad] {
635                    out.push_str(&format!("%{b:02X}"));
636                }
637                rest = &after[bad..];
638            }
639        }
640    }
641}
642
643fn push_escaping_percent(out: &mut String, s: &str) {
644    for ch in s.chars() {
645        if ch == '%' {
646            out.push_str("%25");
647        } else {
648            out.push(ch);
649        }
650    }
651}
652
653/// Reverse [`escape_bytes`]. Returns `None` on malformed escapes.
654pub fn unescape_to_bytes(s: &str) -> Option<Vec<u8>> {
655    let mut out = Vec::with_capacity(s.len());
656    let bytes = s.as_bytes();
657    let mut i = 0;
658    while i < bytes.len() {
659        if bytes[i] == b'%' {
660            let hex = bytes.get(i + 1..i + 3)?;
661            let hi = (hex[0] as char).to_digit(16)?;
662            let lo = (hex[1] as char).to_digit(16)?;
663            out.push((hi * 16 + lo) as u8);
664            i += 3;
665        } else {
666            out.push(bytes[i]);
667            i += 1;
668        }
669    }
670    Some(out)
671}
672
673/// Escape UTF-16 code units (Windows names): valid text passes through
674/// (`%` → `%25`), unpaired surrogates become `%uXXXX`. A literal `%u` in a
675/// name escapes to `%25u`, so the forms never collide. Pure so every host
676/// can test it; `cfg(windows)` wires it to `OsStr`.
677pub fn escape_wide(units: &[u16]) -> String {
678    let mut out = String::with_capacity(units.len());
679    for decoded in char::decode_utf16(units.iter().copied()) {
680        match decoded {
681            Ok('%') => out.push_str("%25"),
682            Ok(c) => out.push(c),
683            Err(e) => {
684                out.push_str(&format!("%u{:04X}", e.unpaired_surrogate()));
685            }
686        }
687    }
688    out
689}
690
691/// Reverse [`escape_wide`]: `%uXXXX` → one code unit, `%XX` → one unit
692/// below 0x100 (covers `%25`), everything else re-encoded as UTF-16.
693pub fn unescape_to_wide(s: &str) -> Option<Vec<u16>> {
694    let mut out = Vec::with_capacity(s.len());
695    let bytes = s.as_bytes();
696    let mut i = 0;
697    while i < bytes.len() {
698        if bytes[i] == b'%' {
699            if bytes.get(i + 1) == Some(&b'u') {
700                out.push(u16::from_str_radix(s.get(i + 2..i + 6)?, 16).ok()?);
701                i += 6;
702            } else {
703                out.push(u16::from(
704                    u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?,
705                ));
706                i += 3;
707            }
708        } else {
709            let c = s[i..].chars().next()?;
710            let mut buf = [0u16; 2];
711            out.extend_from_slice(c.encode_utf16(&mut buf));
712            i += c.len_utf8();
713        }
714    }
715    Some(out)
716}
717
718/// Escape a whole path for wire use (e.g. the `FS_SYNCED` canonical-root
719/// detail): same scheme as components, separators left intact.
720#[cfg(unix)]
721pub fn escape_path(path: &Path) -> String {
722    use std::os::unix::ffi::OsStrExt;
723    escape_bytes(path.as_os_str().as_bytes())
724}
725
726#[cfg(windows)]
727pub fn escape_path(path: &Path) -> String {
728    use std::os::windows::ffi::OsStrExt;
729    escape_wide(&path.as_os_str().encode_wide().collect::<Vec<_>>())
730}
731
732#[cfg(all(not(unix), not(windows)))]
733pub fn escape_path(path: &Path) -> String {
734    escape_bytes(path.to_string_lossy().as_bytes())
735}
736
737#[cfg(unix)]
738fn os_to_wire(name: &std::ffi::OsStr) -> String {
739    use std::os::unix::ffi::OsStrExt;
740    escape_bytes(name.as_bytes())
741}
742
743#[cfg(windows)]
744fn os_to_wire(name: &std::ffi::OsStr) -> String {
745    use std::os::windows::ffi::OsStrExt;
746    escape_wide(&name.encode_wide().collect::<Vec<_>>())
747}
748
749#[cfg(all(not(unix), not(windows)))]
750fn os_to_wire(name: &std::ffi::OsStr) -> String {
751    escape_bytes(name.to_string_lossy().as_bytes())
752}
753
754#[cfg(unix)]
755fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
756    use std::os::unix::ffi::OsStringExt;
757    Some(std::ffi::OsString::from_vec(unescape_to_bytes(component)?))
758}
759
760#[cfg(windows)]
761fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
762    use std::os::windows::ffi::OsStringExt;
763    Some(std::ffi::OsString::from_wide(&unescape_to_wide(component)?))
764}
765
766#[cfg(all(not(unix), not(windows)))]
767fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
768    Some(
769        String::from_utf8(unescape_to_bytes(component)?)
770            .ok()?
771            .into(),
772    )
773}
774
775/// Resolve a wire path (relative, '/'-separated, escaped) against a root.
776/// Rejects traversal — the result always stays under the root.
777pub fn resolve_wire_path(root: &Path, wire: &str) -> Option<PathBuf> {
778    use std::path::Component;
779    let mut abs = root.to_path_buf();
780    if wire.is_empty() {
781        return Some(abs);
782    }
783    for component in wire.split('/') {
784        // Validate the *decoded* component, not the escaped wire text:
785        // `%2E%2E` decodes to `..` and `%2F` to `/`, so a check on the
786        // escaped form (`component == ".."`) is bypassable and would let
787        // a crafted request climb out of the root. Decode first, then
788        // require exactly one normal path component — rejecting empty,
789        // `.`, `..`, absolute/prefix pieces, and any embedded separator.
790        let os = wire_to_os(component)?;
791        let mut parts = Path::new(&os).components();
792        match (parts.next(), parts.next()) {
793            (Some(Component::Normal(part)), None) if part == os.as_os_str() => abs.push(part),
794            _ => return None,
795        }
796    }
797    Some(abs)
798}
799
800fn join_wire(parent: &str, child: &str) -> String {
801    if parent.is_empty() {
802        child.to_string()
803    } else {
804        format!("{parent}/{child}")
805    }
806}
807
808// ---------------------------------------------------------------------------
809// Metadata index
810// ---------------------------------------------------------------------------
811
812#[derive(Clone, Debug, PartialEq, Eq)]
813pub struct NodeMeta {
814    /// Node type in `FS_ENTRY_TYPE_MASK` bits (flags added at send time).
815    pub node_type: u8,
816    pub size: u64,
817    pub mtime_ns: u64,
818    pub mode: u32,
819    /// BLAKE3-128 of content; 0 until the file has been read.
820    pub hash: u128,
821    /// File identity used for move detection; (0, 0) when unavailable.
822    pub dev_ino: (u64, u64),
823    /// Set when `node_type` is `FS_ENTRY_SYMLINK` and the target is a
824    /// directory. Captured at stat time so the send path and the descent gates
825    /// don't each have to re-resolve the link.
826    pub link_dir: bool,
827    /// Set on a directory whose last enumeration skipped an excluded
828    /// child, and sent as `FS_ENTRY_FILTERED`. Not a property of the
829    /// inode, so `stat_meta` never sets it: only the enumeration that
830    /// applied the rules knows, and it writes the answer back onto the
831    /// parent it just listed.
832    pub filtered: bool,
833}
834
835impl NodeMeta {
836    /// True when this node's children belong in the index: a real directory, or
837    /// a symlink resolving to one. The file browser descends both, so every
838    /// gate deciding "should I enumerate this?" must use this rather than
839    /// testing `FS_ENTRY_DIR` alone — otherwise a symlinked directory is a dead
840    /// end, reporting children it can never list.
841    fn enumerable_dir(&self) -> bool {
842        self.node_type == FS_ENTRY_DIR || (self.node_type == FS_ENTRY_SYMLINK && self.link_dir)
843    }
844
845    fn same_identity(&self, other: &NodeMeta) -> bool {
846        self.node_type == other.node_type && self.dev_ino != (0, 0) && self.dev_ino == other.dev_ino
847    }
848
849    fn content_changed(&self, prev: &NodeMeta) -> bool {
850        self.node_type != prev.node_type
851            || self.size != prev.size
852            || self.mtime_ns != prev.mtime_ns
853            || self.dev_ino != prev.dev_ino
854    }
855
856    /// Equality for diffing: everything the client can see, except `hash`,
857    /// which is a lazily learned annotation — a hash fill-in alone must not
858    /// produce records.
859    ///
860    /// "Everything the client can see" includes the two flags that are not
861    /// properties of the inode: `filtered` and `link_dir` both ride out in
862    /// `entry_flags`, so a diff that ignored them could drop the only record
863    /// that would have carried a flag flip. That is not hypothetical — it
864    /// deadlocked `a_directory_reports_that_it_hid_children` on CI (#124).
865    /// A newly excluded child normally bumps its parent directory's mtime,
866    /// so the flip travelled as a side effect of a stat change; when the
867    /// two writes landed in the same filesystem timestamp tick, mtime
868    /// matched, the entry compared equal, and no record was emitted. The
869    /// hint path suppresses further nudges once the canonical entry is
870    /// `filtered` (one re-list per transition), so nothing retried and the
871    /// client never learned. Comparing the flags is what makes the publish
872    /// depend on the flag itself rather than on a coincidence of timestamps.
873    fn visible_eq(&self, other: &NodeMeta) -> bool {
874        self.node_type == other.node_type
875            && self.size == other.size
876            && self.mtime_ns == other.mtime_ns
877            && self.mode == other.mode
878            && self.dev_ino == other.dev_ino
879            && self.filtered == other.filtered
880            && self.link_dir == other.link_dir
881    }
882}
883
884/// `(dev, ino)` of an already-stat'd node, or `(0, 0)` where the platform does
885/// not expose one — which callers must treat as "identity unknown".
886fn target_identity(md: &fs::Metadata) -> (u64, u64) {
887    #[cfg(unix)]
888    {
889        use std::os::unix::fs::MetadataExt;
890        (md.dev(), md.ino())
891    }
892    #[cfg(not(unix))]
893    {
894        let _ = md;
895        (0, 0)
896    }
897}
898
899fn stat_meta(path: &Path) -> io::Result<NodeMeta> {
900    let md = fs::symlink_metadata(path)?;
901    let ft = md.file_type();
902    let node_type = if ft.is_file() {
903        FS_ENTRY_FILE
904    } else if ft.is_dir() {
905        FS_ENTRY_DIR
906    } else if ft.is_symlink() {
907        FS_ENTRY_SYMLINK
908    } else {
909        FS_ENTRY_OTHER
910    };
911    let mtime_ns = md
912        .modified()
913        .ok()
914        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
915        .map(|d| d.as_nanos() as u64)
916        .unwrap_or(0);
917    #[cfg(unix)]
918    let (mode, dev_ino) = {
919        use std::os::unix::fs::MetadataExt;
920        (md.mode(), (md.dev(), md.ino()))
921    };
922    #[cfg(not(unix))]
923    let (mode, dev_ino) = (0u32, (0u64, 0u64));
924    Ok(NodeMeta {
925        node_type,
926        // One extra stat, and only for links: resolving the target is the only
927        // way to know whether this entry is enumerable.
928        link_dir: ft.is_symlink() && fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false),
929        // Enumeration's answer, not the inode's; filled in by whoever
930        // lists this directory's children.
931        filtered: false,
932        // A symlink's "content" is its target bytes (docs/design/fs-write.md
933        // "Links"), so its size is the target length, as lstat reports it.
934        size: if ft.is_file() || ft.is_symlink() {
935            md.len()
936        } else {
937            0
938        },
939        mtime_ns,
940        mode,
941        hash: 0,
942        dev_ino,
943    })
944}
945
946type Index = BTreeMap<String, NodeMeta>;
947
948fn is_under(path: &str, root: &str) -> bool {
949    root.is_empty()
950        || path == root
951        || (path.len() > root.len()
952            && path.starts_with(root)
953            && path.as_bytes()[root.len()] == b'/')
954}
955
956/// Keys at or under `root` in a sorted map: the entry itself plus the
957/// contiguous `root/`-prefixed range — O(log n + subtree), never a scan of
958/// the whole map.
959fn subtree_keys<V>(map: &BTreeMap<String, V>, root: &str) -> Vec<String> {
960    if root.is_empty() {
961        return map.keys().cloned().collect();
962    }
963    let mut keys: Vec<String> = Vec::new();
964    if map.contains_key(root) {
965        keys.push(root.to_string());
966    }
967    let prefix = format!("{root}/");
968    keys.extend(
969        map.range(prefix.clone()..)
970            .take_while(|(k, _)| k.starts_with(&prefix))
971            .map(|(k, _)| k.clone()),
972    );
973    keys
974}
975
976/// Borrowed variant of [`subtree_keys`]: entries at or under `root`.
977fn subtree_entries<'a, V>(
978    map: &'a BTreeMap<String, V>,
979    root: &str,
980) -> impl Iterator<Item = (&'a String, &'a V)> {
981    let own = if root.is_empty() {
982        None
983    } else {
984        map.get_key_value(root)
985    };
986    let prefix = if root.is_empty() {
987        String::new()
988    } else {
989        format!("{root}/")
990    };
991    own.into_iter().chain(
992        map.range(prefix.clone()..)
993            .take_while(move |(k, _)| k.starts_with(&prefix)),
994    )
995}
996
997/// The wire path of `rel`'s parent: `""`  for a top-level entry (its
998/// parent is the root), `None` for the root itself.
999fn parent_wire(rel: &str) -> Option<&str> {
1000    if rel.is_empty() {
1001        None
1002    } else {
1003        Some(match rel.rfind('/') {
1004            Some(i) => &rel[..i],
1005            None => "",
1006        })
1007    }
1008}
1009
1010/// Rebase `path` (which must be under `from`) onto `to`, preserving the
1011/// subtree suffix — the path transform a `MOVE from→to` performs. Shared
1012/// by the held-content map, the retry set, and the diff move fix-ups.
1013fn rebase_subtree_path(path: &str, from: &str, to: &str) -> String {
1014    let suffix = if path.len() > from.len() {
1015        &path[from.len() + usize::from(!from.is_empty())..]
1016    } else {
1017        ""
1018    };
1019    if suffix.is_empty() {
1020        to.to_string()
1021    } else if to.is_empty() {
1022        suffix.to_string()
1023    } else {
1024        format!("{to}/{suffix}")
1025    }
1026}
1027
1028// ---------------------------------------------------------------------------
1029// Diff with move detection
1030// ---------------------------------------------------------------------------
1031
1032#[derive(Clone, Debug, PartialEq, Eq)]
1033pub enum DiffOp {
1034    /// `content_changed` distinguishes data changes from metadata-only ones.
1035    Upsert {
1036        path: String,
1037        content_changed: bool,
1038    },
1039    Delete {
1040        path: String,
1041    },
1042    Move {
1043        from: String,
1044        to: String,
1045    },
1046}
1047
1048/// Compute ops that transform `prev` into `curr`.
1049///
1050/// Move detection is a diff-time join on file identity `(dev, ino)`:
1051/// disappeared and appeared entries with matching identity become `MOVE`
1052/// (shallowest first, descendants covered), so a renamed directory never
1053/// retransmits its files' content. Anything ambiguous decays to
1054/// delete + upsert, which is always valid.
1055pub fn diff(prev: &Index, curr: &Index) -> Vec<DiffOp> {
1056    let mut removed: Vec<&String> = Vec::new();
1057    let mut added: Vec<&String> = Vec::new();
1058    let mut changed: Vec<(&String, bool)> = Vec::new();
1059
1060    let mut pi = prev.iter().peekable();
1061    let mut ci = curr.iter().peekable();
1062    loop {
1063        match (pi.peek(), ci.peek()) {
1064            (Some((pk, pv)), Some((ck, cv))) => {
1065                if pk == ck {
1066                    if !cv.visible_eq(pv) {
1067                        changed.push((ck, cv.content_changed(pv)));
1068                    }
1069                    pi.next();
1070                    ci.next();
1071                } else if pk < ck {
1072                    removed.push(pk);
1073                    pi.next();
1074                } else {
1075                    added.push(ck);
1076                    ci.next();
1077                }
1078            }
1079            (Some((pk, _)), None) => {
1080                removed.push(pk);
1081                pi.next();
1082            }
1083            (None, Some((ck, _))) => {
1084                added.push(ck);
1085                ci.next();
1086            }
1087            (None, None) => break,
1088        }
1089    }
1090    diff_classified(prev, curr, removed, added, changed)
1091}
1092
1093/// [`diff`] restricted to a known changed-key set: only `changed_keys` are
1094/// probed against the two indexes, so a small change in a large tree costs
1095/// O(changed) instead of a walk of both maps. The set must cover every key
1096/// that differs between `prev` and `curr` (the reconciler's published sets
1097/// guarantee this); keys that turn out equal are skipped.
1098fn diff_changed(
1099    prev: &Index,
1100    curr: &Index,
1101    changed_keys: &std::collections::BTreeSet<String>,
1102) -> Vec<DiffOp> {
1103    let mut removed: Vec<&String> = Vec::new();
1104    let mut added: Vec<&String> = Vec::new();
1105    let mut changed: Vec<(&String, bool)> = Vec::new();
1106    // BTreeSet iteration is sorted, which the classification relies on.
1107    for key in changed_keys {
1108        match (prev.get_key_value(key), curr.get_key_value(key)) {
1109            (Some((pk, pv)), Some((_, cv))) => {
1110                if !cv.visible_eq(pv) {
1111                    changed.push((pk, cv.content_changed(pv)));
1112                }
1113            }
1114            (Some((pk, _)), None) => removed.push(pk),
1115            (None, Some((ck, _))) => added.push(ck),
1116            (None, None) => {}
1117        }
1118    }
1119    diff_classified(prev, curr, removed, added, changed)
1120}
1121
1122/// Mark `root` and its subtree in a sorted path list: the entry itself and
1123/// the contiguous `root/` range, located by binary search instead of a
1124/// whole-list scan.
1125fn cover_sorted(paths: &[&String], covered: &mut [bool], root: &str) {
1126    if let Ok(i) = paths.binary_search_by(|p| p.as_str().cmp(root)) {
1127        covered[i] = true;
1128    }
1129    let prefix = format!("{root}/");
1130    let start = paths.partition_point(|p| p.as_str() < prefix.as_str());
1131    for i in start..paths.len() {
1132        if !paths[i].starts_with(&prefix) {
1133            break;
1134        }
1135        covered[i] = true;
1136    }
1137}
1138
1139/// Shared tail of [`diff`] / [`diff_changed`]: move join over the
1140/// classified removed/added/changed lists (each sorted by path), then op
1141/// emission.
1142fn diff_classified(
1143    prev: &Index,
1144    curr: &Index,
1145    removed: Vec<&String>,
1146    added: Vec<&String>,
1147    changed: Vec<(&String, bool)>,
1148) -> Vec<DiffOp> {
1149    // Identity join: removed × added, shallowest (shortest path) first so a
1150    // directory move covers its descendants.
1151    let mut moves: Vec<(String, String)> = Vec::new();
1152    let mut removed_covered = vec![false; removed.len()];
1153    let mut added_covered = vec![false; added.len()];
1154    let mut by_identity: std::collections::HashMap<(u64, u64), usize> =
1155        std::collections::HashMap::new();
1156    for (idx, path) in removed.iter().enumerate() {
1157        let meta = &prev[*path];
1158        if meta.dev_ino != (0, 0) {
1159            by_identity.insert(meta.dev_ino, idx);
1160        }
1161    }
1162    let mut add_order: Vec<usize> = (0..added.len()).collect();
1163    add_order.sort_by_key(|&i| added[i].len());
1164    for ai in add_order {
1165        if added_covered[ai] {
1166            continue;
1167        }
1168        let to = added[ai];
1169        let cmeta = &curr[to];
1170        let Some(&ri) = by_identity.get(&cmeta.dev_ino) else {
1171            continue;
1172        };
1173        if removed_covered[ri] || !prev[removed[ri]].same_identity(cmeta) {
1174            continue;
1175        }
1176        let from = removed[ri];
1177        // Cover both subtrees.
1178        cover_sorted(&removed, &mut removed_covered, from);
1179        cover_sorted(&added, &mut added_covered, to);
1180        moves.push((from.clone(), to.clone()));
1181    }
1182
1183    let mut ops = Vec::new();
1184    // Moves first (so later deletes of emptied ancestors don't prune them),
1185    // then deletes, then upserts.
1186    for (from, to) in &moves {
1187        ops.push(DiffOp::Move {
1188            from: from.clone(),
1189            to: to.clone(),
1190        });
1191    }
1192    // Skip paths whose ancestor is also being deleted; DELETE prunes.
1193    // Sorted order puts every ancestor before its descendants, so probing a
1194    // path's ancestor chain against the deletes already emitted replaces
1195    // the pairwise removed × removed scan.
1196    let mut emitted: std::collections::HashSet<&str> = std::collections::HashSet::new();
1197    for (i, path) in removed.iter().enumerate() {
1198        if removed_covered[i] {
1199            continue;
1200        }
1201        let mut ancestor_deleted = false;
1202        let mut cursor: &str = path;
1203        while let Some(parent) = parent_wire(cursor) {
1204            if emitted.contains(parent) {
1205                ancestor_deleted = true;
1206                break;
1207            }
1208            cursor = parent;
1209        }
1210        if !ancestor_deleted {
1211            emitted.insert(path.as_str());
1212            ops.push(DiffOp::Delete {
1213                path: (*path).clone(),
1214            });
1215        }
1216    }
1217    for (i, path) in added.iter().enumerate() {
1218        if !added_covered[i] {
1219            ops.push(DiffOp::Upsert {
1220                path: (*path).clone(),
1221                content_changed: true,
1222            });
1223        }
1224    }
1225    // A moved subtree is not necessarily identical at its new path: in the
1226    // same settle window children may have been modified, created, or
1227    // deleted, and the root's own metadata may differ — all invisible to
1228    // the client after MOVE alone. Emit fix-ups for every visible
1229    // difference between the old subtree (rebased onto `to`) and the new.
1230    for (from, to) in &moves {
1231        for (path, _) in subtree_entries(prev, from) {
1232            let new_path = rebase_subtree_path(path, from, to);
1233            if !curr.contains_key(&new_path) {
1234                ops.push(DiffOp::Delete { path: new_path });
1235            }
1236        }
1237        for (path, new) in subtree_entries(curr, to) {
1238            let old_path = rebase_subtree_path(path, to, from);
1239            match prev.get(&old_path) {
1240                Some(old) if new.visible_eq(old) => {}
1241                Some(old) => ops.push(DiffOp::Upsert {
1242                    path: path.clone(),
1243                    content_changed: new.content_changed(old),
1244                }),
1245                None => ops.push(DiffOp::Upsert {
1246                    path: path.clone(),
1247                    content_changed: true,
1248                }),
1249            }
1250        }
1251    }
1252    for (path, content_changed) in changed {
1253        ops.push(DiffOp::Upsert {
1254            path: path.clone(),
1255            content_changed,
1256        });
1257    }
1258    ops
1259}
1260
1261// ---------------------------------------------------------------------------
1262// Verified content reads
1263// ---------------------------------------------------------------------------
1264
1265pub enum ReadOutcome {
1266    Stable(Vec<u8>),
1267    Unstable,
1268    Unreadable,
1269}
1270
1271enum ReadMetaOutcome {
1272    /// Content plus the stat it was verified against.
1273    Stable(Vec<u8>, NodeMeta),
1274    Unstable,
1275    Unreadable,
1276}
1277
1278/// Read an entry's content with torn-read protection: identity/size/mtime
1279/// are compared before and after the read; one retry, then `Unstable`.
1280/// A symlink's content is its target bytes, never the file it points to.
1281fn read_verified_meta(path: &Path) -> ReadMetaOutcome {
1282    for _ in 0..2 {
1283        let Ok(before) = stat_meta(path) else {
1284            return ReadMetaOutcome::Unreadable;
1285        };
1286        let read = if before.node_type == FS_ENTRY_SYMLINK {
1287            link_target_bytes(path)
1288        } else {
1289            fs::read(path)
1290        };
1291        let Ok(data) = read else {
1292            return ReadMetaOutcome::Unreadable;
1293        };
1294        match stat_meta(path) {
1295            Ok(after)
1296                if after.dev_ino == before.dev_ino
1297                    && after.size == before.size
1298                    && after.mtime_ns == before.mtime_ns =>
1299            {
1300                return ReadMetaOutcome::Stable(data, after);
1301            }
1302            Ok(_) => continue,
1303            Err(_) => return ReadMetaOutcome::Unreadable,
1304        }
1305    }
1306    ReadMetaOutcome::Unstable
1307}
1308
1309/// [`read_verified_meta`] without the stat, for fetch responses and tests.
1310pub fn read_verified(path: &Path) -> ReadOutcome {
1311    match read_verified_meta(path) {
1312        ReadMetaOutcome::Stable(data, _) => ReadOutcome::Stable(data),
1313        ReadMetaOutcome::Unstable => ReadOutcome::Unstable,
1314        ReadMetaOutcome::Unreadable => ReadOutcome::Unreadable,
1315    }
1316}
1317
1318/// Coarse filesystem clocks (FAT's 2 s, some network FS) can leave a
1319/// just-written file with an mtime indistinguishable from a rewrite in the
1320/// same granule. A file whose mtime is within this window of now is
1321/// "racily clean" — its hash must not be adopted as an identity others can
1322/// serve content by. Matches git's racy-index margin, widened for FAT.
1323const RACY_WINDOW_NS: u64 = 2_000_000_000;
1324
1325fn racily_clean(mtime_ns: u64) -> bool {
1326    let now_ns = SystemTime::now()
1327        .duration_since(SystemTime::UNIX_EPOCH)
1328        .map(|d| d.as_nanos() as u64)
1329        .unwrap_or(0);
1330    now_ns.saturating_sub(mtime_ns) < RACY_WINDOW_NS
1331}
1332
1333/// BLAKE3 truncated to 128 bits, little-endian — the protocol-wide content
1334/// hash (docs/design/fs-watch.md). `pub` so sibling stores (the KV store,
1335/// docs/design/kv.md) share the one convention instead of re-deriving it.
1336pub fn blake3_128(data: &[u8]) -> u128 {
1337    let hash = blake3::hash(data);
1338    u128::from_le_bytes(hash.as_bytes()[..16].try_into().unwrap())
1339}
1340
1341// ---------------------------------------------------------------------------
1342// Writes (docs/design/fs-write.md): the path-confinement guard mutations
1343// need on top of reads, plus atomic-replace / create-exclusive primitives.
1344// Pure platform code — the CAS, hint injection, and echo priming that use
1345// these live in the engine (`SyncEngine::exec_write` / `exec_op`).
1346// ---------------------------------------------------------------------------
1347
1348/// Per-write content cap (`BLIT_FS_WRITE_MAX`, default 16 MiB); refused
1349/// with `TOO_LARGE`. The decompress guard already bounds inbound bytes at
1350/// the 64 MiB protocol cap.
1351fn fs_write_max() -> u64 {
1352    std::env::var("BLIT_FS_WRITE_MAX")
1353        .ok()
1354        .and_then(|v| v.parse().ok())
1355        .unwrap_or(16 * 1024 * 1024)
1356}
1357
1358fn write_io_status(e: &io::Error) -> u8 {
1359    match e.kind() {
1360        io::ErrorKind::NotFound => FS_DONE_NOT_FOUND,
1361        io::ErrorKind::PermissionDenied => FS_DONE_PERMISSION,
1362        io::ErrorKind::AlreadyExists => FS_DONE_CONFLICT,
1363        _ => FS_DONE_OTHER,
1364    }
1365}
1366
1367/// How a final-component symlink at the target is treated.
1368enum SymlinkPolicy {
1369    /// Refuse it (a content write could escape the root through it).
1370    Refuse,
1371    /// Write through it, but only if its canonical target stays under root.
1372    Follow,
1373    /// Operate on the link itself (remove/rename move or unlink the link,
1374    /// never following it — safe, no escape).
1375    Operate,
1376}
1377
1378/// Why a wire path could not be confined under `root`.
1379enum ConfineError {
1380    /// Empty or not a single normal-component path.
1381    Invalid,
1382    /// The parent could not be canonicalized (missing, permission, ...).
1383    Io(io::Error),
1384    /// The canonical parent lies outside `root`.
1385    Escapes,
1386}
1387
1388/// Component-validate `wire` (the traversal fix), then canonicalize the
1389/// target's *parent* and re-confirm it is under the already-canonical
1390/// `root` — defeating an in-tree symlink whose target escapes, which
1391/// `resolve_wire_path` (no symlink resolution) would miss. The final
1392/// component is *not* resolved here: callers apply their own symlink
1393/// policy (a final-component symlink is read/operated on as the link, never
1394/// followed out of root). Returns the confined absolute path.
1395fn confine_target(root: &Path, wire: &str) -> Result<PathBuf, ConfineError> {
1396    let abs = resolve_wire_path(root, wire).ok_or(ConfineError::Invalid)?;
1397    let (Some(parent), Some(name)) = (abs.parent(), abs.file_name()) else {
1398        return Err(ConfineError::Invalid);
1399    };
1400    let canon_parent = fs::canonicalize(parent).map_err(ConfineError::Io)?;
1401    if !canon_parent.starts_with(root) {
1402        return Err(ConfineError::Escapes);
1403    }
1404    Ok(canon_parent.join(name))
1405}
1406
1407/// Resolve and confine a write target via [`confine_target`], then handle a
1408/// final-component symlink per `policy`. Returns the absolute path to
1409/// operate on, or an `FS_DONE_*` status on refusal.
1410fn resolve_write_target(root: &Path, wire: &str, policy: SymlinkPolicy) -> Result<PathBuf, u8> {
1411    let target = match confine_target(root, wire) {
1412        Ok(t) => t,
1413        Err(ConfineError::Invalid) => return Err(FS_DONE_INVALID),
1414        Err(ConfineError::Io(e)) => return Err(write_io_status(&e)),
1415        Err(ConfineError::Escapes) => return Err(FS_DONE_PERMISSION),
1416    };
1417    match fs::symlink_metadata(&target) {
1418        Ok(md) if md.file_type().is_symlink() => match policy {
1419            SymlinkPolicy::Refuse => Err(FS_DONE_PERMISSION),
1420            SymlinkPolicy::Operate => Ok(target),
1421            SymlinkPolicy::Follow => {
1422                let resolved = fs::canonicalize(&target).map_err(|e| write_io_status(&e))?;
1423                if resolved.starts_with(root) {
1424                    Ok(resolved)
1425                } else {
1426                    Err(FS_DONE_PERMISSION)
1427                }
1428            }
1429        },
1430        _ => Ok(target),
1431    }
1432}
1433
1434/// A unique sibling temp path for atomic replace (same directory ⇒ same
1435/// filesystem ⇒ atomic `rename`).
1436fn temp_sibling(target: &Path) -> PathBuf {
1437    static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1438    let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1439    let dir = target.parent().unwrap_or_else(|| Path::new("."));
1440    dir.join(format!(".blit-tmp-{}-{n}", std::process::id()))
1441}
1442
1443/// Set `mode` on an open file (Unix); preserve the replaced file's mode
1444/// when `mode` is 0 and a file exists at `at`.
1445#[cfg(unix)]
1446fn apply_mode(f: &fs::File, at: &Path, mode: u32) {
1447    if mode == 0
1448        && let Ok(md) = fs::metadata(at)
1449    {
1450        let _ = f.set_permissions(md.permissions());
1451    }
1452}
1453#[cfg(not(unix))]
1454fn apply_mode(_f: &fs::File, _at: &Path, _mode: u32) {}
1455
1456/// fsync `f` and its parent directory (F_FULLFSYNC on macOS via std's
1457/// `sync_all`) so a crash after return cannot lose the write.
1458fn fsync_durable(f: &fs::File, target: &Path) -> io::Result<()> {
1459    f.sync_all()?;
1460    #[cfg(unix)]
1461    if let Some(dir) = target.parent()
1462        && let Ok(d) = fs::File::open(dir)
1463    {
1464        let _ = d.sync_all();
1465    }
1466    let _ = target;
1467    Ok(())
1468}
1469
1470/// Write `bytes` to `target` atomically: a same-directory temp file, then
1471/// `rename` over the destination — a reader sees the old bytes or the new,
1472/// never a torn write. `mode` 0 preserves the existing file's mode.
1473fn write_atomic(target: &Path, bytes: &[u8], mode: u32, durable: bool) -> io::Result<()> {
1474    use std::io::Write as _;
1475    let tmp = temp_sibling(target);
1476    let mut opts = fs::OpenOptions::new();
1477    opts.write(true).create_new(true);
1478    #[cfg(unix)]
1479    if mode != 0 {
1480        use std::os::unix::fs::OpenOptionsExt;
1481        opts.mode(mode);
1482    }
1483    let mut f = opts.open(&tmp)?;
1484    let staged = (|| {
1485        f.write_all(bytes)?;
1486        apply_mode(&f, target, mode);
1487        if durable {
1488            f.sync_all()?;
1489        }
1490        Ok(())
1491    })();
1492    drop(f);
1493    if let Err(e) = staged {
1494        let _ = fs::remove_file(&tmp);
1495        return Err(e);
1496    }
1497    if let Err(e) = fs::rename(&tmp, target) {
1498        let _ = fs::remove_file(&tmp);
1499        return Err(e);
1500    }
1501    #[cfg(unix)]
1502    if durable && let Ok(d) = fs::File::open(target.parent().unwrap_or_else(|| Path::new("."))) {
1503        let _ = d.sync_all();
1504    }
1505    Ok(())
1506}
1507
1508/// Create `target` exclusively (`O_EXCL`): fails `AlreadyExists` if the
1509/// path exists, race-free even against an external creator — the
1510/// create-exclusive ("New File") precondition.
1511fn create_exclusive(target: &Path, bytes: &[u8], mode: u32, durable: bool) -> io::Result<()> {
1512    use std::io::Write as _;
1513    let mut opts = fs::OpenOptions::new();
1514    opts.write(true).create_new(true);
1515    #[cfg(unix)]
1516    if mode != 0 {
1517        use std::os::unix::fs::OpenOptionsExt;
1518        opts.mode(mode);
1519    }
1520    // Open exclusively first: a pre-existing file / concurrent creator
1521    // (AlreadyExists) is never touched by the cleanup below.
1522    let mut f = opts.open(target)?;
1523    let staged = (|| {
1524        f.write_all(bytes)?;
1525        if durable {
1526            fsync_durable(&f, target)?;
1527        }
1528        Ok(())
1529    })();
1530    drop(f);
1531    if let Err(e) = staged {
1532        // Restore the "path does not exist" invariant so a retry re-attempts
1533        // the create instead of hitting a phantom CONFLICT on the partial
1534        // bytes (and leaves nothing for the reconciler to echo).
1535        let _ = fs::remove_file(target);
1536        return Err(e);
1537    }
1538    Ok(())
1539}
1540
1541/// The current on-disk content hash of `path`, or 0 (the "absent"
1542/// sentinel) when missing or unreadable. A symlink hashes its target
1543/// bytes, matching the read side. Read under the write lock, so no other
1544/// blit writer can interleave; an external writer is the disclosed,
1545/// irreducible window.
1546fn current_hash(path: &Path) -> u128 {
1547    match fs::symlink_metadata(path) {
1548        Ok(md) if md.file_type().is_symlink() => match link_target_bytes(path) {
1549            Ok(bytes) => blake3_128(&bytes),
1550            Err(_) => 0,
1551        },
1552        // Stream the existing file through a fixed buffer rather than
1553        // fs::read: the on-disk target is unbounded (the CAS request that
1554        // triggers this hash is capped, but the file it compares against
1555        // is not), so a whole-file read would let a tiny request force an
1556        // arbitrarily large allocation.
1557        _ => hash_file_streamed(path).unwrap_or(0),
1558    }
1559}
1560
1561/// BLAKE3-128 of a file's bytes, read through a fixed buffer so peak memory
1562/// stays constant regardless of file size. Same value as `blake3_128` over
1563/// the full content.
1564fn hash_file_streamed(path: &Path) -> io::Result<u128> {
1565    use std::io::Read as _;
1566    let mut f = fs::File::open(path)?;
1567    let mut hasher = blake3::Hasher::new();
1568    let mut buf = [0u8; 64 * 1024];
1569    loop {
1570        let n = f.read(&mut buf)?;
1571        if n == 0 {
1572            break;
1573        }
1574        hasher.update(&buf[..n]);
1575    }
1576    Ok(u128::from_le_bytes(
1577        hasher.finalize().as_bytes()[..16].try_into().unwrap(),
1578    ))
1579}
1580
1581/// A symlink's target as content bytes: verbatim on Unix, lossy UTF-8
1582/// elsewhere (a client-minted target is UTF-8 and round-trips exactly).
1583fn link_target_bytes(path: &Path) -> io::Result<Vec<u8>> {
1584    let target = fs::read_link(path)?;
1585    #[cfg(unix)]
1586    {
1587        use std::os::unix::ffi::OsStrExt;
1588        Ok(target.as_os_str().as_bytes().to_vec())
1589    }
1590    #[cfg(not(unix))]
1591    Ok(target.to_string_lossy().into_owned().into_bytes())
1592}
1593
1594/// Create a symlink at `at` whose target is the verbatim string `target`.
1595#[cfg(unix)]
1596fn symlink_at(target: &str, at: &Path) -> io::Result<()> {
1597    std::os::unix::fs::symlink(target, at)
1598}
1599#[cfg(windows)]
1600fn symlink_at(target: &str, at: &Path) -> io::Result<()> {
1601    // Windows symlinks are typed: pick the directory flavor when the
1602    // target resolves to a directory right now, the file flavor otherwise
1603    // (including dangling targets).
1604    let resolved = at.parent().unwrap_or_else(|| Path::new(".")).join(target);
1605    if resolved.is_dir() {
1606        std::os::windows::fs::symlink_dir(target, at)
1607    } else {
1608        std::os::windows::fs::symlink_file(target, at)
1609    }
1610}
1611#[cfg(not(any(unix, windows)))]
1612fn symlink_at(_target: &str, _at: &Path) -> io::Result<()> {
1613    Err(io::Error::from(io::ErrorKind::Unsupported))
1614}
1615
1616/// The reconciler's index key for an absolute path under `root`: each
1617/// component escaped and `/`-joined, exactly as `note_hint` derives it.
1618/// Used to key echo priming by the path the change actually lands under
1619/// (which differs from the client's wire path for a followed symlink).
1620fn wire_key_for(root: &Path, abs: &Path) -> Option<String> {
1621    let rel = abs.strip_prefix(root).ok()?;
1622    let mut wire = String::new();
1623    for comp in rel.components() {
1624        wire = join_wire(&wire, &os_to_wire(comp.as_os_str()));
1625    }
1626    Some(wire)
1627}
1628
1629/// A process-global lock keyed by a canonical filesystem path. The
1630/// compare-hash-and-write critical section serializes on the on-disk
1631/// *file*, not the `RootKey`: two writers reaching the same file through
1632/// different roots (recursive vs not, or a root and a nested root) hold
1633/// distinct `SharedRootHandle`s, so a per-root lock could not have closed
1634/// their CAS race. Distinct files still lock independently and run in
1635/// parallel. The map self-prunes dropped entries, so it stays O(live
1636/// writers).
1637fn path_write_lock(path: &Path) -> Arc<Mutex<()>> {
1638    static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, std::sync::Weak<Mutex<()>>>>> =
1639        OnceLock::new();
1640    let mut map = LOCKS.get_or_init(Default::default).lock().unwrap();
1641    if let Some(existing) = map.get(path).and_then(std::sync::Weak::upgrade) {
1642        return existing;
1643    }
1644    map.retain(|_, w| w.strong_count() > 0);
1645    let lock = Arc::new(Mutex::new(()));
1646    map.insert(path.to_path_buf(), Arc::downgrade(&lock));
1647    lock
1648}
1649
1650/// Create `target_parent` and any missing ancestors for `MKPARENTS`,
1651/// confined to `root`: the deepest existing ancestor is canonicalized and
1652/// re-checked under root, then each missing component is created (never
1653/// `create_dir_all`, which would happily descend through an existing
1654/// symlink pointing outside the root and create directories there).
1655fn create_parents_confined(root: &Path, target_parent: &Path) -> Result<(), u8> {
1656    let mut existing = target_parent.to_path_buf();
1657    let mut tail: Vec<std::ffi::OsString> = Vec::new();
1658    while !existing.exists() {
1659        let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
1660            return Err(FS_DONE_INVALID);
1661        };
1662        tail.push(name);
1663        existing = existing.parent().map(Path::to_path_buf).unwrap_or_default();
1664        if existing.as_os_str().is_empty() {
1665            return Err(FS_DONE_INVALID);
1666        }
1667    }
1668    let mut cur = fs::canonicalize(&existing).map_err(|e| write_io_status(&e))?;
1669    if !cur.starts_with(root) {
1670        return Err(FS_DONE_PERMISSION);
1671    }
1672    for name in tail.iter().rev() {
1673        cur.push(name);
1674        if let Err(e) = fs::create_dir(&cur) {
1675            // Tolerate only a REAL concurrently-created directory, never a
1676            // symlink: `symlink_metadata` does not follow the link, so a
1677            // symlink planted in this slot between the existence walk and
1678            // now is rejected instead of silently descended through.
1679            let real_dir = fs::symlink_metadata(&cur)
1680                .map(|m| m.file_type().is_dir())
1681                .unwrap_or(false);
1682            if !real_dir {
1683                return Err(write_io_status(&e));
1684            }
1685        }
1686        // Re-canonicalize and re-confirm each created component stays under
1687        // root before the next `push` descends through it — defense in depth
1688        // against a racing in-tree symlink redirecting the tail outside.
1689        match fs::canonicalize(&cur) {
1690            Ok(c) if c.starts_with(root) => cur = c,
1691            Ok(_) => return Err(FS_DONE_PERMISSION),
1692            Err(e) => return Err(write_io_status(&e)),
1693        }
1694    }
1695    Ok(())
1696}
1697
1698// ---------------------------------------------------------------------------
1699// Content-addressed blob store and delta encoding
1700// ---------------------------------------------------------------------------
1701
1702/// Content-addressed LRU cache of file bytes, keyed by BLAKE3-128 and
1703/// shared by every sync in the process: identical files cost one entry,
1704/// and delta bases are found by the hash each engine recorded for the
1705/// content its client holds. Eviction only costs efficiency — a missing
1706/// base falls back to full content.
1707pub struct BlobStore {
1708    budget: usize,
1709    total: usize,
1710    seq: u64,
1711    by_hash: std::collections::HashMap<u128, (Arc<Vec<u8>>, u64)>,
1712    by_age: BTreeMap<u64, u128>,
1713}
1714
1715impl BlobStore {
1716    pub fn new(budget: usize) -> Self {
1717        BlobStore {
1718            budget,
1719            total: 0,
1720            seq: 0,
1721            by_hash: Default::default(),
1722            by_age: Default::default(),
1723        }
1724    }
1725
1726    /// Fetch a blob and refresh its LRU position.
1727    pub fn get(&mut self, hash: u128) -> Option<Arc<Vec<u8>>> {
1728        let (data, seq) = self.by_hash.get(&hash)?.clone();
1729        self.by_age.remove(&seq);
1730        self.seq += 1;
1731        self.by_age.insert(self.seq, hash);
1732        self.by_hash.insert(hash, (data.clone(), self.seq));
1733        Some(data)
1734    }
1735
1736    /// Insert (or refresh) a blob, evicting the oldest entries past the
1737    /// budget. Blobs larger than the whole budget are not stored.
1738    pub fn put(&mut self, hash: u128, data: Arc<Vec<u8>>) {
1739        if data.len() > self.budget {
1740            return;
1741        }
1742        if self.by_hash.contains_key(&hash) {
1743            self.get(hash);
1744            return;
1745        }
1746        self.seq += 1;
1747        self.total += data.len();
1748        self.by_age.insert(self.seq, hash);
1749        self.by_hash.insert(hash, (data, self.seq));
1750        while self.total > self.budget {
1751            let (&seq, &oldest) = self
1752                .by_age
1753                .iter()
1754                .next()
1755                .expect("total > 0 implies entries");
1756            self.by_age.remove(&seq);
1757            if let Some((old, _)) = self.by_hash.remove(&oldest) {
1758                self.total -= old.len();
1759            }
1760        }
1761    }
1762}
1763
1764/// The process-wide store; budget via `BLIT_FS_BLOB_MAX` (default 256 MiB).
1765pub fn blob_store() -> &'static Mutex<BlobStore> {
1766    static STORE: OnceLock<Mutex<BlobStore>> = OnceLock::new();
1767    STORE.get_or_init(|| {
1768        Mutex::new(BlobStore::new(
1769            env_u64("BLIT_FS_BLOB_MAX", 256 * 1024 * 1024) as usize,
1770        ))
1771    })
1772}
1773
1774fn push_leb128(out: &mut Vec<u8>, mut value: u64) {
1775    loop {
1776        let byte = (value & 0x7F) as u8;
1777        value >>= 7;
1778        if value == 0 {
1779            out.push(byte);
1780            return;
1781        }
1782        out.push(byte | 0x80);
1783    }
1784}
1785
1786/// Single-span delta: the longest common prefix and suffix become `COPY`s,
1787/// the middle an `INSERT`. Covers appends, prepends, truncations, and one
1788/// contiguous in-place edit — the common shapes of saved files and logs.
1789/// Scattered edits degrade to a large `INSERT`; the caller falls back to
1790/// full content when the encoding is not clearly smaller.
1791pub fn encode_delta(base: &[u8], new: &[u8]) -> Vec<u8> {
1792    let bound = base.len().min(new.len());
1793    let mut prefix = 0;
1794    while prefix < bound && base[prefix] == new[prefix] {
1795        prefix += 1;
1796    }
1797    let mut suffix = 0;
1798    let bound = bound - prefix;
1799    while suffix < bound && base[base.len() - 1 - suffix] == new[new.len() - 1 - suffix] {
1800        suffix += 1;
1801    }
1802    let mut ops = Vec::new();
1803    if prefix > 0 {
1804        ops.push(0x01);
1805        push_leb128(&mut ops, 0);
1806        push_leb128(&mut ops, prefix as u64);
1807    }
1808    let middle = &new[prefix..new.len() - suffix];
1809    if !middle.is_empty() {
1810        ops.push(0x02);
1811        push_leb128(&mut ops, middle.len() as u64);
1812        ops.extend_from_slice(middle);
1813    }
1814    if suffix > 0 {
1815        ops.push(0x01);
1816        push_leb128(&mut ops, (base.len() - suffix) as u64);
1817        push_leb128(&mut ops, suffix as u64);
1818    }
1819    ops
1820}
1821
1822// ---------------------------------------------------------------------------
1823// Engine
1824// ---------------------------------------------------------------------------
1825
1826/// One per shared root: owns the canonical index, verifies hints against
1827/// the filesystem, and publishes immutable snapshots to subscribed sync
1828/// engines. Exits when its inbox disconnects (last handle dropped).
1829struct Reconciler {
1830    root: PathBuf,
1831    /// SINGLE mode (docs/design/fs-watch.md "Single-file sync"): `root` is
1832    /// a FILE, the index holds at most the one entry "", hints are
1833    /// filtered to the file (and its parent — some backends report
1834    /// directory-level events), and nothing is ever enumerated.
1835    single: bool,
1836    /// Scan scope from the [`RootKey`] plus env-default budgets; the
1837    /// per-client knobs in here (content, window…) are unused.
1838    opts: SyncOptions,
1839    /// What this root excludes, compiled. `None` when the spec is empty —
1840    /// an unfiltered sync must not pay a matcher call per entry — and
1841    /// always `None` in SINGLE mode, where nothing is enumerated.
1842    ignores: Option<ignores::Ignores>,
1843    rx: Receiver<RootMsg>,
1844    backend: Box<dyn BackendHandle>,
1845    canonical: Index,
1846    /// Last published snapshot; republished to every new subscriber.
1847    snapshot: Arc<Index>,
1848    subs: std::collections::HashMap<u64, (Sender<SyncMsg>, Duration)>,
1849    /// Settle window: the minimum over subscribers.
1850    latency: Duration,
1851    dirty: std::collections::BTreeSet<String>,
1852    /// Keys whose canonical entry changed since the last publish. Drives
1853    /// the publish decision (empty = nothing to publish, no compare and no
1854    /// clone) and ships with the snapshot so engines diff only these.
1855    changed: std::collections::BTreeSet<String>,
1856    /// Keys verified this tick whose stat could not prove they are
1857    /// unchanged, because their mtime falls inside the racy window
1858    /// (docs/design/fs-watch.md "Racily-clean entries"). Published even
1859    /// when `changed` is empty — an unchanged snapshot is exactly the
1860    /// symptom — so the engines can settle it against their content hashes.
1861    recheck: std::collections::BTreeSet<String>,
1862    full_rescan: bool,
1863    pending_since: Option<Instant>,
1864    /// Learned-hash changes are annotations only (invisible to the
1865    /// per-sync diff, which excludes `hash`), so they publish on a coarse
1866    /// interval rather than the settle window — otherwise the burst of
1867    /// hashes after an initial content sync would trigger one full-index
1868    /// clone + publish each.
1869    hash_dirty_since: Option<Instant>,
1870    /// Root-level failure; sticky, replayed to late subscribers.
1871    closed: Option<u8>,
1872    /// Shared with the handle so `open_root` never joins a closed root.
1873    closed_flag: Arc<OnceLock<u8>>,
1874}
1875
1876/// Coalescing window for hash-only (annotation) publishes.
1877const HASH_PUBLISH_INTERVAL: Duration = Duration::from_millis(500);
1878
1879/// Record every key whose entry differs between `old` and `new` — the
1880/// changed-key set of a full rescan, computed as a sorted merge.
1881fn record_merge_changed(
1882    old: &Index,
1883    new: &Index,
1884    changed: &mut std::collections::BTreeSet<String>,
1885) {
1886    let mut oi = old.iter().peekable();
1887    let mut ni = new.iter().peekable();
1888    loop {
1889        match (oi.peek(), ni.peek()) {
1890            (Some((ok, ov)), Some((nk, nv))) => {
1891                if ok == nk {
1892                    if ov != nv {
1893                        changed.insert((*ok).clone());
1894                    }
1895                    oi.next();
1896                    ni.next();
1897                } else if ok < nk {
1898                    changed.insert((*ok).clone());
1899                    oi.next();
1900                } else {
1901                    changed.insert((*nk).clone());
1902                    ni.next();
1903                }
1904            }
1905            (Some((ok, _)), None) => {
1906                changed.insert((*ok).clone());
1907                oi.next();
1908            }
1909            (None, Some((nk, _))) => {
1910                changed.insert((*nk).clone());
1911                ni.next();
1912            }
1913            (None, None) => break,
1914        }
1915    }
1916}
1917
1918impl Reconciler {
1919    fn new(
1920        key: RootKey,
1921        single: bool,
1922        rx: Receiver<RootMsg>,
1923        backend: Box<dyn BackendHandle>,
1924        closed_flag: Arc<OnceLock<u8>>,
1925    ) -> Self {
1926        let opts = SyncOptions {
1927            recursive: key.recursive,
1928            cross_filesystem: key.cross_filesystem,
1929            ..Default::default()
1930        };
1931        // SINGLE mode enumerates nothing, so an ignore spec has nothing to
1932        // filter; `single_root_key` normalizes it away, and this mirrors
1933        // that so the two can never disagree.
1934        let ignores = (!single && !key.ignores.is_empty())
1935            .then(|| ignores::Ignores::new(&key.path, &key.ignores));
1936        Reconciler {
1937            root: key.path,
1938            single,
1939            latency: opts.latency,
1940            opts,
1941            ignores,
1942            rx,
1943            backend,
1944            canonical: Index::new(),
1945            snapshot: Arc::new(Index::new()),
1946            subs: Default::default(),
1947            dirty: Default::default(),
1948            changed: Default::default(),
1949            recheck: Default::default(),
1950            full_rescan: false,
1951            pending_since: None,
1952            hash_dirty_since: None,
1953            closed: None,
1954            closed_flag,
1955        }
1956    }
1957
1958    fn run(mut self) {
1959        // Ignore sources above the root are read once at construction and
1960        // no hint from inside the tree could ever report them, so watch
1961        // the directories holding them — their parents, since a watch on a
1962        // file follows its inode past the rename-over an editor performs.
1963        // Armed before the scan like everything else, so an edit racing
1964        // the initial enumeration is not lost.
1965        if let Some(ignores) = &self.ignores {
1966            for dir in ignores.external_watch_dirs() {
1967                self.backend.watch_outside(&dir);
1968            }
1969        }
1970        // Initial enumeration; the watcher was armed at open, so anything
1971        // missed during the scan is already queued as a hint.
1972        match self.scan_all() {
1973            Ok(index) => {
1974                self.canonical = index;
1975                self.snapshot = Arc::new(self.canonical.clone());
1976            }
1977            Err(reason) => self.close(reason),
1978        }
1979        loop {
1980            let deadline = |since: Option<Instant>, window: Duration| {
1981                since.map(|s| (s + window).saturating_duration_since(Instant::now()))
1982            };
1983            let timeout = if self.closed.is_some() {
1984                Duration::from_secs(3600)
1985            } else {
1986                [
1987                    deadline(self.pending_since, self.latency),
1988                    deadline(self.hash_dirty_since, HASH_PUBLISH_INTERVAL),
1989                ]
1990                .into_iter()
1991                .flatten()
1992                .min()
1993                .unwrap_or(Duration::from_secs(3600))
1994            };
1995            match self.rx.recv_timeout(timeout) {
1996                Ok(RootMsg::Hint(hint)) => self.note_hint(hint),
1997                Ok(RootMsg::Subscribe { id, tx, latency }) => {
1998                    let update = match self.closed {
1999                        Some(reason) => RootUpdate::Closed(reason),
2000                        // The current snapshot is already settled; the new
2001                        // subscriber's initial series should stream at once.
2002                        None => RootUpdate::Snapshot {
2003                            index: self.snapshot.clone(),
2004                            settled: None,
2005                            changed: None,
2006                            recheck: Default::default(),
2007                        },
2008                    };
2009                    let _ = tx.send(SyncMsg::Root(update));
2010                    self.subs.insert(id, (tx, latency));
2011                    self.recompute_latency();
2012                }
2013                Ok(RootMsg::Unsubscribe { id }) => {
2014                    self.subs.remove(&id);
2015                    self.recompute_latency();
2016                }
2017                Ok(RootMsg::HashLearned { path, meta }) => {
2018                    if let Some(existing) = self.canonical.get_mut(&path)
2019                        && existing.hash != meta.hash
2020                        && existing.node_type == meta.node_type
2021                        && existing.dev_ino == meta.dev_ino
2022                        && existing.size == meta.size
2023                        && existing.mtime_ns == meta.mtime_ns
2024                    {
2025                        existing.hash = meta.hash;
2026                        self.changed.insert(path);
2027                        if self.hash_dirty_since.is_none() {
2028                            self.hash_dirty_since = Some(Instant::now());
2029                        }
2030                    }
2031                }
2032                Err(RecvTimeoutError::Timeout) => {}
2033                Err(RecvTimeoutError::Disconnected) => return,
2034            }
2035            let elapsed = |since: Option<Instant>, window: Duration| {
2036                since.is_some_and(|s| Instant::now().saturating_duration_since(s) >= window)
2037            };
2038            if self.closed.is_none()
2039                && (elapsed(self.pending_since, self.latency)
2040                    || elapsed(self.hash_dirty_since, HASH_PUBLISH_INTERVAL))
2041            {
2042                self.tick();
2043            }
2044        }
2045    }
2046
2047    /// Whether the entry at wire path `rel` is excluded from this root
2048    /// (docs/design/fs-watch.md "Ignoring"). `is_dir` must describe a
2049    /// *real* directory: git's syntax distinguishes `build` from `build/`,
2050    /// and a symlink is a file to it even when it resolves to a directory.
2051    fn ignored(&mut self, rel: &str, is_dir: bool) -> bool {
2052        match &mut self.ignores {
2053            Some(ignores) => ignores.matched(rel, is_dir),
2054            None => false,
2055        }
2056    }
2057
2058    /// Whether a write to `abs` changed the rules themselves, which costs
2059    /// a rebuild of the matcher and a full re-enumeration. An ignore file
2060    /// under an already-excluded directory is not one — nothing ever reads
2061    /// it (docs/design/fs-watch.md "Ignoring").
2062    fn ignore_rules_changed(&mut self, abs: &Path, rel: &str) -> bool {
2063        match &mut self.ignores {
2064            Some(ignores) => ignores.source_affects_rules(abs, rel),
2065            None => false,
2066        }
2067    }
2068
2069    /// Disarm every watched directory the canonical index no longer holds
2070    /// as a directory. A no-op for the recursive backends.
2071    fn retain_watched_dirs(&self) {
2072        let root = &self.root;
2073        let canonical = &self.canonical;
2074        self.backend.retain_dirs(&|abs| {
2075            wire_key_for(root, abs)
2076                .and_then(|key| canonical.get(&key).map(|m| m.node_type == FS_ENTRY_DIR))
2077                .unwrap_or(false)
2078        });
2079    }
2080
2081    fn recompute_latency(&mut self) {
2082        self.latency = self
2083            .subs
2084            .values()
2085            .map(|(_, latency)| *latency)
2086            .min()
2087            .unwrap_or(self.opts.latency);
2088    }
2089
2090    fn close(&mut self, reason: u8) {
2091        self.closed = Some(reason);
2092        // Publish before broadcasting so a racing open_root observes the
2093        // closure and spawns a fresh root rather than joining this dead one.
2094        let _ = self.closed_flag.set(reason);
2095        self.pending_since = None;
2096        for (tx, _) in self.subs.values() {
2097            let _ = tx.send(SyncMsg::Root(RootUpdate::Closed(reason)));
2098        }
2099    }
2100
2101    fn note_hint(&mut self, hint: Hint) {
2102        if self.single {
2103            // The watch sits on the parent directory, so hints arrive for
2104            // every sibling: only the file itself — or the parent, since
2105            // some backends report directory-level events for changes
2106            // inside it — re-verifies the one entry. Sibling churn returns
2107            // here without arming a settle tick, so it never wakes the
2108            // sync. Rescan degrades to the same single re-stat.
2109            let relevant = match hint {
2110                Hint::Rescan => true,
2111                Hint::Dirty(abs) => abs == self.root || Some(abs.as_path()) == self.root.parent(),
2112            };
2113            if relevant {
2114                self.dirty.insert(String::new());
2115                if self.pending_since.is_none() {
2116                    self.pending_since = Some(Instant::now());
2117                }
2118            }
2119            return;
2120        }
2121        match hint {
2122            Hint::Rescan => self.full_rescan = true,
2123            Hint::Dirty(abs) => {
2124                let rel = match abs.strip_prefix(&self.root) {
2125                    Ok(rel) => rel,
2126                    // Outside the tree — except for the ignore sources
2127                    // above it, whose watches exist precisely so that a
2128                    // parent `.gitignore` edit re-classifies this root
2129                    // instead of going unnoticed for the sync's lifetime.
2130                    Err(_) => {
2131                        if self
2132                            .ignores
2133                            .as_ref()
2134                            .is_some_and(|i| i.is_external_source(&abs))
2135                        {
2136                            if let Some(ignores) = &mut self.ignores {
2137                                ignores.invalidate();
2138                            }
2139                            self.full_rescan = true;
2140                            if self.pending_since.is_none() {
2141                                self.pending_since = Some(Instant::now());
2142                            }
2143                        }
2144                        return;
2145                    }
2146                };
2147                let mut wire = String::new();
2148                let mut depth = 0usize;
2149                for comp in rel.components() {
2150                    wire = join_wire(&wire, &os_to_wire(comp.as_os_str()));
2151                    depth += 1;
2152                }
2153                // Non-recursive syncs index the root and its immediate
2154                // children only; deeper hints are outside the sync.
2155                if !self.opts.recursive && depth > 1 {
2156                    return;
2157                }
2158                // An ignore-source edit re-classifies entries a previous
2159                // scan baked in — in both directions — so the matcher is
2160                // rebuilt and the tree re-enumerated rather than trusted.
2161                // Tested *before* the filter: `$GIT_DIR/info/exclude` sits
2162                // inside a directory the filter itself excludes, so the
2163                // other order would drop the hint that its own rules moved.
2164                if self.ignore_rules_changed(&abs, &wire) {
2165                    if let Some(ignores) = &mut self.ignores {
2166                        ignores.invalidate();
2167                    }
2168                    self.full_rescan = true;
2169                } else if self.ignored(&wire, false) {
2170                    // Excluded: dropped here, so churn under `node_modules`
2171                    // never reaches a stat, a hash, or a settle tick. A
2172                    // directory-only pattern (`build/`) does not match the
2173                    // path as a file, so the hint survives to `reconcile`,
2174                    // which stats it and excludes it there.
2175                    //
2176                    // One exception: a directory reporting no hidden
2177                    // children just gained one, and `FS_ENTRY_FILTERED`
2178                    // has to flip. Re-list that directory once — the next
2179                    // excluded child finds the flag already set and costs
2180                    // nothing, so this is one listing per transition, not
2181                    // per event. A parent that is itself excluded is not
2182                    // in the index and never qualifies.
2183                    if let Some(parent) = parent_wire(&wire)
2184                        && self.canonical.get(parent).is_some_and(|m| !m.filtered)
2185                    {
2186                        self.dirty.insert(parent.to_string());
2187                        if self.pending_since.is_none() {
2188                            self.pending_since = Some(Instant::now());
2189                        }
2190                    }
2191                    return;
2192                }
2193                self.dirty.insert(wire);
2194            }
2195        }
2196        if self.pending_since.is_none() {
2197            self.pending_since = Some(Instant::now());
2198        }
2199    }
2200
2201    /// Settle: verify accumulated dirt, publish a snapshot if anything
2202    /// (including a learned hash) changed.
2203    fn tick(&mut self) {
2204        // When real dirt drove this tick, the batch began settling at
2205        // pending_since; engines settle from that instant so the total
2206        // change-to-wire delay is one window, not two.
2207        let settled = self.pending_since;
2208        self.pending_since = None;
2209        self.hash_dirty_since = None;
2210        if self.full_rescan {
2211            self.full_rescan = false;
2212            self.dirty.clear();
2213            match self.scan_all() {
2214                Ok(index) => {
2215                    // Record the rescan's effect as changed keys (a sorted
2216                    // merge, O(n) like the scan itself) so engines still
2217                    // diff incrementally.
2218                    record_merge_changed(&self.canonical, &index, &mut self.changed);
2219                    self.canonical = index;
2220                    // A rescan re-arms everything it enumerates but reports
2221                    // no removals, so directories that vanished (or became
2222                    // excluded) since the last one are disarmed here.
2223                    self.retain_watched_dirs();
2224                    // Recompiling the rules can change which sources above
2225                    // the root they read — a `.gitignore` appearing in an
2226                    // ancestor that had none. Re-arming is a set lookup
2227                    // per directory when nothing moved.
2228                    if let Some(ignores) = &self.ignores {
2229                        for dir in ignores.external_watch_dirs() {
2230                            self.backend.watch_outside(&dir);
2231                        }
2232                    }
2233                }
2234                Err(reason) => return self.close(reason),
2235            }
2236        } else {
2237            let dirty = std::mem::take(&mut self.dirty);
2238            for rel in dirty {
2239                if let Err(reason) = self.reconcile(&rel) {
2240                    return self.close(reason);
2241                }
2242            }
2243        }
2244        // Drop keys that reverted within the window: a tick whose
2245        // verification found no net change publishes nothing — and pays
2246        // neither the full-index compare nor the clone.
2247        let prev_snapshot = self.snapshot.clone();
2248        self.changed
2249            .retain(|k| self.canonical.get(k) != prev_snapshot.get(k));
2250        // A racily-clean entry publishes even when the snapshot is
2251        // identical: an identical snapshot is precisely the symptom, and
2252        // only the engines' content hashes can tell it from a real no-op.
2253        if !self.changed.is_empty() || !self.recheck.is_empty() {
2254            let changed = Arc::new(std::mem::take(&mut self.changed));
2255            let recheck = Arc::new(std::mem::take(&mut self.recheck));
2256            if !changed.is_empty() {
2257                self.snapshot = Arc::new(self.canonical.clone());
2258            }
2259            for (tx, _) in self.subs.values() {
2260                let _ = tx.send(SyncMsg::Root(RootUpdate::Snapshot {
2261                    index: self.snapshot.clone(),
2262                    settled,
2263                    changed: Some(changed.clone()),
2264                    recheck: recheck.clone(),
2265                }));
2266            }
2267        }
2268    }
2269
2270    fn scan_all(&mut self) -> Result<Index, u8> {
2271        if self.single {
2272            return self.scan_single();
2273        }
2274        let mut index = Index::new();
2275        let root = self.root.clone();
2276        self.scan_into(&mut index, &root, "", self.opts.recursive, None)
2277            .map_err(|e| match e.kind() {
2278                io::ErrorKind::NotFound => FS_CLOSED_ROOT_GONE,
2279                io::ErrorKind::PermissionDenied => FS_CLOSED_PERMISSION_LOST_COMPAT,
2280                _ if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => FS_CLOSED_RESOURCE_LIMIT,
2281                _ => FS_CLOSED_RESOURCE_LIMIT,
2282            })?;
2283        Ok(index)
2284    }
2285
2286    /// SINGLE-mode snapshot: stat the one file, never enumerate. The file's
2287    /// own absence is state, not failure — deletes and recreates flow as
2288    /// DELETE/UPSERT of "" — but a vanished PARENT means the watch itself
2289    /// is dead and no recreate could ever be observed, so that closes the
2290    /// sync (docs/design/fs-watch.md "Single-file sync").
2291    fn scan_single(&self) -> Result<Index, u8> {
2292        let mut index = Index::new();
2293        match stat_meta(&self.root) {
2294            Ok(meta) => {
2295                index.insert(String::new(), meta);
2296            }
2297            Err(e) => {
2298                if !self.root.parent().map(Path::exists).unwrap_or(false) {
2299                    return Err(FS_CLOSED_ROOT_GONE);
2300                }
2301                if e.kind() == io::ErrorKind::PermissionDenied {
2302                    return Err(FS_CLOSED_PERMISSION_LOST_COMPAT);
2303                }
2304                // NotFound (or transient): the file is absent right now;
2305                // the mirror is empty until a hint observes a recreate.
2306            }
2307        }
2308        Ok(index)
2309    }
2310
2311    /// SINGLE-mode verification of the one entry, preserving a learned
2312    /// hash across metadata-only changes exactly as directory reconcile
2313    /// does. Same absence/parent-gone split as [`Reconciler::scan_single`].
2314    fn reconcile_single(&mut self) -> Result<(), u8> {
2315        match stat_meta(&self.root) {
2316            Ok(meta) => {
2317                let preserved = self
2318                    .canonical
2319                    .get("")
2320                    .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2321                let mut meta = meta;
2322                if let Some(h) = preserved {
2323                    meta.hash = h;
2324                    self.note_racy("", &meta);
2325                }
2326                self.index_insert(String::new(), meta);
2327            }
2328            Err(e) => {
2329                if !self.root.parent().map(Path::exists).unwrap_or(false) {
2330                    return Err(FS_CLOSED_ROOT_GONE);
2331                }
2332                if e.kind() == io::ErrorKind::PermissionDenied {
2333                    return Err(FS_CLOSED_PERMISSION_LOST_COMPAT);
2334                }
2335                self.index_remove("");
2336            }
2337        }
2338        Ok(())
2339    }
2340
2341    /// Scan `abs` (wire path `rel`) into `index`. `root_dev` bounds
2342    /// cross-filesystem traversal; directories are registered with the
2343    /// backend as they are discovered.
2344    fn scan_into(
2345        &mut self,
2346        index: &mut Index,
2347        abs: &Path,
2348        rel: &str,
2349        recurse: bool,
2350        root_dev: Option<u64>,
2351    ) -> io::Result<()> {
2352        let mut ancestors = Vec::new();
2353        self.scan_into_inner(index, abs, rel, recurse, root_dev, &mut ancestors)?;
2354        Ok(())
2355    }
2356
2357    /// `ancestors` holds the `(dev, ino)` identity of every directory on the
2358    /// current descent path, which is what makes following symlinks safe: a
2359    /// link whose target is already an ancestor is a cycle and is reported
2360    /// without being descended. Identity comes from the stat already performed,
2361    /// so this costs nothing extra for the common case.
2362    fn scan_into_inner(
2363        &mut self,
2364        index: &mut Index,
2365        abs: &Path,
2366        rel: &str,
2367        recurse: bool,
2368        root_dev: Option<u64>,
2369        ancestors: &mut Vec<(u64, u64)>,
2370        // `true` = the entry was excluded rather than indexed, which the
2371        // caller records on the parent directory as `FS_ENTRY_FILTERED`.
2372    ) -> io::Result<bool> {
2373        let meta = stat_meta(abs)?;
2374        // Excluded: not indexed, not descended, not counted against the
2375        // entry budget. A symlink to a directory counts as a directory
2376        // here — git would call it a file, but git also does not descend
2377        // it, and this sync does: a `build/` that could not exclude a
2378        // symlinked `build` would leave one hole through which a whole
2379        // subtree still gets mirrored.
2380        if !rel.is_empty() && self.ignored(rel, meta.enumerable_dir()) {
2381            return Ok(true);
2382        }
2383        if index.len() >= self.opts.max_entries {
2384            return Err(io::Error::from_raw_os_error(RESOURCE_LIMIT_ERRNO));
2385        }
2386        let node_type = meta.node_type;
2387        let link_dir = meta.link_dir;
2388        let self_id = meta.dev_ino;
2389        let dev = meta.dev_ino.0;
2390        index.insert(rel.to_string(), meta);
2391
2392        // A symlink to a directory is still reported as FS_ENTRY_SYMLINK — its
2393        // content stays the target bytes (docs/design/fs-watch.md "Links") —
2394        // but it is enumerated so the file browser can descend it. Without
2395        // this a symlinked directory is a dead end: an entry with children
2396        // that can never be listed.
2397        let (descend_id, dev) = if node_type == FS_ENTRY_SYMLINK {
2398            if !link_dir {
2399                return Ok(false); // dangling, or a link to a file
2400            }
2401            let Ok(target) = fs::metadata(abs) else {
2402                return Ok(false);
2403            };
2404            let id = target_identity(&target);
2405            if id == (0, 0) {
2406                // No usable identity means no way to detect a cycle. Report the
2407                // link, but do not risk descending forever.
2408                return Ok(false);
2409            }
2410            if ancestors.contains(&id) {
2411                return Ok(false); // the link points back up its own path
2412            }
2413            (id, id.0)
2414        } else if node_type == FS_ENTRY_DIR {
2415            (self_id, dev)
2416        } else {
2417            return Ok(false);
2418        };
2419
2420        ancestors.push(descend_id);
2421        let real_dir = node_type == FS_ENTRY_DIR;
2422        let result =
2423            self.scan_children(index, abs, rel, recurse, root_dev, dev, real_dir, ancestors);
2424        ancestors.pop();
2425        result.map(|()| false)
2426    }
2427
2428    #[allow(clippy::too_many_arguments)]
2429    fn scan_children(
2430        &mut self,
2431        index: &mut Index,
2432        abs: &Path,
2433        rel: &str,
2434        recurse: bool,
2435        root_dev: Option<u64>,
2436        dev: u64,
2437        // `real_dir`: a real directory, not a symlink to one. Only real
2438        // directories are armed — the recursive watch never followed links
2439        // either (`backend::watcher`, which explains why an aliased path
2440        // gets no descriptor), and arming both an alias and its target
2441        // hands inotify the same descriptor twice.
2442        real_dir: bool,
2443        ancestors: &mut Vec<(u64, u64)>,
2444    ) -> io::Result<()> {
2445        let root_dev = root_dev.or(Some(dev));
2446        if !self.opts.cross_filesystem && Some(dev) != root_dev {
2447            return Ok(()); // report the mount point, don't descend
2448        }
2449        // Arm before listing: an entry created in the gap is either listed
2450        // by the read below or reported by the watch, never neither. Watch
2451        // exhaustion closes the root — the alternative is a subtree that
2452        // silently stops updating.
2453        if real_dir && !self.backend.add_dir(abs) {
2454            return Err(io::Error::from_raw_os_error(RESOURCE_LIMIT_ERRNO));
2455        }
2456        if !recurse && !rel.is_empty() {
2457            return Ok(());
2458        }
2459        let entries = match fs::read_dir(abs) {
2460            Ok(e) => e,
2461            Err(_) => return Ok(()), // unreadable dir: node stays, children unknown
2462        };
2463        let mut filtered = false;
2464        for entry in entries.flatten() {
2465            let name = os_to_wire(&entry.file_name());
2466            let child_rel = join_wire(rel, &name);
2467            let child_abs = entry.path();
2468            // Non-recursive syncs index immediate children only.
2469            let child_recurse = self.opts.recursive;
2470            match self.scan_into_inner(
2471                index,
2472                &child_abs,
2473                &child_rel,
2474                child_recurse,
2475                root_dev,
2476                ancestors,
2477            ) {
2478                Ok(excluded) => filtered |= excluded,
2479                Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => return Err(e),
2480                Err(_) => {}
2481            }
2482            // Other errors: entry vanished mid-scan — fine, a hint follows.
2483        }
2484        // Tell the client this listing is incomplete by design. Written
2485        // onto the directory *after* its children, since only the walk
2486        // knows what the rules covered.
2487        if filtered && let Some(dir) = index.get_mut(rel) {
2488            dir.filtered = true;
2489        }
2490        Ok(())
2491    }
2492
2493    /// Flag a verified-but-unprovable entry: its stat matched the one on
2494    /// record, yet its mtime is recent enough that a rewrite could have
2495    /// landed in the same timestamp granule and left size, identity and
2496    /// mtime all untouched (docs/design/fs-watch.md "Racily-clean
2497    /// entries"). Only content-carrying entries are worth flagging — a
2498    /// directory's bytes are its children, which get their own hints — and
2499    /// only engines can settle it, by hashing what they last sent.
2500    fn note_racy(&mut self, key: &str, meta: &NodeMeta) {
2501        if matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) && racily_clean(meta.mtime_ns)
2502        {
2503            self.recheck.insert(key.to_string());
2504        }
2505    }
2506
2507    /// Insert `meta` at `key`, recording the key as changed when the entry
2508    /// actually differs (hash included — an adopted hash must publish).
2509    fn index_insert(&mut self, key: String, meta: NodeMeta) {
2510        if self.canonical.get(&key) != Some(&meta) {
2511            self.changed.insert(key.clone());
2512            self.canonical.insert(key, meta);
2513        }
2514    }
2515
2516    fn index_remove(&mut self, key: &str) {
2517        let Some(meta) = self.canonical.remove(key) else {
2518            return;
2519        };
2520        self.changed.insert(key.to_string());
2521        // A directory leaving the index — deleted, replaced by a file, or
2522        // newly excluded — takes its watch with it. Every removal path
2523        // funnels through here, so none of them can leak one.
2524        if meta.node_type == FS_ENTRY_DIR
2525            && let Some(abs) = resolve_wire_path(&self.root, key)
2526        {
2527            self.backend.remove_dir(&abs);
2528        }
2529    }
2530
2531    /// Remove `rel` and everything under it via a range scan of the sorted
2532    /// index (never a full-index filter). `keep_root` retains `rel` itself.
2533    fn remove_index_subtree(&mut self, rel: &str, keep_root: bool) {
2534        for key in subtree_keys(&self.canonical, rel) {
2535            if keep_root && key == rel {
2536                continue;
2537            }
2538            self.index_remove(&key);
2539        }
2540    }
2541
2542    /// The device the sync root lives on, which bounds cross-filesystem
2543    /// descent.
2544    ///
2545    /// Reconcile paths must pass this rather than `None`: `scan_children`
2546    /// re-anchors a `None` bound to whatever device it is handed, and for a
2547    /// symlink that is the *target's* device — silently lifting the guard for
2548    /// the whole foreign subtree. The reconcile pre-check cannot catch it
2549    /// either, because a symlink's own `dev_ino` comes from `lstat` and so
2550    /// reports the device the link itself lives on, not its target's.
2551    fn root_device(&self) -> Option<u64> {
2552        self.canonical.get("").map(|m| m.dev_ino.0)
2553    }
2554
2555    /// Verify one hinted path against the canonical index.
2556    fn reconcile(&mut self, rel: &str) -> Result<(), u8> {
2557        if self.single {
2558            // note_hint only ever dirties "" in single mode.
2559            return self.reconcile_single();
2560        }
2561        let Some(abs) = resolve_wire_path(&self.root, rel) else {
2562            return Ok(());
2563        };
2564        match stat_meta(&abs) {
2565            Err(_) => {
2566                if rel.is_empty() {
2567                    return Err(FS_CLOSED_ROOT_GONE);
2568                }
2569                self.remove_index_subtree(rel, false);
2570            }
2571            Ok(meta) => {
2572                // Newly excluded — a `.gitignore` grew a line, or a
2573                // directory-only pattern that the hint stage could not
2574                // settle without a stat. Whatever the index holds under
2575                // it goes, so the client sees one DELETE of the subtree.
2576                if self.ignored(rel, meta.enumerable_dir()) {
2577                    self.remove_index_subtree(rel, false);
2578                    // The parent now has a hidden child. Only a listing can
2579                    // clear this again, which the next enumeration of that
2580                    // directory does; setting it from one path is the
2581                    // cheap half of the answer and never the wrong way
2582                    // round.
2583                    if let Some(parent) = parent_wire(rel)
2584                        && let Some(meta) = self.canonical.get(parent)
2585                        && !meta.filtered
2586                    {
2587                        let mut meta = meta.clone();
2588                        meta.filtered = true;
2589                        self.index_insert(parent.to_string(), meta);
2590                    }
2591                    return Ok(());
2592                }
2593                // Cross-filesystem exclusion (docs/fs-watch.md): mirror
2594                // scan_into on the hint path. A foreign-device entry is
2595                // kept only if it is the mount point itself (parent on the
2596                // root device) — reported but not descended; anything
2597                // deeper is never indexed, and a stale subtree from a prior
2598                // cross-fs pass is pruned. Without this, a hint under a
2599                // mount point would index entries a full rescan then
2600                // mass-deletes.
2601                if !self.opts.cross_filesystem
2602                    && !rel.is_empty()
2603                    && let Some(root_dev) = self.canonical.get("").map(|m| m.dev_ino.0)
2604                    && meta.dev_ino.0 != root_dev
2605                {
2606                    let parent_on_root = parent_wire(rel)
2607                        .and_then(|p| self.canonical.get(p))
2608                        .is_some_and(|m| m.dev_ino.0 == root_dev);
2609                    if parent_on_root {
2610                        self.index_insert(rel.to_string(), meta);
2611                        self.check_budget()?;
2612                    } else {
2613                        self.remove_index_subtree(rel, false);
2614                    }
2615                    return Ok(());
2616                }
2617                let known = self.canonical.contains_key(rel);
2618                let was_dir = self
2619                    .canonical
2620                    .get(rel)
2621                    .map(|m| m.enumerable_dir())
2622                    .unwrap_or(false);
2623                let is_dir = meta.enumerable_dir();
2624                let preserved_hash = self
2625                    .canonical
2626                    .get(rel)
2627                    .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2628                // `filtered` is the enumeration's answer, not the inode's,
2629                // so a fresh stat knows nothing about it: carry it forward
2630                // rather than clearing it on every re-verification. The
2631                // listing below (or the next one) is what corrects it.
2632                let was_filtered = self.canonical.get(rel).is_some_and(|m| m.filtered);
2633                let mut meta = meta;
2634                meta.filtered = was_filtered;
2635                if let Some(h) = preserved_hash {
2636                    meta.hash = h;
2637                    self.note_racy(rel, &meta);
2638                }
2639                self.index_insert(rel.to_string(), meta);
2640                self.check_budget()?;
2641                if is_dir && (!known || !was_dir) {
2642                    // New (or type-changed) directory: index its subtree and
2643                    // then rescan once more — children created between the
2644                    // watch registration and this scan produce duplicate
2645                    // hints, which reconcile to no-ops.
2646                    let mut sub = Index::new();
2647                    let bound = self.root_device();
2648                    match self.scan_into(&mut sub, &abs, rel, self.opts.recursive, bound) {
2649                        Ok(()) => {}
2650                        Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => {
2651                            return Err(FS_CLOSED_RESOURCE_LIMIT);
2652                        }
2653                        // Other errors: entry vanished mid-scan; a hint follows.
2654                        Err(_) => {}
2655                    }
2656                    for (k, v) in sub {
2657                        self.index_insert(k, v);
2658                    }
2659                    self.check_budget()?;
2660                } else if is_dir && self.opts.recursive {
2661                    // Existing dir: verify immediate children (names may
2662                    // have appeared/vanished without their own hints on
2663                    // some backends).
2664                    self.reconcile_children(&abs, rel)?;
2665                }
2666                if was_dir && !is_dir {
2667                    self.remove_index_subtree(rel, true);
2668                }
2669            }
2670        }
2671        Ok(())
2672    }
2673
2674    /// `FS_CLOSED_RESOURCE_LIMIT` once the index grows past the entry
2675    /// budget. Incremental reconcile must enforce this too, not just the
2676    /// initial scan (docs/fs-watch.md limits table), or a tree that grows
2677    /// live past `BLIT_FS_MAX_ENTRIES` would index without bound.
2678    fn check_budget(&self) -> Result<(), u8> {
2679        if self.canonical.len() > self.opts.max_entries {
2680            Err(FS_CLOSED_RESOURCE_LIMIT)
2681        } else {
2682            Ok(())
2683        }
2684    }
2685
2686    fn reconcile_children(&mut self, abs: &Path, rel: &str) -> Result<(), u8> {
2687        let Ok(entries) = fs::read_dir(abs) else {
2688            return Ok(());
2689        };
2690        let mut seen: std::collections::HashSet<String> = Default::default();
2691        let mut new_dirs: Vec<(PathBuf, String)> = Vec::new();
2692        let mut filtered = false;
2693        for entry in entries.flatten() {
2694            let name = os_to_wire(&entry.file_name());
2695            let child_rel = join_wire(rel, &name);
2696            if let Ok(meta) = stat_meta(&entry.path()) {
2697                // An excluded child is neither indexed nor marked seen, so
2698                // whatever the index still holds under it is pruned with
2699                // the vanished ones below — the transition a path makes
2700                // when an ignore rule starts covering it.
2701                if self.ignored(&child_rel, meta.enumerable_dir()) {
2702                    filtered = true;
2703                    continue;
2704                }
2705                let newly_dir = meta.enumerable_dir()
2706                    && self
2707                        .canonical
2708                        .get(&child_rel)
2709                        .map(|m| !m.enumerable_dir())
2710                        .unwrap_or(true);
2711                let preserved = self
2712                    .canonical
2713                    .get(&child_rel)
2714                    .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2715                let mut meta = meta;
2716                // As in `reconcile`: a stat cannot see what a listing of
2717                // *this child's* children found, so keep the last answer.
2718                meta.filtered = self.canonical.get(&child_rel).is_some_and(|m| m.filtered);
2719                if let Some(h) = preserved {
2720                    meta.hash = h;
2721                    self.note_racy(&child_rel, &meta);
2722                }
2723                if newly_dir {
2724                    new_dirs.push((entry.path(), child_rel.clone()));
2725                }
2726                self.index_insert(child_rel.clone(), meta);
2727                self.check_budget()?;
2728            }
2729            seen.insert(child_rel);
2730        }
2731        // This listing saw every child, so it is also the authority on
2732        // whether any were excluded — including when the answer flips back
2733        // to `false` because the last one was deleted or un-ignored.
2734        if let Some(dir) = self.canonical.get(rel)
2735            && dir.filtered != filtered
2736        {
2737            let mut meta = dir.clone();
2738            meta.filtered = filtered;
2739            self.index_insert(rel.to_string(), meta);
2740        }
2741        // Children that disappeared, with their subtrees: one range walk
2742        // over `rel`'s subtree keyed by first component, instead of a
2743        // full-index scan per gone child.
2744        let prefix = if rel.is_empty() {
2745            String::new()
2746        } else {
2747            format!("{rel}/")
2748        };
2749        let gone: Vec<String> = self
2750            .canonical
2751            .range(prefix.clone()..)
2752            .take_while(|(k, _)| k.starts_with(&prefix))
2753            .filter(|(k, _)| {
2754                k.as_str() != rel && {
2755                    let rest = &k[prefix.len()..];
2756                    let child_end = prefix.len() + rest.find('/').unwrap_or(rest.len());
2757                    !seen.contains(&k[..child_end])
2758                }
2759            })
2760            .map(|(k, _)| k.clone())
2761            .collect();
2762        for k in gone {
2763            self.index_remove(&k);
2764        }
2765        let bound = self.root_device();
2766        for (abs, rel) in new_dirs {
2767            let mut sub = Index::new();
2768            match self.scan_into(&mut sub, &abs, &rel, self.opts.recursive, bound) {
2769                Ok(()) => {}
2770                Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => {
2771                    return Err(FS_CLOSED_RESOURCE_LIMIT);
2772                }
2773                Err(_) => {}
2774            }
2775            for (k, v) in sub {
2776                self.index_insert(k, v);
2777            }
2778            self.check_budget()?;
2779        }
2780        Ok(())
2781    }
2782}
2783
2784enum Exit {
2785    ClientGone,
2786    Closed(u8),
2787    Stopped,
2788}
2789
2790enum ContentRead {
2791    Stable { hash: u128, data: Arc<Vec<u8>> },
2792    Unstable,
2793    Unreadable,
2794}
2795
2796/// Backoff state for one file awaiting a settled re-read.
2797struct RetryEntry {
2798    /// Consecutive UNSTABLE/UNREADABLE outcomes.
2799    failures: u32,
2800    /// Earliest instant the next re-read may run.
2801    due: Instant,
2802}
2803
2804/// Delay before the next re-read of an UNSTABLE/UNREADABLE entry: one
2805/// settle window after the first failure, doubling per consecutive
2806/// failure, capped so a file that never settles (an actively appended log
2807/// under a content sync) costs a bounded re-read rate instead of up to two
2808/// full reads every tick forever.
2809fn retry_backoff(failures: u32, latency: Duration) -> Duration {
2810    const RETRY_BACKOFF_CAP: Duration = Duration::from_secs(2);
2811    latency
2812        .saturating_mul(
2813            1u32.checked_shl(failures.saturating_sub(1))
2814                .unwrap_or(u32::MAX),
2815        )
2816        .min(RETRY_BACKOFF_CAP)
2817}
2818
2819/// Per-sync engine: cuts client-specific update series from published
2820/// snapshots and paces them against the client's ack window.
2821struct SyncEngine {
2822    sync_id: u16,
2823    root: PathBuf,
2824    /// SINGLE sync: `root` is a FILE and the only addressable wire path —
2825    /// for fetches and the write family alike — is "" (the root itself).
2826    single: bool,
2827    opts: SyncOptions,
2828    rx: Receiver<SyncMsg>,
2829    outbox: Outbox,
2830    shared: Arc<SharedRootHandle>,
2831    sub_id: u64,
2832    /// Latest published canonical snapshot.
2833    latest: Arc<Index>,
2834    /// A snapshot arrived since the last emit.
2835    snapshot_dirty: bool,
2836    /// What the client's live map will equal once it applies everything
2837    /// sent so far (reliable ordered transport ⇒ no acknowledgment needed
2838    /// for correctness, only for pacing).
2839    shadow: Arc<Index>,
2840    pending_since: Option<Instant>,
2841    next_update_id: u32,
2842    /// Highest update id ever sent; acking beyond it is a protocol error.
2843    highest_sent: u32,
2844    /// (update_id, serialized_bytes) not yet cumulatively acked.
2845    unacked: std::collections::VecDeque<(u32, usize)>,
2846    unacked_bytes: usize,
2847    initial_sent: bool,
2848    /// Hash of the content the client holds per path (updates are ordered
2849    /// over a reliable transport, so "sent" is "held"). Basis for delta
2850    /// encoding and for skipping content the client already has.
2851    held: std::collections::HashMap<String, u128>,
2852    /// Files last reported UNSTABLE/UNREADABLE: re-read once their backoff
2853    /// expires even though their metadata may not change again.
2854    retry: BTreeMap<String, RetryEntry>,
2855    /// Keys changed across the snapshots received since the last emit; the
2856    /// incremental diff probes only these.
2857    pending_changed: std::collections::BTreeSet<String>,
2858    /// Keys the reconciler could not settle from stat alone; this engine
2859    /// settles them by hashing (docs/design/fs-watch.md "Racily-clean
2860    /// entries").
2861    pending_recheck: std::collections::BTreeSet<String>,
2862    /// A snapshot arrived without a changed set: the next emit must fall
2863    /// back to the full two-map walk.
2864    full_diff: bool,
2865}
2866
2867impl SyncEngine {
2868    fn new(
2869        sync_id: u16,
2870        shared: Arc<SharedRootHandle>,
2871        sub_id: u64,
2872        opts: SyncOptions,
2873        rx: Receiver<SyncMsg>,
2874        outbox: Outbox,
2875    ) -> Self {
2876        SyncEngine {
2877            sync_id,
2878            root: shared.key.path.clone(),
2879            single: shared.single,
2880            opts,
2881            rx,
2882            outbox,
2883            shared,
2884            sub_id,
2885            latest: Arc::new(Index::new()),
2886            snapshot_dirty: false,
2887            shadow: Arc::new(Index::new()),
2888            pending_since: None,
2889            next_update_id: 1,
2890            highest_sent: 0,
2891            unacked: Default::default(),
2892            unacked_bytes: 0,
2893            initial_sent: false,
2894            held: Default::default(),
2895            retry: Default::default(),
2896            pending_changed: Default::default(),
2897            pending_recheck: Default::default(),
2898            full_diff: false,
2899        }
2900    }
2901
2902    fn run(mut self) {
2903        let exit = self.event_loop();
2904        let _ = self
2905            .shared
2906            .tx
2907            .send(RootMsg::Unsubscribe { id: self.sub_id });
2908        match exit {
2909            Exit::ClientGone => {}
2910            Exit::Stopped => {
2911                self.drain_pending_commands();
2912                let _ = (self.outbox)(msg_fs_closed(self.sync_id, FS_CLOSED_CLIENT_REQUEST));
2913            }
2914            Exit::Closed(reason) => {
2915                self.drain_pending_commands();
2916                let _ = (self.outbox)(msg_fs_closed(self.sync_id, reason));
2917            }
2918        }
2919    }
2920
2921    /// Answer every request still queued when the engine exits so the
2922    /// family's one-reply-per-nonce invariant holds on the close path too.
2923    /// Client Commands and reconciler RootUpdates share one inbox, so a
2924    /// Write/Op/Fetch enqueued behind (or racing) the Closed message would
2925    /// otherwise be dropped with its InflightGuard and never answered.
2926    /// Requests arriving after the engine's receiver drops instead see
2927    /// `SyncHandle::command` return false, and the server answers them.
2928    fn drain_pending_commands(&mut self) {
2929        while let Ok(msg) = self.rx.try_recv() {
2930            match msg {
2931                SyncMsg::Cmd(Command::Write(w)) => {
2932                    let _ = (self.outbox)(msg_fs_done(w.nonce, FS_DONE_OTHER, 0, 0));
2933                }
2934                SyncMsg::Cmd(Command::Op(o)) => {
2935                    let _ = (self.outbox)(msg_fs_done(o.nonce, FS_DONE_OTHER, 0, 0));
2936                }
2937                SyncMsg::Cmd(Command::Fetch { nonce, .. }) => {
2938                    let _ = (self.outbox)(msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]));
2939                }
2940                SyncMsg::Cmd(Command::Ack(_) | Command::Stop) | SyncMsg::Root(_) => {}
2941            }
2942        }
2943    }
2944
2945    fn event_loop(&mut self) -> Exit {
2946        loop {
2947            // Settle deadline only matters while we hold send credit; when
2948            // credit-blocked, only an ack (or command) can unblock us, so
2949            // wait for messages instead of spinning on an expired deadline.
2950            let timeout = match self.pending_since {
2951                Some(since) if self.unacked_bytes < self.opts.window_bytes => {
2952                    (since + self.opts.latency).saturating_duration_since(Instant::now())
2953                }
2954                _ => Duration::from_secs(3600),
2955            };
2956            match self.rx.recv_timeout(timeout) {
2957                Ok(SyncMsg::Root(update)) => {
2958                    if let Err(exit) = self.handle_root(update) {
2959                        return exit;
2960                    }
2961                }
2962                Ok(SyncMsg::Cmd(Command::Ack(update_id))) => {
2963                    if let Err(exit) = self.handle_ack(update_id) {
2964                        return exit;
2965                    }
2966                }
2967                Ok(SyncMsg::Cmd(Command::Fetch { nonce, path })) => {
2968                    if !self.handle_fetch(nonce, &path) {
2969                        return Exit::ClientGone;
2970                    }
2971                }
2972                Ok(SyncMsg::Cmd(Command::Write(w))) => {
2973                    if !self.handle_write(w) {
2974                        return Exit::ClientGone;
2975                    }
2976                }
2977                Ok(SyncMsg::Cmd(Command::Op(o))) => {
2978                    if !self.handle_op(o) {
2979                        return Exit::ClientGone;
2980                    }
2981                }
2982                Ok(SyncMsg::Cmd(Command::Stop)) => return Exit::Stopped,
2983                Err(RecvTimeoutError::Timeout) => {}
2984                Err(RecvTimeoutError::Disconnected) => return Exit::ClientGone,
2985            }
2986            // Tick when settled and credit allows.
2987            if let Some(since) = self.pending_since
2988                && Instant::now().saturating_duration_since(since) >= self.opts.latency
2989                && self.unacked_bytes < self.opts.window_bytes
2990                && let Err(exit) = self.tick()
2991            {
2992                return exit;
2993            }
2994        }
2995    }
2996
2997    fn handle_root(&mut self, update: RootUpdate) -> Result<(), Exit> {
2998        match update {
2999            RootUpdate::Snapshot {
3000                index,
3001                settled,
3002                changed,
3003                recheck,
3004            } => {
3005                self.latest = index;
3006                self.snapshot_dirty = true;
3007                // Independent of `changed`/`full_diff`: a full two-map walk
3008                // is just as blind to a same-stat rewrite as the
3009                // changed-key probe is.
3010                self.pending_recheck.extend(recheck.iter().cloned());
3011                match changed {
3012                    // Per-snapshot sets cover consecutive publishes, so
3013                    // their union covers shadow → latest exactly.
3014                    Some(set) if !self.full_diff => {
3015                        self.pending_changed.extend(set.iter().cloned());
3016                    }
3017                    Some(_) => {}
3018                    None => {
3019                        self.full_diff = true;
3020                        self.pending_changed.clear();
3021                    }
3022                }
3023                // Settle from when the reconciler's batch began, not now:
3024                // the reconciler already waited one window, so re-waiting
3025                // here would double the change-to-wire latency. `None`
3026                // (already settled) emits at once.
3027                let due = settled.unwrap_or_else(|| {
3028                    Instant::now()
3029                        .checked_sub(self.opts.latency)
3030                        .unwrap_or_else(Instant::now)
3031                });
3032                self.pending_since = Some(match self.pending_since {
3033                    Some(existing) if existing <= due => existing,
3034                    _ => due,
3035                });
3036                Ok(())
3037            }
3038            RootUpdate::Closed(reason) => Err(Exit::Closed(reason)),
3039        }
3040    }
3041
3042    /// Cumulative ack. Comparisons use serial-number (wrap-aware)
3043    /// arithmetic so acking survives the `update_id` counter wrapping at
3044    /// 2^32: in-flight ids span at most a few windows, far under 2^31, so
3045    /// "strictly ahead of the highest sent id" is unambiguous. Acking
3046    /// genuinely ahead is still a fatal protocol error.
3047    fn handle_ack(&mut self, update_id: u32) -> Result<(), Exit> {
3048        let ahead = update_id.wrapping_sub(self.highest_sent);
3049        if ahead != 0 && ahead < 0x8000_0000 {
3050            return Err(Exit::Closed(FS_CLOSED_BACKEND_FAILED_COMPAT));
3051        }
3052        while let Some(&(id, bytes)) = self.unacked.front() {
3053            // id is at or before update_id in wrap order.
3054            if update_id.wrapping_sub(id) < 0x8000_0000 {
3055                self.unacked.pop_front();
3056                self.unacked_bytes -= bytes;
3057            } else {
3058                break;
3059            }
3060        }
3061        Ok(())
3062    }
3063
3064    fn tick(&mut self) -> Result<(), Exit> {
3065        self.pending_since = None;
3066        if self.initial_sent && !self.snapshot_dirty && self.retry.is_empty() {
3067            return Ok(());
3068        }
3069        let canonical = self.latest.clone();
3070        let initial = !self.initial_sent;
3071        self.snapshot_dirty = false;
3072        let full = std::mem::take(&mut self.full_diff);
3073        let changed = std::mem::take(&mut self.pending_changed);
3074        let recheck = std::mem::take(&mut self.pending_recheck);
3075        self.emit_updates(&canonical, initial, full, &changed, &recheck)?;
3076        self.shadow = canonical;
3077        self.initial_sent = true;
3078        // Credit waits may have delivered a newer snapshot mid-emit, and
3079        // unstable files want another pass: keep the clock running — for
3080        // retries, only until the earliest backoff expires (the tick fires
3081        // one latency window after `pending_since`).
3082        if self.snapshot_dirty {
3083            self.pending_since = Some(Instant::now());
3084        } else if let Some(due) = self.retry.values().map(|e| e.due).min() {
3085            self.pending_since = Some(
3086                due.checked_sub(self.opts.latency)
3087                    .unwrap_or_else(Instant::now),
3088            );
3089        }
3090        Ok(())
3091    }
3092
3093    /// Diff shadow vs `canonical` and send updates. `initial` wraps the
3094    /// series in RESET … SYNC; `full` forces the two-map walk, otherwise
3095    /// only `changed` keys are probed. Batches stream as they are built,
3096    /// each gated on the ack window — a snapshot of any size holds at most
3097    /// one batch in memory and never outruns the client's credit.
3098    fn emit_updates(
3099        &mut self,
3100        canonical: &Arc<Index>,
3101        initial: bool,
3102        full: bool,
3103        changed: &std::collections::BTreeSet<String>,
3104        recheck: &std::collections::BTreeSet<String>,
3105    ) -> Result<(), Exit> {
3106        if initial {
3107            // The initial series carries every file's content, so nothing
3108            // pending can be stale.
3109            return self.emit_initial(canonical);
3110        }
3111        let mut ops = if full {
3112            diff(&self.shadow, canonical)
3113        } else {
3114            diff_changed(&self.shadow, canonical, changed)
3115        };
3116        // A retry entry whose file was renamed this tick must follow the
3117        // move before we prune against `canonical` (which only knows the
3118        // new path), or the pending content read is lost forever.
3119        for op in &ops {
3120            if let DiffOp::Move { from, to } = op {
3121                self.rekey_move(from, to);
3122            }
3123        }
3124        self.retry.retain(|path, _| canonical.contains_key(path));
3125        // Files awaiting a settled re-read (UNSTABLE or transiently
3126        // UNREADABLE) re-read even when their metadata is unchanged, so the
3127        // content still arrives once the file settles — but only once each
3128        // entry's backoff has expired.
3129        let now = Instant::now();
3130        let forced: Vec<String> = self
3131            .retry
3132            .iter()
3133            .filter(|(path, entry)| {
3134                entry.due <= now
3135                    && !ops
3136                        .iter()
3137                        .any(|op| matches!(op, DiffOp::Upsert { path: p, .. } if p == *path))
3138            })
3139            .map(|(path, _)| path.clone())
3140            .collect();
3141        ops.extend(forced.into_iter().map(|path| DiffOp::Upsert {
3142            path,
3143            content_changed: true,
3144        }));
3145        // Entries the reconciler could not settle from stat alone: hash
3146        // them against what the client holds, which is the only thing that
3147        // can tell a same-granule rewrite from a genuine no-op. Bytes just
3148        // written are in page cache, and a matching hash emits nothing.
3149        let racy: Vec<String> = recheck
3150            .iter()
3151            .filter(|path| {
3152                !ops.iter()
3153                    .any(|op| matches!(op, DiffOp::Upsert { path: p, .. } if p == *path))
3154                    && self.content_diverged(path, canonical)
3155            })
3156            .cloned()
3157            .collect();
3158        ops.extend(racy.into_iter().map(|path| DiffOp::Upsert {
3159            path,
3160            content_changed: true,
3161        }));
3162        if ops.is_empty() {
3163            return Ok(());
3164        }
3165
3166        let mut buf: Vec<u8> = Vec::new();
3167        let mut reset_pending = false;
3168        for op in &ops {
3169            match op {
3170                DiffOp::Delete { path } => {
3171                    self.held.retain(|held_path, _| !is_under(held_path, path));
3172                    append_fs_record(&mut buf, &FsRecord::Delete { path });
3173                }
3174                DiffOp::Move { from, to } => {
3175                    // held/retry were already rekeyed above.
3176                    append_fs_record(&mut buf, &FsRecord::Move { from, to });
3177                }
3178                DiffOp::Upsert {
3179                    path,
3180                    content_changed,
3181                } => {
3182                    if let Some(meta) = canonical.get(path) {
3183                        self.append_upsert(&mut buf, path, meta, *content_changed);
3184                    }
3185                }
3186            }
3187            if buf.len() >= self.opts.batch_target {
3188                self.send_update(std::mem::take(&mut buf), &mut reset_pending, false)?;
3189            }
3190        }
3191        if !buf.is_empty() {
3192            self.send_update(buf, &mut reset_pending, false)?;
3193        }
3194        Ok(())
3195    }
3196
3197    /// Stream the initial `RESET … SYNC` series straight off the snapshot:
3198    /// every entry is an upsert, so the series borrows paths and metadata
3199    /// from the index instead of materializing a whole-tree op list. The
3200    /// final update carries SYNC (an empty RESET|SYNC update is valid and
3201    /// terminates an empty tree's snapshot).
3202    fn emit_initial(&mut self, canonical: &Arc<Index>) -> Result<(), Exit> {
3203        let mut buf: Vec<u8> = Vec::new();
3204        let mut reset_pending = true;
3205        let index: &Index = canonical;
3206        for (path, meta) in index.iter() {
3207            self.append_upsert(&mut buf, path, meta, true);
3208            if buf.len() >= self.opts.batch_target {
3209                self.send_update(std::mem::take(&mut buf), &mut reset_pending, false)?;
3210            }
3211        }
3212        self.send_update(buf, &mut reset_pending, true)?;
3213        Ok(())
3214    }
3215
3216    /// Append one upsert record for `path`/`meta`, attaching content per
3217    /// the sync's options and maintaining the held/retry maps. Emits
3218    /// nothing for a still-churning retry the client already knows about.
3219    fn append_upsert(
3220        &mut self,
3221        buf: &mut Vec<u8>,
3222        path: &str,
3223        meta: &NodeMeta,
3224        content_changed: bool,
3225    ) {
3226        let prior_failures = self.retry.remove(path).map(|e| e.failures).unwrap_or(0);
3227        let was_retry = prior_failures > 0;
3228        let mut entry_flags = meta.node_type & FS_ENTRY_TYPE_MASK;
3229        if meta.link_dir {
3230            entry_flags |= FS_ENTRY_LINK_DIR;
3231        }
3232        if meta.filtered {
3233            entry_flags |= FS_ENTRY_FILTERED;
3234        }
3235        let mut hash = meta.hash;
3236        let mut full: Option<Arc<Vec<u8>>> = None;
3237        let mut delta: Option<Vec<u8>> = None;
3238        // Files and symlinks both carry content — a symlink's is its
3239        // target bytes (hash = BLAKE3-128 over them).
3240        if matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) {
3241            // An inlined file's bytes ride an FS_UPDATE, whose
3242            // decompressed payload a compliant client refuses above
3243            // FS_MAX_DECOMPRESSED — so never inline past that cap
3244            // regardless of the (client-supplied) inline_max, else
3245            // the update is undecodable and the sync wedges.
3246            let inline_cap = self
3247                .opts
3248                .inline_max
3249                .min(blit_remote::fs::FS_MAX_DECOMPRESSED as u64);
3250            if !self.opts.content || meta.size > inline_cap {
3251                entry_flags |= FS_ENTRY_NO_CONTENT;
3252                self.held.remove(path);
3253            } else if content_changed || meta.hash == 0 {
3254                match self.read_content(path, meta) {
3255                    ContentRead::Stable {
3256                        hash: read_hash,
3257                        data,
3258                    } => {
3259                        hash = read_hash;
3260                        if self.held.get(path) == Some(&hash) {
3261                            // The client already holds exactly
3262                            // these bytes (touch, or a rewrite
3263                            // with identical content): metadata-
3264                            // only upsert, the mirror keeps them.
3265                        } else {
3266                            // Delta against the content the
3267                            // client holds when the base is
3268                            // still in the blob store and the
3269                            // encoding is clearly smaller.
3270                            delta = self
3271                                .held
3272                                .get(path)
3273                                .and_then(|&base_hash| blob_store().lock().unwrap().get(base_hash))
3274                                .map(|base| encode_delta(&base, &data))
3275                                .filter(|ops| ops.len() * 8 < data.len() * 7);
3276                            if delta.is_none() {
3277                                full = Some(data.clone());
3278                            }
3279                            self.held.insert(path.to_string(), hash);
3280                        }
3281                    }
3282                    ContentRead::Unstable => {
3283                        self.held.remove(path);
3284                        self.note_retry(path, prior_failures);
3285                        if was_retry {
3286                            // Still churning: the client already
3287                            // knows; try again after the backoff.
3288                            return;
3289                        }
3290                        entry_flags |= FS_ENTRY_UNSTABLE;
3291                    }
3292                    ContentRead::Unreadable => {
3293                        // The read raced a delete/permission
3294                        // flip between the reconciler's stat and
3295                        // our read. Re-read after the backoff so a
3296                        // transiently unreadable file still
3297                        // converges; diff alone would never
3298                        // revisit it (stat may be unchanged).
3299                        self.held.remove(path);
3300                        self.note_retry(path, prior_failures);
3301                        if was_retry {
3302                            return;
3303                        }
3304                        entry_flags |= FS_ENTRY_UNREADABLE;
3305                    }
3306                }
3307            }
3308            // Metadata-only change on a file whose content the
3309            // client already holds: no content section, no
3310            // NO_CONTENT flag — the mirror keeps its bytes.
3311        }
3312        let content = match (&delta, &full) {
3313            (Some(ops), _) => FsContent::Delta(ops),
3314            (None, Some(data)) => FsContent::Full(data.as_slice()),
3315            (None, None) => FsContent::None,
3316        };
3317        append_fs_record(
3318            buf,
3319            &FsRecord::Upsert {
3320                path,
3321                entry_flags,
3322                size: meta.size,
3323                mtime_ns: meta.mtime_ns,
3324                mode: meta.mode,
3325                hash,
3326                content,
3327            },
3328        );
3329    }
3330
3331    /// Schedule the next re-read of a failed content read, doubling the
3332    /// per-entry delay per consecutive failure.
3333    fn note_retry(&mut self, path: &str, prior_failures: u32) {
3334        let failures = prior_failures + 1;
3335        self.retry.insert(
3336            path.to_string(),
3337            RetryEntry {
3338                failures,
3339                due: Instant::now() + retry_backoff(failures, self.opts.latency),
3340            },
3341        );
3342    }
3343
3344    /// Whether `path`'s bytes on disk differ from the bytes the client
3345    /// holds — the question the reconciler could not answer for a
3346    /// racily-clean entry (docs/design/fs-watch.md).
3347    ///
3348    /// Reads the file rather than going through [`Self::read_content`]: the
3349    /// blob store and the learned-hash map are both keyed on the stat that
3350    /// is under suspicion here, so consulting either would answer with the
3351    /// stale bytes it is our job to catch. Files the client has no content
3352    /// for — a metadata-only sync, or one over the inline cap — answer
3353    /// "no": there is nothing of theirs that could be stale.
3354    fn content_diverged(&self, path: &str, canonical: &Index) -> bool {
3355        if !self.opts.content {
3356            return false;
3357        }
3358        let Some(&held) = self.held.get(path) else {
3359            return false;
3360        };
3361        let Some(meta) = canonical.get(path) else {
3362            return false;
3363        };
3364        if !matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) {
3365            return false;
3366        }
3367        let Some(abs) = resolve_wire_path(&self.root, path) else {
3368            return false;
3369        };
3370        match read_verified_meta(&abs) {
3371            ReadMetaOutcome::Stable(data, _) => blake3_128(&data) != held,
3372            // Churning or unreadable: the ordinary retry path owns it, and
3373            // guessing "changed" here would resend on every tick.
3374            ReadMetaOutcome::Unstable | ReadMetaOutcome::Unreadable => false,
3375        }
3376    }
3377
3378    /// Content for one file: from the blob store when any sync has already
3379    /// hashed these bytes, from a verified disk read otherwise — feeding
3380    /// the store and teaching the reconciler the hash so other syncs skip
3381    /// the read entirely.
3382    fn read_content(&self, path: &str, meta: &NodeMeta) -> ContentRead {
3383        if meta.hash != 0
3384            && let Some(data) = blob_store().lock().unwrap().get(meta.hash)
3385        {
3386            return ContentRead::Stable {
3387                hash: meta.hash,
3388                data,
3389            };
3390        }
3391        // The snapshot may predate another sync's hash learning (hash
3392        // publishes coalesce): consult the shared learned map, validated
3393        // against this snapshot's stat exactly as the reconciler validates
3394        // HashLearned, so a concurrent content sync serves from the blob
3395        // store instead of re-reading the tree.
3396        if meta.hash == 0
3397            && let Some(learned) = self.shared.learned.lock().unwrap().get(path).cloned()
3398            && learned.hash != 0
3399            && learned.node_type == meta.node_type
3400            && learned.dev_ino == meta.dev_ino
3401            && learned.size == meta.size
3402            && learned.mtime_ns == meta.mtime_ns
3403            && let Some(data) = blob_store().lock().unwrap().get(learned.hash)
3404        {
3405            return ContentRead::Stable {
3406                hash: learned.hash,
3407                data,
3408            };
3409        }
3410        let Some(abs) = resolve_wire_path(&self.root, path) else {
3411            return ContentRead::Unreadable;
3412        };
3413        match read_verified_meta(&abs) {
3414            ReadMetaOutcome::Stable(data, mut stat) => {
3415                let hash = blake3_128(&data);
3416                let data = Arc::new(data);
3417                blob_store().lock().unwrap().put(hash, data.clone());
3418                stat.hash = hash;
3419                // Racily-clean guard (docs/fs-watch.md): a file whose mtime
3420                // is within one coarse granule of now could be rewritten
3421                // again inside the same granule without changing its stat.
3422                // Don't teach the reconciler such a hash, or another sync
3423                // could later serve stale bytes by it. The blob store still
3424                // caches the bytes (only reachable via a matching hash).
3425                if !racily_clean(stat.mtime_ns) {
3426                    self.teach_hash(path, stat);
3427                }
3428                ContentRead::Stable { hash, data }
3429            }
3430            ReadMetaOutcome::Unstable => ContentRead::Unstable,
3431            ReadMetaOutcome::Unreadable => ContentRead::Unreadable,
3432        }
3433    }
3434
3435    /// Teach the reconciler (and, immediately, sibling engines via the
3436    /// shared learned map) a verified content hash.
3437    fn teach_hash(&self, path: &str, meta: NodeMeta) {
3438        {
3439            let mut learned = self.shared.learned.lock().unwrap();
3440            // Coarse bound: entries only bridge the hash-publish window,
3441            // so dropping them all merely costs a re-read.
3442            if learned.len() >= 65536 {
3443                learned.clear();
3444            }
3445            learned.insert(path.to_string(), meta.clone());
3446        }
3447        let _ = self.shared.tx.send(RootMsg::HashLearned {
3448            path: path.to_string(),
3449            meta,
3450        });
3451    }
3452
3453    /// Rename the `from` subtree to `to` in the held-content map and the
3454    /// retry set, mirroring what a `MOVE` record does to the client's map.
3455    /// Keeping `retry` in step is essential: a file that was reported
3456    /// `UNSTABLE` and then renamed within the same settle window must still
3457    /// be re-read at its new path, or its content never arrives.
3458    fn rekey_move(&mut self, from: &str, to: &str) {
3459        let moved: Vec<(String, u128)> = self
3460            .held
3461            .iter()
3462            .filter(|(path, _)| is_under(path, from))
3463            .map(|(path, &hash)| (path.clone(), hash))
3464            .collect();
3465        for (path, _) in &moved {
3466            self.held.remove(path);
3467        }
3468        for (path, hash) in moved {
3469            self.held.insert(rebase_subtree_path(&path, from, to), hash);
3470        }
3471        for path in subtree_keys(&self.retry, from) {
3472            if let Some(entry) = self.retry.remove(&path) {
3473                self.retry
3474                    .insert(rebase_subtree_path(&path, from, to), entry);
3475            }
3476        }
3477    }
3478
3479    /// Send one update, first blocking until the ack window has credit.
3480    fn send_update(
3481        &mut self,
3482        records: Vec<u8>,
3483        reset_pending: &mut bool,
3484        sync: bool,
3485    ) -> Result<(), Exit> {
3486        self.wait_for_credit()?;
3487        let mut flags = 0u8;
3488        if *reset_pending {
3489            flags |= FS_UPDATE_RESET;
3490            *reset_pending = false;
3491        }
3492        if sync {
3493            flags |= FS_UPDATE_SYNC;
3494        }
3495        let update_id = self.next_update_id;
3496        self.next_update_id = self.next_update_id.wrapping_add(1);
3497        self.highest_sent = update_id;
3498        let msg = msg_fs_update(self.sync_id, update_id, flags, &records);
3499        self.unacked.push_back((update_id, msg.len()));
3500        self.unacked_bytes += msg.len();
3501        if !(self.outbox)(msg) {
3502            return Err(Exit::ClientGone);
3503        }
3504        Ok(())
3505    }
3506
3507    /// Block until unacked bytes drop under the window. Commands are served
3508    /// while waiting; snapshots accumulate for the next tick.
3509    fn wait_for_credit(&mut self) -> Result<(), Exit> {
3510        while self.unacked_bytes >= self.opts.window_bytes {
3511            match self.rx.recv() {
3512                Ok(SyncMsg::Cmd(Command::Ack(id))) => self.handle_ack(id)?,
3513                Ok(SyncMsg::Cmd(Command::Fetch { nonce, path })) => {
3514                    if !self.handle_fetch(nonce, &path) {
3515                        return Err(Exit::ClientGone);
3516                    }
3517                }
3518                Ok(SyncMsg::Cmd(Command::Write(w))) => {
3519                    if !self.handle_write(w) {
3520                        return Err(Exit::ClientGone);
3521                    }
3522                }
3523                Ok(SyncMsg::Cmd(Command::Op(o))) => {
3524                    if !self.handle_op(o) {
3525                        return Err(Exit::ClientGone);
3526                    }
3527                }
3528                Ok(SyncMsg::Cmd(Command::Stop)) => return Err(Exit::Stopped),
3529                Ok(SyncMsg::Root(update)) => self.handle_root(update)?,
3530                Err(_) => return Err(Exit::ClientGone),
3531            }
3532        }
3533        Ok(())
3534    }
3535
3536    fn handle_fetch(&mut self, nonce: u16, wire_path: &str) -> bool {
3537        if self.single {
3538            // A SINGLE sync's namespace holds exactly one path, "" — the
3539            // root file itself, already canonical, so `confine_target`'s
3540            // parent-of-target check (built for paths *under* a directory
3541            // root) does not apply. Anything else does not exist.
3542            let msg = if wire_path.is_empty() {
3543                let root = self.root.clone();
3544                self.fetch_confined(nonce, &root)
3545            } else {
3546                msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3547            };
3548            return (self.outbox)(msg);
3549        }
3550        // Confine exactly as the write path does: resolve_wire_path alone
3551        // validates components but performs no symlink resolution, so an
3552        // in-tree symlink in an intermediate component would let fs::read
3553        // follow it out of root (arbitrary file read). Canonicalizing the
3554        // parent and re-checking starts_with(root) closes that.
3555        let msg = match confine_target(&self.root, wire_path) {
3556            Err(ConfineError::Invalid) => msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[]),
3557            Err(ConfineError::Io(e)) if e.kind() == io::ErrorKind::NotFound => {
3558                msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3559            }
3560            Err(ConfineError::Io(_)) => msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3561            Err(ConfineError::Escapes) => msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]),
3562            Ok(abs) => self.fetch_confined(nonce, &abs),
3563        };
3564        (self.outbox)(msg)
3565    }
3566
3567    /// Read a confined fetch target. Only regular files and symlinks carry
3568    /// fetchable content (a symlink's is its own target bytes, never the
3569    /// file it points at); refusing fifos/devices/sockets keeps `fs::read`
3570    /// from blocking the engine thread forever on a device node reached
3571    /// through the tree.
3572    fn fetch_confined(&self, nonce: u16, abs: &Path) -> Vec<u8> {
3573        let md = match fs::symlink_metadata(abs) {
3574            Ok(md) => md,
3575            Err(e) if e.kind() == io::ErrorKind::NotFound => {
3576                return msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[]);
3577            }
3578            Err(_) => return msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3579        };
3580        let ft = md.file_type();
3581        if !ft.is_file() && !ft.is_symlink() {
3582            return msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]);
3583        }
3584        // Refuse oversized files before reading a byte: an FS_FILE whose
3585        // decompressed payload exceeds the protocol cap could not be parsed
3586        // by a compliant client anyway, and reading it would spike transient
3587        // memory unbounded (docs/fs-watch.md).
3588        if ft.is_file() && md.len() > blit_remote::fs::FS_MAX_DECOMPRESSED as u64 {
3589            return msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]);
3590        }
3591        match read_verified(abs) {
3592            ReadOutcome::Stable(data) => msg_fs_file(nonce, FS_FILE_OK, &data),
3593            ReadOutcome::Unstable => msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3594            ReadOutcome::Unreadable => {
3595                if abs.exists() {
3596                    msg_fs_file(nonce, FS_FILE_UNREADABLE, &[])
3597                } else {
3598                    msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3599                }
3600            }
3601        }
3602    }
3603
3604    /// Resolve a write-family target for this sync. A SINGLE sync's
3605    /// namespace is exactly one path — the empty wire path, naming the
3606    /// root file — so any other path answers INVALID, and the
3607    /// final-component symlink policy applies to the root itself (a
3608    /// symlink can be renamed over it after validation). A followed
3609    /// symlink's resolution necessarily leaves the one-file namespace, so
3610    /// Follow refuses it exactly as an out-of-root target under a
3611    /// directory sync.
3612    fn resolve_target(&self, wire: &str, policy: SymlinkPolicy) -> Result<PathBuf, u8> {
3613        if !self.single {
3614            return resolve_write_target(&self.root, wire, policy);
3615        }
3616        if !wire.is_empty() {
3617            return Err(FS_DONE_INVALID);
3618        }
3619        match fs::symlink_metadata(&self.root) {
3620            Ok(md) if md.file_type().is_symlink() => match policy {
3621                SymlinkPolicy::Refuse => Err(FS_DONE_PERMISSION),
3622                SymlinkPolicy::Operate => Ok(self.root.clone()),
3623                SymlinkPolicy::Follow => {
3624                    let resolved = fs::canonicalize(&self.root).map_err(|e| write_io_status(&e))?;
3625                    if resolved == self.root {
3626                        Ok(resolved)
3627                    } else {
3628                        Err(FS_DONE_PERMISSION)
3629                    }
3630                }
3631            },
3632            _ => Ok(self.root.clone()),
3633        }
3634    }
3635
3636    fn handle_write(&mut self, w: WriteReq) -> bool {
3637        let (status, hash, mtime_ns) = self.exec_write(&w);
3638        (self.outbox)(msg_fs_done(w.nonce, status, hash, mtime_ns))
3639    }
3640
3641    /// Land a content write under the target's per-file write lock: confine
3642    /// the path, enforce the CAS precondition against the freshly re-read
3643    /// live hash — for `FS_WRITE_CONTENT_DELTA`, apply the instruction
3644    /// stream against the verified base bytes — write atomically (or
3645    /// create-exclusive), then prime the echo.
3646    fn exec_write(&mut self, w: &WriteReq) -> (u8, u128, u64) {
3647        use blit_remote::fs::{FS_WRITE_CONTENT_DELTA, FS_WRITE_CONTENT_FULL, apply_fs_delta};
3648        let is_delta = w.content_kind == FS_WRITE_CONTENT_DELTA;
3649        // Kinds 0/1 are full bytes, 2 a delta; anything else is a future
3650        // encoding this server does not speak.
3651        if !is_delta && w.content_kind != 0 && w.content_kind != FS_WRITE_CONTENT_FULL {
3652            return (FS_DONE_INVALID, 0, 0);
3653        }
3654        let no_cas = w.flags & FS_WRITE_NO_CAS != 0;
3655        // A delta applies against the exact bytes the CAS `base` names
3656        // (docs/design/fs-write.md "Wire"): NO_CAS has no precondition and
3657        // a zero base means "absent", so neither can anchor one.
3658        if is_delta && (no_cas || w.base == 0) {
3659            return (FS_DONE_INVALID, 0, 0);
3660        }
3661        if w.content.len() as u64 > fs_write_max() {
3662            return (FS_DONE_TOO_LARGE, 0, 0);
3663        }
3664        // A SINGLE sync's target is the root file itself: its parent exists
3665        // by construction (the watch sits on it), so MKPARENTS is a no-op.
3666        if !self.single
3667            && w.flags & FS_WRITE_MKPARENTS != 0
3668            && let Some(parent) = resolve_wire_path(&self.root, &w.path)
3669                .and_then(|a| a.parent().map(Path::to_path_buf))
3670            && let Err(status) = create_parents_confined(&self.root, &parent)
3671        {
3672            return (status, 0, 0);
3673        }
3674        let policy = if w.flags & FS_WRITE_FOLLOW_SYMLINK != 0 {
3675            SymlinkPolicy::Follow
3676        } else {
3677            SymlinkPolicy::Refuse
3678        };
3679        let target = match self.resolve_target(&w.path, policy) {
3680            Ok(t) => t,
3681            Err(status) => return (status, 0, 0),
3682        };
3683        let durable = w.flags & FS_WRITE_DURABLE != 0;
3684
3685        // Serialize check-and-write against every other blit writer of this
3686        // exact file — including ones reaching it through a different root.
3687        // The guard owns its Arc, leaving `self` free for the `&mut self`
3688        // echo priming below.
3689        let lock = path_write_lock(&target);
3690        let _guard = lock.lock().unwrap();
3691
3692        // Never clobber a directory with a file.
3693        if fs::symlink_metadata(&target)
3694            .map(|m| m.is_dir())
3695            .unwrap_or(false)
3696        {
3697            return (FS_DONE_WRONG_TYPE, 0, 0);
3698        }
3699
3700        let create_exclusive_mode = !no_cas && w.base == 0;
3701        // CAS check — and, for a delta, base production. The two are one
3702        // verified read: the target's current content must hash to `base`
3703        // (else CONFLICT carrying the live hash, exactly as a full write),
3704        // and those verified bytes ARE the delta base. A base the server
3705        // cannot produce is therefore precisely a failed precondition —
3706        // a corrupted apply is impossible by construction.
3707        let applied: Option<Vec<u8>> = if is_delta {
3708            // Bound the base read like the write payload: the on-disk file
3709            // is unbounded, and an unbounded `fs::read` would let a tiny
3710            // request force an arbitrarily large allocation (full-write
3711            // CAS streams its hash for the same reason).
3712            match fs::symlink_metadata(&target) {
3713                Ok(md) if md.len() > fs_write_max() => return (FS_DONE_TOO_LARGE, 0, 0),
3714                Err(e) if e.kind() == io::ErrorKind::NotFound => {
3715                    // Absent target: a non-zero base cannot match; the
3716                    // conflict hash is the "absent" zero sentinel.
3717                    return (FS_DONE_CONFLICT, 0, 0);
3718                }
3719                _ => {}
3720            }
3721            let base = match read_verified_meta(&target) {
3722                ReadMetaOutcome::Stable(data, _) => data,
3723                // Actively churning under an external writer: the
3724                // precondition cannot be confirmed, and applying blind
3725                // could corrupt.
3726                ReadMetaOutcome::Unstable => return (FS_DONE_OTHER, 0, 0),
3727                ReadMetaOutcome::Unreadable => {
3728                    return if target.exists() {
3729                        (FS_DONE_OTHER, 0, 0)
3730                    } else {
3731                        (FS_DONE_CONFLICT, 0, 0)
3732                    };
3733                }
3734            };
3735            let cur = blake3_128(&base);
3736            if cur != w.base {
3737                return (FS_DONE_CONFLICT, cur, 0);
3738            }
3739            let Some(applied) = apply_fs_delta(&base, &w.content) else {
3740                // Malformed instruction stream.
3741                return (FS_DONE_INVALID, 0, 0);
3742            };
3743            if applied.len() as u64 > fs_write_max() {
3744                return (FS_DONE_TOO_LARGE, 0, 0);
3745            }
3746            Some(applied)
3747        } else {
3748            if !no_cas {
3749                if w.base == 0 {
3750                    if target.exists() {
3751                        return (FS_DONE_CONFLICT, current_hash(&target), 0);
3752                    }
3753                } else {
3754                    let cur = current_hash(&target);
3755                    if cur != w.base {
3756                        return (FS_DONE_CONFLICT, cur, 0);
3757                    }
3758                }
3759            }
3760            None
3761        };
3762        let content: &[u8] = applied.as_deref().unwrap_or(&w.content);
3763
3764        let hash = blake3_128(content);
3765        if create_exclusive_mode {
3766            match create_exclusive(&target, content, w.mode, durable) {
3767                Ok(()) => {}
3768                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3769                    return (FS_DONE_CONFLICT, current_hash(&target), 0);
3770                }
3771                Err(e) => return (write_io_status(&e), 0, 0),
3772            }
3773        } else if let Err(e) = write_atomic(&target, content, w.mode, durable) {
3774            return (write_io_status(&e), 0, 0);
3775        }
3776
3777        let mtime_ns = stat_meta(&target).map(|m| m.mtime_ns).unwrap_or(0);
3778        // Key the echo by the path the write actually landed under — which
3779        // is the resolved target, not the client's wire path, when a
3780        // symlink was followed. Otherwise the two coincide.
3781        let echo_wire = wire_key_for(&self.root, &target).unwrap_or_else(|| w.path.clone());
3782        self.prime_echo(&echo_wire, &target, hash, content, mtime_ns);
3783        (FS_DONE_OK, hash, mtime_ns)
3784    }
3785
3786    fn handle_op(&mut self, o: OpReq) -> bool {
3787        let (status, hash, mtime_ns) = self.exec_op(&o);
3788        (self.outbox)(msg_fs_done(o.nonce, status, hash, mtime_ns))
3789    }
3790
3791    /// Execute a metadata op (mkdir/remove/rename), each under the affected
3792    /// path's per-file write lock.
3793    fn exec_op(&mut self, o: &OpReq) -> (u8, u128, u64) {
3794        match o.op {
3795            FS_OP_MKDIR => {
3796                if !self.single
3797                    && o.flags & FS_OP_MKPARENTS != 0
3798                    && let Some(parent) = resolve_wire_path(&self.root, &o.a)
3799                        .and_then(|a| a.parent().map(Path::to_path_buf))
3800                    && let Err(status) = create_parents_confined(&self.root, &parent)
3801                {
3802                    return (status, 0, 0);
3803                }
3804                let target = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3805                    Ok(t) => t,
3806                    Err(status) => return (status, 0, 0),
3807                };
3808                let lock = path_write_lock(&target);
3809                let _guard = lock.lock().unwrap();
3810                let mut builder = fs::DirBuilder::new();
3811                #[cfg(unix)]
3812                if o.mode != 0 {
3813                    use std::os::unix::fs::DirBuilderExt;
3814                    builder.mode(o.mode);
3815                }
3816                match builder.create(&target) {
3817                    Ok(()) => {}
3818                    // Idempotent when the path is already a directory.
3819                    Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3820                        if !target.is_dir() {
3821                            return (FS_DONE_CONFLICT, 0, 0);
3822                        }
3823                    }
3824                    Err(e) => return (write_io_status(&e), 0, 0),
3825                }
3826                let mtime_ns = stat_meta(&target).map(|m| m.mtime_ns).unwrap_or(0);
3827                self.hint_change(&target);
3828                (FS_DONE_OK, 0, mtime_ns)
3829            }
3830            FS_OP_REMOVE => {
3831                let target = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3832                    Ok(t) => t,
3833                    Err(status) => return (status, 0, 0),
3834                };
3835                let lock = path_write_lock(&target);
3836                let _guard = lock.lock().unwrap();
3837                let md = match fs::symlink_metadata(&target) {
3838                    Ok(m) => m,
3839                    Err(e) if e.kind() == io::ErrorKind::NotFound => {
3840                        return (FS_DONE_NOT_FOUND, 0, 0);
3841                    }
3842                    Err(e) => return (write_io_status(&e), 0, 0),
3843                };
3844                // Conditional remove is meaningful only for a regular file.
3845                if o.flags & FS_OP_NO_CAS == 0 && o.base != 0 {
3846                    let cur = current_hash(&target);
3847                    if cur != o.base {
3848                        return (FS_DONE_CONFLICT, cur, 0);
3849                    }
3850                }
3851                let res = if md.file_type().is_dir() {
3852                    fs::remove_dir_all(&target)
3853                } else {
3854                    // A symlink is unlinked, never followed.
3855                    fs::remove_file(&target)
3856                };
3857                if let Err(e) = res {
3858                    return (write_io_status(&e), 0, 0);
3859                }
3860                self.hint_change(&target);
3861                (FS_DONE_OK, 0, 0)
3862            }
3863            FS_OP_RENAME => {
3864                if !self.single
3865                    && o.flags & FS_OP_MKPARENTS != 0
3866                    && let Some(parent) = resolve_wire_path(&self.root, &o.b)
3867                        .and_then(|a| a.parent().map(Path::to_path_buf))
3868                    && let Err(status) = create_parents_confined(&self.root, &parent)
3869                {
3870                    return (status, 0, 0);
3871                }
3872                let from = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3873                    Ok(t) => t,
3874                    Err(status) => return (status, 0, 0),
3875                };
3876                let lock = path_write_lock(&from);
3877                let _guard = lock.lock().unwrap();
3878                if fs::symlink_metadata(&from).is_err() {
3879                    return (FS_DONE_NOT_FOUND, 0, 0);
3880                }
3881                let to = match self.resolve_target(&o.b, SymlinkPolicy::Operate) {
3882                    Ok(t) => t,
3883                    Err(status) => return (status, 0, 0),
3884                };
3885                if let Err(e) = fs::rename(&from, &to) {
3886                    return (write_io_status(&e), 0, 0);
3887                }
3888                self.hint_change(&from);
3889                self.hint_change(&to);
3890                (FS_DONE_OK, 0, 0)
3891            }
3892            FS_OP_SYMLINK | FS_OP_HARDLINK => self.exec_link(o),
3893            _ => (FS_DONE_INVALID, 0, 0),
3894        }
3895    }
3896
3897    /// Create a link at `b`: a symlink whose target is the verbatim string
3898    /// `a` (`SYMLINK`), or a hard link to the regular file at `a`
3899    /// (`HARDLINK`). `base` CASes on the entry currently at `b` exactly as
3900    /// a write's `base` does on its path — zero = create-exclusive,
3901    /// non-zero = replace iff the current content hash matches (a symlink
3902    /// hashes its target bytes), `NO_CAS` = unconditional. Replacement is
3903    /// atomic: the new link lands at a sibling temp path and renames over
3904    /// `b`, so a reader sees the old entry or the new, never neither.
3905    fn exec_link(&mut self, o: &OpReq) -> (u8, u128, u64) {
3906        if !self.single
3907            && o.flags & FS_OP_MKPARENTS != 0
3908            && let Some(parent) =
3909                resolve_wire_path(&self.root, &o.b).and_then(|b| b.parent().map(Path::to_path_buf))
3910            && let Err(status) = create_parents_confined(&self.root, &parent)
3911        {
3912            return (status, 0, 0);
3913        }
3914        // A hard-link source is a confined wire path and must be a regular
3915        // file (aliasing a symlink or a directory is refused). A symlink
3916        // target is a verbatim string stored as given: in-tree relative,
3917        // absolute, and dangling targets are all legitimate symlinks — the
3918        // read side reports them, never follows (docs/design/fs-watch.md).
3919        let src = if o.op == FS_OP_HARDLINK {
3920            let src = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3921                Ok(t) => t,
3922                Err(status) => return (status, 0, 0),
3923            };
3924            match fs::symlink_metadata(&src) {
3925                Ok(md) if md.file_type().is_file() => {}
3926                Ok(_) => return (FS_DONE_WRONG_TYPE, 0, 0),
3927                Err(e) if e.kind() == io::ErrorKind::NotFound => {
3928                    return (FS_DONE_NOT_FOUND, 0, 0);
3929                }
3930                Err(e) => return (write_io_status(&e), 0, 0),
3931            }
3932            Some(src)
3933        } else {
3934            if o.a.is_empty() {
3935                return (FS_DONE_INVALID, 0, 0);
3936            }
3937            None
3938        };
3939        let link = match self.resolve_target(&o.b, SymlinkPolicy::Operate) {
3940            Ok(t) => t,
3941            Err(status) => return (status, 0, 0),
3942        };
3943        let lock = path_write_lock(&link);
3944        let _guard = lock.lock().unwrap();
3945        // Never clobber a directory with a link (a symlink *to* a directory
3946        // at `b` is itself a link entry and may be replaced).
3947        if fs::symlink_metadata(&link)
3948            .map(|m| m.is_dir())
3949            .unwrap_or(false)
3950        {
3951            return (FS_DONE_WRONG_TYPE, 0, 0);
3952        }
3953        let no_cas = o.flags & FS_OP_NO_CAS != 0;
3954        let create_exclusive_mode = !no_cas && o.base == 0;
3955        if !no_cas {
3956            if o.base == 0 {
3957                // symlink_metadata, not exists(): a dangling symlink at `b`
3958                // is an entry and must fail create-exclusive.
3959                if fs::symlink_metadata(&link).is_ok() {
3960                    return (FS_DONE_CONFLICT, current_hash(&link), 0);
3961                }
3962            } else {
3963                let cur = current_hash(&link);
3964                if cur != o.base {
3965                    return (FS_DONE_CONFLICT, cur, 0);
3966                }
3967            }
3968        }
3969        let create = |at: &Path| -> io::Result<()> {
3970            match &src {
3971                Some(src) => fs::hard_link(src, at),
3972                None => symlink_at(&o.a, at),
3973            }
3974        };
3975        if create_exclusive_mode {
3976            // symlink()/link() fail EEXIST natively, so create-exclusive is
3977            // race-free even against an external creator.
3978            match create(&link) {
3979                Ok(()) => {}
3980                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3981                    return (FS_DONE_CONFLICT, current_hash(&link), 0);
3982                }
3983                Err(e) => return (write_io_status(&e), 0, 0),
3984            }
3985        } else {
3986            let tmp = temp_sibling(&link);
3987            if let Err(e) = create(&tmp) {
3988                return (write_io_status(&e), 0, 0);
3989            }
3990            if let Err(e) = fs::rename(&tmp, &link) {
3991                let _ = fs::remove_file(&tmp);
3992                return (write_io_status(&e), 0, 0);
3993            }
3994        }
3995        let mtime_ns = stat_meta(&link).map(|m| m.mtime_ns).unwrap_or(0);
3996        let echo_wire = wire_key_for(&self.root, &link).unwrap_or_else(|| o.b.clone());
3997        match &src {
3998            None => {
3999                let hash = blake3_128(o.a.as_bytes());
4000                self.prime_echo(&echo_wire, &link, hash, o.a.as_bytes(), mtime_ns);
4001                (FS_DONE_OK, hash, mtime_ns)
4002            }
4003            Some(src) => {
4004                // The link's content is the source file's. Hash it for the
4005                // echo when the bytes are stable and modestly sized; a huge
4006                // or in-flux source just lets the reconciler learn lazily.
4007                let small = fs::symlink_metadata(src)
4008                    .map(|m| m.len() <= fs_write_max())
4009                    .unwrap_or(false);
4010                match if small {
4011                    read_verified(&link)
4012                } else {
4013                    ReadOutcome::Unstable
4014                } {
4015                    ReadOutcome::Stable(data) => {
4016                        let hash = blake3_128(&data);
4017                        self.prime_echo(&echo_wire, &link, hash, &data, mtime_ns);
4018                        (FS_DONE_OK, hash, mtime_ns)
4019                    }
4020                    _ => {
4021                        self.hint_change(&link);
4022                        (FS_DONE_OK, 0, mtime_ns)
4023                    }
4024                }
4025            }
4026        }
4027    }
4028
4029    /// Prime the echo of a landed write: cache the bytes by hash, mark this
4030    /// client as already holding them (so its own UPSERT echo carries
4031    /// metadata, not a copy), teach the reconciler the hash, and inject a
4032    /// synchronous dirty hint so the change publishes in one settle window.
4033    fn prime_echo(&mut self, wire: &str, abs: &Path, hash: u128, bytes: &[u8], mtime_ns: u64) {
4034        blob_store()
4035            .lock()
4036            .unwrap()
4037            .put(hash, Arc::new(bytes.to_vec()));
4038        self.held.insert(wire.to_string(), hash);
4039        if !racily_clean(mtime_ns)
4040            && let Ok(mut meta) = stat_meta(abs)
4041        {
4042            meta.hash = hash;
4043            self.teach_hash(wire, meta);
4044        }
4045        self.hint_change(abs);
4046    }
4047
4048    /// Inject a synchronous dirty hint for a path and its parent so a write
4049    /// or op re-enters the mirror in one settle window instead of awaiting
4050    /// the native watcher (which also fires and reconciles to a no-op).
4051    fn hint_change(&self, abs: &Path) {
4052        let _ = self
4053            .shared
4054            .tx
4055            .send(RootMsg::Hint(Hint::Dirty(abs.to_path_buf())));
4056        if let Some(parent) = abs.parent() {
4057            let _ = self
4058                .shared
4059                .tx
4060                .send(RootMsg::Hint(Hint::Dirty(parent.to_path_buf())));
4061        }
4062    }
4063}
4064
4065// Close-reason aliases for readability at use sites above.
4066const FS_CLOSED_BACKEND_FAILED_COMPAT: u8 = blit_remote::fs::FS_CLOSED_BACKEND_FAILED;
4067const FS_CLOSED_PERMISSION_LOST_COMPAT: u8 = blit_remote::fs::FS_CLOSED_PERMISSION_LOST;
4068/// Errno smuggled through io::Error to signal the entry budget was hit.
4069const RESOURCE_LIMIT_ERRNO: i32 = libc_enfile();
4070
4071const fn libc_enfile() -> i32 {
4072    23 // ENFILE everywhere we care about; only used as an internal marker
4073}
4074
4075#[cfg(test)]
4076mod tests {
4077    use super::*;
4078    use blit_remote::fs::FsMirror;
4079    use std::sync::atomic::{AtomicU64, Ordering};
4080    use std::sync::{Arc, Mutex};
4081
4082    static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(0);
4083
4084    fn temp_dir() -> PathBuf {
4085        let dir = std::env::temp_dir().join(format!(
4086            "blit-fssync-test-{}-{}",
4087            std::process::id(),
4088            TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed)
4089        ));
4090        fs::create_dir_all(&dir).unwrap();
4091        dir
4092    }
4093
4094    fn test_key(root: &Path) -> RootKey {
4095        RootKey {
4096            path: root.to_path_buf(),
4097            recursive: true,
4098            cross_filesystem: false,
4099            ignores: IgnoreSpec::default(),
4100        }
4101    }
4102
4103    /// [`test_key`] with an ignore spec — a *different* shared root, since
4104    /// the spec is part of the key.
4105    fn test_key_ignoring(root: &Path, ignores: IgnoreSpec) -> RootKey {
4106        RootKey {
4107            ignores,
4108            ..test_key(root)
4109        }
4110    }
4111
4112    #[test]
4113    fn escape_roundtrip() {
4114        assert_eq!(escape_bytes(b"plain.txt"), "plain.txt");
4115        assert_eq!(escape_bytes(b"50%.txt"), "50%25.txt");
4116        let bad = b"a\xFFb";
4117        let escaped = escape_bytes(bad);
4118        assert_eq!(escaped, "a%FFb");
4119        assert_eq!(unescape_to_bytes(&escaped).unwrap(), bad.to_vec());
4120        assert_eq!(unescape_to_bytes("50%25.txt").unwrap(), b"50%.txt".to_vec());
4121    }
4122
4123    #[test]
4124    fn wide_escape_roundtrip() {
4125        // Plain text passes through.
4126        let plain: Vec<u16> = "file.txt".encode_utf16().collect();
4127        assert_eq!(escape_wide(&plain), "file.txt");
4128        assert_eq!(unescape_to_wide("file.txt").unwrap(), plain);
4129        // Literal '%' escapes so "%u" in a name never collides.
4130        let percent: Vec<u16> = "50%u.txt".encode_utf16().collect();
4131        assert_eq!(escape_wide(&percent), "50%25u.txt");
4132        assert_eq!(unescape_to_wide("50%25u.txt").unwrap(), percent);
4133        // Valid surrogate pair (U+1D11E) survives as text.
4134        let clef: Vec<u16> = "𝄞.txt".encode_utf16().collect();
4135        assert_eq!(escape_wide(&clef), "𝄞.txt");
4136        assert_eq!(unescape_to_wide("𝄞.txt").unwrap(), clef);
4137        // Unpaired surrogates become %uXXXX and round-trip exactly.
4138        let bad = [0xD800u16, 0x0041, 0xDFFF];
4139        let escaped = escape_wide(&bad);
4140        assert_eq!(escaped, "%uD800A%uDFFF");
4141        assert_eq!(unescape_to_wide(&escaped).unwrap(), bad.to_vec());
4142        // Malformed escapes are rejected.
4143        assert!(unescape_to_wide("%u12").is_none());
4144        assert!(unescape_to_wide("%uZZZZ").is_none());
4145    }
4146
4147    #[test]
4148    fn wire_path_traversal_rejected() {
4149        let root = Path::new("/tmp/root");
4150        assert!(resolve_wire_path(root, "a/../b").is_none());
4151        assert!(resolve_wire_path(root, "..").is_none());
4152        assert!(resolve_wire_path(root, "a//b").is_none());
4153        assert_eq!(resolve_wire_path(root, ""), Some(root.to_path_buf()));
4154        assert_eq!(
4155            resolve_wire_path(root, "a/b"),
4156            Some(root.join("a").join("b"))
4157        );
4158    }
4159
4160    /// Traversal must be rejected even when the dot-dot or separator is
4161    /// percent-encoded: the `.`/`..`/empty and embedded-`/` checks run
4162    /// against the *decoded* component, not the escaped wire text, so a
4163    /// crafted `FS_FETCH` cannot climb out of the synced root. (A
4164    /// well-behaved peer never sends these — the server escapes `.` as
4165    /// `.` and `/` as a separator — but the resolver must not trust the
4166    /// client's encoding.)
4167    #[test]
4168    fn encoded_traversal_rejected() {
4169        let root = Path::new("/tmp/root");
4170        // %2E%2E decodes to "..".
4171        assert!(resolve_wire_path(root, "%2E%2E").is_none());
4172        assert!(resolve_wire_path(root, "%2e%2e/etc/passwd").is_none());
4173        // %2E decodes to ".".
4174        assert!(resolve_wire_path(root, "%2E").is_none());
4175        // An embedded encoded separator smuggles two components past a
4176        // per-component check.
4177        assert!(resolve_wire_path(root, "a%2F..%2Fb").is_none());
4178        assert!(resolve_wire_path(root, "a%2Fb").is_none());
4179        // A genuine name that merely contains a percent still resolves.
4180        assert_eq!(resolve_wire_path(root, "%2525"), Some(root.join("%25")));
4181    }
4182
4183    fn meta(node_type: u8, size: u64, mtime: u64, ino: u64) -> NodeMeta {
4184        NodeMeta {
4185            node_type,
4186            size,
4187            mtime_ns: mtime,
4188            mode: 0o644,
4189            hash: 0,
4190            dev_ino: (1, ino),
4191            link_dir: false,
4192            filtered: false,
4193        }
4194    }
4195
4196    /// A flag flip with an otherwise identical stat still produces a record.
4197    ///
4198    /// `filtered` and `link_dir` are the two wire-visible bits that are not
4199    /// properties of the inode, so nothing about a fresh stat implies them.
4200    /// In the field the flip usually rides along with a stat change — the
4201    /// excluded child that set `filtered` also bumped its parent's mtime —
4202    /// which is why a diff that ignored the flags looked correct until two
4203    /// writes shared one timestamp tick. That is the CI deadlock in #124:
4204    /// no record, and the hint path stops nudging once the canonical entry
4205    /// is `filtered`, so the client never hears about it. Asserting on the
4206    /// mechanism keeps this deterministic instead of timestamp-dependent.
4207    #[test]
4208    fn diff_reports_a_flag_flip_under_an_unchanged_stat() {
4209        for (label, flip) in [
4210            (
4211                "filtered",
4212                (|m: &mut NodeMeta| m.filtered = true) as fn(&mut NodeMeta),
4213            ),
4214            ("link_dir", |m: &mut NodeMeta| m.link_dir = true),
4215        ] {
4216            let mut prev = Index::new();
4217            prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4218            prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4219            let mut curr = prev.clone();
4220            flip(curr.get_mut("d").unwrap());
4221
4222            let changed = std::collections::BTreeSet::from(["d".to_string()]);
4223            for (how, ops) in [
4224                ("diff", diff(&prev, &curr)),
4225                ("diff_changed", diff_changed(&prev, &curr, &changed)),
4226            ] {
4227                let [
4228                    DiffOp::Upsert {
4229                        path,
4230                        content_changed,
4231                    },
4232                ] = &ops[..]
4233                else {
4234                    panic!("{label} via {how}: expected one Upsert, got {ops:?}");
4235                };
4236                assert_eq!(path, "d", "{label} via {how}");
4237                // A flag is metadata: the client re-reads flags, not bytes.
4238                assert!(!content_changed, "{label} via {how} asked for content");
4239            }
4240        }
4241    }
4242
4243    #[test]
4244    fn diff_detects_directory_move() {
4245        let mut prev = Index::new();
4246        prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4247        prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4248        prev.insert("d/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4249        let mut curr = Index::new();
4250        curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4251        curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4252        curr.insert("e/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4253        let ops = diff(&prev, &curr);
4254        assert_eq!(
4255            ops,
4256            vec![DiffOp::Move {
4257                from: "d".into(),
4258                to: "e".into()
4259            }]
4260        );
4261    }
4262
4263    /// A MOVE must not swallow same-window changes inside the moved
4264    /// subtree: modified, created, and deleted children all need fix-ups.
4265    #[test]
4266    fn diff_move_with_same_window_child_changes() {
4267        let mut prev = Index::new();
4268        prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4269        prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 50, 2));
4270        prev.insert("d/modified".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4271        prev.insert("d/deleted".into(), meta(FS_ENTRY_FILE, 5, 10, 4));
4272        let mut curr = Index::new();
4273        curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4274        curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 50, 2));
4275        curr.insert("e/modified".into(), meta(FS_ENTRY_FILE, 999, 777, 3));
4276        curr.insert("e/created".into(), meta(FS_ENTRY_FILE, 1, 900, 9));
4277        let ops = diff(&prev, &curr);
4278        assert!(ops.contains(&DiffOp::Move {
4279            from: "d".into(),
4280            to: "e".into()
4281        }));
4282        assert!(
4283            ops.contains(&DiffOp::Upsert {
4284                path: "e/modified".into(),
4285                content_changed: true
4286            }),
4287            "modified child swallowed: {ops:?}"
4288        );
4289        assert!(
4290            ops.contains(&DiffOp::Upsert {
4291                path: "e/created".into(),
4292                content_changed: true
4293            }),
4294            "created child swallowed: {ops:?}"
4295        );
4296        assert!(
4297            ops.contains(&DiffOp::Delete {
4298                path: "e/deleted".into()
4299            }),
4300            "deleted child swallowed: {ops:?}"
4301        );
4302    }
4303
4304    /// Drive one engine over a shared root and apply every update to a
4305    /// mirror, acking as we go. Returns (mirror, sent-log, handle, hints).
4306    #[cfg(unix)]
4307    fn drive_engine(root: &Path) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4308        drive_engine_keyed(test_key(root))
4309    }
4310
4311    fn drive_engine_keyed(key: RootKey) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4312        let shared = open_root_unwatched(key);
4313        let hint_tx = shared.hint_sender();
4314        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4315        let sent2 = sent.clone();
4316        let opts = SyncOptions {
4317            content: true,
4318            latency: Duration::from_millis(5),
4319            ..Default::default()
4320        };
4321        let handle = start_sync(
4322            &shared,
4323            1,
4324            opts,
4325            Box::new(move |msg| {
4326                sent2.lock().unwrap().push(msg);
4327                true
4328            }),
4329        );
4330        (sent, handle, hint_tx)
4331    }
4332
4333    /// Send a command and block until the `FS_DONE` for `nonce` arrives.
4334    fn await_done(
4335        handle: &SyncHandle,
4336        sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4337        nonce: u16,
4338        cmd: Command,
4339    ) -> (u8, u128, u64) {
4340        handle.command(cmd);
4341        let deadline = Instant::now() + Duration::from_secs(5);
4342        loop {
4343            for msg in sent.lock().unwrap().iter() {
4344                if let Some((n, s, h, m)) = blit_remote::fs::parse_fs_done(msg)
4345                    && n == nonce
4346                {
4347                    return (s, h, m);
4348                }
4349            }
4350            assert!(Instant::now() < deadline, "no FS_DONE for nonce {nonce}");
4351            std::thread::sleep(Duration::from_millis(2));
4352        }
4353    }
4354
4355    fn write_req(nonce: u16, path: &str, base: u128, flags: u8, content: &[u8]) -> Command {
4356        Command::Write(WriteReq {
4357            nonce,
4358            path: path.into(),
4359            base,
4360            mode: 0,
4361            flags,
4362            content_kind: 1,
4363            content: content.to_vec(),
4364            inflight: None,
4365        })
4366    }
4367
4368    /// Drive one engine over a SINGLE (one-file) shared root, hint-driven.
4369    fn drive_single_engine(file: &Path) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4370        let shared = open_single_root_unwatched(file.to_path_buf());
4371        assert!(shared.is_single());
4372        let hint_tx = shared.hint_sender();
4373        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4374        let sent2 = sent.clone();
4375        let opts = SyncOptions {
4376            content: true,
4377            recursive: false,
4378            latency: Duration::from_millis(5),
4379            ..Default::default()
4380        };
4381        let handle = start_sync(
4382            &shared,
4383            1,
4384            opts,
4385            Box::new(move |msg| {
4386                sent2.lock().unwrap().push(msg);
4387                true
4388            }),
4389        );
4390        (sent, handle, hint_tx)
4391    }
4392
4393    fn count_updates(sent: &Arc<Mutex<Vec<Vec<u8>>>>) -> usize {
4394        sent.lock()
4395            .unwrap()
4396            .iter()
4397            .filter(|m| m[0] == blit_remote::fs::S2C_FS_UPDATE)
4398            .count()
4399    }
4400
4401    fn count_closed(sent: &Arc<Mutex<Vec<Vec<u8>>>>) -> usize {
4402        sent.lock()
4403            .unwrap()
4404            .iter()
4405            .filter(|m| m[0] == blit_remote::fs::S2C_FS_CLOSED)
4406            .count()
4407    }
4408
4409    /// Apply every unseen FS_UPDATE to `mirror`, acking as the client would.
4410    fn pump_mirror(
4411        sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4412        handle: &SyncHandle,
4413        mirror: &mut FsMirror,
4414        seen: &mut usize,
4415    ) {
4416        let msgs = sent.lock().unwrap().clone();
4417        for msg in &msgs[*seen..] {
4418            if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
4419                let id = mirror.apply_update(msg).expect("valid update");
4420                handle.command(Command::Ack(id));
4421            }
4422        }
4423        *seen = msgs.len();
4424    }
4425
4426    /// Pump until `pred(mirror)` holds or the deadline passes.
4427    fn pump_until(
4428        sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4429        handle: &SyncHandle,
4430        mirror: &mut FsMirror,
4431        seen: &mut usize,
4432        what: &str,
4433        pred: impl Fn(&FsMirror) -> bool,
4434    ) {
4435        pump_until_nudging(sent, handle, mirror, seen, what, || {}, pred)
4436    }
4437
4438    /// `pump_until`, re-sending `nudge` on every poll.
4439    ///
4440    /// For the waits that hang off a *single* engine-side transition — an
4441    /// excluded child re-listing its parent once, and only once, so the flag
4442    /// flip costs one listing rather than one per event — a lone hint has to
4443    /// win against the write becoming visible to that listing. A backend
4444    /// keeps hinting as long as anything moves; these tests do not, so they
4445    /// re-send rather than depend on one delivery landing in the right order.
4446    /// The generous deadline is for a loaded machine (the coverage job runs
4447    /// every crate's tests at once, instrumented), not for a slow engine:
4448    /// when nothing is wrong these return in milliseconds.
4449    fn pump_until_nudging(
4450        sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4451        handle: &SyncHandle,
4452        mirror: &mut FsMirror,
4453        seen: &mut usize,
4454        what: &str,
4455        nudge: impl Fn(),
4456        pred: impl Fn(&FsMirror) -> bool,
4457    ) {
4458        let deadline = Instant::now() + Duration::from_secs(30);
4459        loop {
4460            pump_mirror(sent, handle, mirror, seen);
4461            if pred(mirror) {
4462                return;
4463            }
4464            assert!(
4465                Instant::now() < deadline,
4466                "timed out waiting for {what}; live = {:?}",
4467                mirror.live.keys().collect::<Vec<_>>()
4468            );
4469            nudge();
4470            std::thread::sleep(Duration::from_millis(2));
4471        }
4472    }
4473
4474    /// SINGLE sync lifecycle (docs/design/fs-watch.md "Single-file sync"):
4475    /// the initial snapshot is exactly one entry keyed "", external
4476    /// modifications flow, sibling churn never wakes the sync,
4477    /// delete/recreate and rename-away/rename-back flow as DELETE/UPSERT
4478    /// of "" without closing, same-file opens share one root, and FS_STOP
4479    /// tears down with FS_CLOSED(client request).
4480    #[test]
4481    fn single_sync_lifecycle() {
4482        let dir = temp_dir().canonicalize().unwrap();
4483        let file = dir.join("note.txt");
4484        let sibling = dir.join("sibling.txt");
4485        fs::write(&file, b"v1").unwrap();
4486        fs::write(&sibling, b"noise").unwrap();
4487
4488        // Same-file opens share one root; a directory open of the parent
4489        // coexists without joining it (the flag set is part of the key).
4490        let shared = open_single_root_unwatched(file.clone());
4491        assert!(Arc::ptr_eq(
4492            &shared,
4493            &open_single_root_unwatched(file.clone())
4494        ));
4495        let dir_root = open_root_unwatched(test_key(&dir));
4496        assert!(!Arc::ptr_eq(&shared, &dir_root));
4497        drop(dir_root);
4498        drop(shared);
4499
4500        let (sent, handle, hint) = drive_single_engine(&file);
4501        let mut mirror = FsMirror::new();
4502        let mut seen = 0usize;
4503
4504        // Initial snapshot: exactly the one "" entry, content attached.
4505        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial ''", |m| {
4506            m.live
4507                .get("")
4508                .is_some_and(|n| n.content.as_deref() == Some(&b"v1"[..]))
4509        });
4510        assert_eq!(mirror.live.len(), 1, "mirror holds exactly the root");
4511        let node = &mirror.live[""];
4512        assert_eq!(node.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_FILE);
4513        assert_eq!(node.hash, blake3_128(b"v1"));
4514
4515        // Sibling churn must not wake the sync: no FS_UPDATE flows.
4516        let quiet = count_updates(&sent);
4517        fs::write(&sibling, b"more noise").unwrap();
4518        fs::write(dir.join("new-sibling.txt"), b"x").unwrap();
4519        hint.send(Hint::Dirty(sibling.clone()));
4520        hint.send(Hint::Dirty(dir.join("new-sibling.txt")));
4521        std::thread::sleep(Duration::from_millis(120));
4522        pump_mirror(&sent, &handle, &mut mirror, &mut seen);
4523        assert_eq!(
4524            count_updates(&sent),
4525            quiet,
4526            "sibling churn woke the single sync"
4527        );
4528        assert_eq!(mirror.live[""].content.as_deref(), Some(&b"v1"[..]));
4529
4530        // An external modify flows (file-level hint).
4531        fs::write(&file, b"v2").unwrap();
4532        hint.send(Hint::Dirty(file.clone()));
4533        pump_until(&sent, &handle, &mut mirror, &mut seen, "v2", |m| {
4534            m.live
4535                .get("")
4536                .is_some_and(|n| n.content.as_deref() == Some(&b"v2"[..]))
4537        });
4538
4539        // A parent-level hint (directory-granular backends) also re-verifies.
4540        fs::write(&file, b"v3").unwrap();
4541        hint.send(Hint::Dirty(dir.clone()));
4542        pump_until(&sent, &handle, &mut mirror, &mut seen, "v3", |m| {
4543            m.live
4544                .get("")
4545                .is_some_and(|n| n.content.as_deref() == Some(&b"v3"[..]))
4546        });
4547
4548        // Delete flows as DELETE of "" — the sync stays open.
4549        fs::remove_file(&file).unwrap();
4550        hint.send(Hint::Dirty(file.clone()));
4551        pump_until(&sent, &handle, &mut mirror, &mut seen, "delete", |m| {
4552            m.live.is_empty()
4553        });
4554        assert_eq!(count_closed(&sent), 0, "delete must not close the sync");
4555
4556        // Recreate flows back as an UPSERT of "".
4557        fs::write(&file, b"v4").unwrap();
4558        hint.send(Hint::Dirty(file.clone()));
4559        pump_until(&sent, &handle, &mut mirror, &mut seen, "recreate", |m| {
4560            m.live
4561                .get("")
4562                .is_some_and(|n| n.content.as_deref() == Some(&b"v4"[..]))
4563        });
4564
4565        // Rename away (the watch survives on the parent), then back.
4566        let away = dir.join("renamed.txt");
4567        fs::rename(&file, &away).unwrap();
4568        hint.send(Hint::Dirty(file.clone()));
4569        hint.send(Hint::Dirty(away.clone()));
4570        pump_until(&sent, &handle, &mut mirror, &mut seen, "rename away", |m| {
4571            m.live.is_empty()
4572        });
4573        fs::rename(&away, &file).unwrap();
4574        hint.send(Hint::Dirty(file.clone()));
4575        hint.send(Hint::Dirty(away.clone()));
4576        pump_until(&sent, &handle, &mut mirror, &mut seen, "rename back", |m| {
4577            m.live
4578                .get("")
4579                .is_some_and(|n| n.content.as_deref() == Some(&b"v4"[..]))
4580        });
4581        assert_eq!(count_closed(&sent), 0);
4582
4583        // Teardown: FS_CLOSED(client request).
4584        handle.command(Command::Stop);
4585        let deadline = Instant::now() + Duration::from_secs(5);
4586        while count_closed(&sent) == 0 {
4587            assert!(Instant::now() < deadline, "no FS_CLOSED after Stop");
4588            std::thread::sleep(Duration::from_millis(2));
4589        }
4590        let closed = sent
4591            .lock()
4592            .unwrap()
4593            .iter()
4594            .find(|m| m[0] == blit_remote::fs::S2C_FS_CLOSED)
4595            .unwrap()
4596            .clone();
4597        assert_eq!(closed[3], FS_CLOSED_CLIENT_REQUEST);
4598        let _ = fs::remove_dir_all(&dir);
4599    }
4600
4601    /// A SINGLE root's validation: directories answer the invalid-path
4602    /// With `cross_filesystem` off (the default), a symlink to a directory on
4603    /// another mount is reported but never descended — on the initial scan and,
4604    /// the case that actually regressed, on an incremental reconcile.
4605    ///
4606    /// The reconcile pre-check cannot catch this one: a symlink's `dev_ino`
4607    /// comes from `lstat`, so it reports the device the *link* lives on (the
4608    /// root's), sailing past the guard that stops real foreign-device
4609    /// directories. `scan_into` was then called with `root_dev: None`, which
4610    /// re-anchored the bound to the target's device and indexed the whole
4611    /// cross-device subtree.
4612    ///
4613    /// Uses /dev/shm as the second filesystem; skipped when it is absent, not
4614    /// writable, or happens to share a device with the temp dir.
4615    #[cfg(target_os = "linux")]
4616    #[test]
4617    fn cross_device_symlink_is_not_descended_on_reconcile() {
4618        use std::os::unix::fs::MetadataExt;
4619
4620        let dir = temp_dir().canonicalize().unwrap();
4621        let Ok(shm) = std::path::Path::new("/dev/shm").canonicalize() else {
4622            return;
4623        };
4624        let foreign = shm.join(format!("blit-xdev-{}", std::process::id()));
4625        if fs::create_dir_all(foreign.join("inner")).is_err() {
4626            return;
4627        }
4628        // Guard the premise: without two devices this proves nothing.
4629        let (Ok(a), Ok(b)) = (fs::metadata(&dir), fs::metadata(&foreign)) else {
4630            let _ = fs::remove_dir_all(&foreign);
4631            return;
4632        };
4633        if a.dev() == b.dev() {
4634            let _ = fs::remove_dir_all(&foreign);
4635            return;
4636        }
4637        fs::write(foreign.join("inner/secret.txt"), b"elsewhere").unwrap();
4638        fs::write(dir.join("local.txt"), b"here").unwrap();
4639
4640        // cross_filesystem defaults to false in test_key.
4641        let (sent, handle, hint) = drive_engine(&dir);
4642        let mut mirror = FsMirror::new();
4643        let mut seen = 0usize;
4644        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4645            m.live.contains_key("local.txt")
4646        });
4647
4648        // Create the link *after* the first snapshot, so the hint takes the
4649        // "new (or type-changed) directory" branch — the trigger.
4650        std::os::unix::fs::symlink(&foreign, dir.join("far")).unwrap();
4651        hint.send(Hint::Dirty(dir.join("far")));
4652        pump_until(&sent, &handle, &mut mirror, &mut seen, "link entry", |m| {
4653            m.live.contains_key("far")
4654        });
4655
4656        // The link is reported — it is the boundary, like a mount point — but
4657        // nothing beyond it is indexed. Snapshot the verdict, then tear down
4658        // *before* asserting: /dev/shm is RAM, and a failing assert would
4659        // otherwise leave the fixture behind.
4660        let leaked: Vec<String> = mirror
4661            .live
4662            .keys()
4663            .filter(|k| k.starts_with("far/"))
4664            .cloned()
4665            .collect();
4666        handle.command(Command::Stop);
4667        let _ = fs::remove_dir_all(&foreign);
4668
4669        assert!(
4670            leaked.is_empty(),
4671            "cross_filesystem is off: a symlink to another device must not be \
4672             descended, found {leaked:?}"
4673        );
4674    }
4675
4676    /// A filtered root arms one watch per indexed directory instead of one
4677    /// recursive watch over everything, so an excluded subtree costs no
4678    /// descriptors (docs/design/fs-watch.md "Ignoring"). The risk that
4679    /// buys is a lost event, so this drives the *real* backend: changes
4680    /// several levels down, in directories created after the initial scan,
4681    /// must still arrive — while the excluded subtree stays absent and
4682    /// unarmed.
4683    #[cfg(target_os = "linux")]
4684    #[test]
4685    fn per_directory_watching_still_delivers_every_change() {
4686        let root = temp_dir().canonicalize().unwrap();
4687        fs::create_dir_all(root.join("src/deep")).unwrap();
4688        fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
4689        fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
4690        fs::write(root.join("src/deep/seed.txt"), b"seed").unwrap();
4691
4692        let key = test_key_ignoring(
4693            &root,
4694            IgnoreSpec {
4695                gitignore: true,
4696                dot_ignore: true,
4697                exclude_git: true,
4698                patterns: Vec::new(),
4699            },
4700        );
4701        let shared = open_root(key).expect("arm native watch");
4702        assert!(
4703            shared
4704                ._backend
4705                .lock()
4706                .unwrap()
4707                .as_ref()
4708                .is_some_and(|b| b.watches.is_per_dir()),
4709            "a filtered root on Linux arms per directory"
4710        );
4711        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4712        let sent2 = sent.clone();
4713        let handle = start_sync(
4714            &shared,
4715            9,
4716            SyncOptions {
4717                content: true,
4718                latency: Duration::from_millis(5),
4719                ..Default::default()
4720            },
4721            Box::new(move |msg| {
4722                sent2.lock().unwrap().push(msg);
4723                true
4724            }),
4725        );
4726        let mut mirror = FsMirror::new();
4727        let mut seen = 0usize;
4728        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4729            m.live.contains_key("src/deep/seed.txt")
4730        });
4731
4732        // A write two levels down, seen only through the watch armed on
4733        // that directory during the scan.
4734        fs::write(root.join("src/deep/seed.txt"), b"changed").unwrap();
4735        pump_until(&sent, &handle, &mut mirror, &mut seen, "deep write", |m| {
4736            m.live
4737                .get("src/deep/seed.txt")
4738                .is_some_and(|n| n.content.as_deref() == Some(&b"changed"[..]))
4739        });
4740
4741        // A directory created *after* the scan has to be armed by the
4742        // reconcile path, and its children reported through that new watch
4743        // — the arm-before-list contract, one level down.
4744        fs::create_dir(root.join("src/fresh")).unwrap();
4745        fs::write(root.join("src/fresh/a.txt"), b"a").unwrap();
4746        pump_until(&sent, &handle, &mut mirror, &mut seen, "fresh dir", |m| {
4747            m.live.contains_key("src/fresh/a.txt")
4748        });
4749        fs::write(root.join("src/fresh/b.txt"), b"b").unwrap();
4750        pump_until(&sent, &handle, &mut mirror, &mut seen, "fresh child", |m| {
4751            m.live.contains_key("src/fresh/b.txt")
4752        });
4753
4754        // Deleting it disarms; recreating re-arms and still delivers.
4755        fs::remove_dir_all(root.join("src/fresh")).unwrap();
4756        pump_until(&sent, &handle, &mut mirror, &mut seen, "dir gone", |m| {
4757            !m.live.contains_key("src/fresh")
4758        });
4759        fs::create_dir(root.join("src/fresh")).unwrap();
4760        fs::write(root.join("src/fresh/c.txt"), b"c").unwrap();
4761        pump_until(&sent, &handle, &mut mirror, &mut seen, "re-armed", |m| {
4762            m.live.contains_key("src/fresh/c.txt")
4763        });
4764
4765        // Meanwhile the excluded subtree was never armed and never seen.
4766        fs::write(root.join("node_modules/pkg/index.js"), b"x").unwrap();
4767        std::thread::sleep(Duration::from_millis(100));
4768        pump_mirror(&sent, &handle, &mut mirror, &mut seen);
4769        assert!(
4770            !mirror.live.keys().any(|k| k.starts_with("node_modules")),
4771            "live = {:?}",
4772            mirror.live.keys().collect::<Vec<_>>()
4773        );
4774        handle.command(Command::Stop);
4775    }
4776
4777    /// docs/design/fs-watch.md "Ignoring": excluded paths are absent from
4778    /// the mirror rather than filtered out of it, churn under them
4779    /// produces no update at all, and an edit to an ignore source
4780    /// re-classifies the tree in both directions.
4781    #[test]
4782    fn excluded_paths_never_reach_the_client() {
4783        let dir = temp_dir().canonicalize().unwrap();
4784        fs::create_dir_all(dir.join(".git")).unwrap();
4785        fs::write(dir.join(".git/config"), b"[core]").unwrap();
4786        fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
4787        fs::write(dir.join("node_modules/pkg/index.js"), b"x").unwrap();
4788        fs::create_dir_all(dir.join("target/debug")).unwrap();
4789        fs::write(dir.join("target/debug/bin"), b"x").unwrap();
4790        fs::create_dir_all(dir.join("src")).unwrap();
4791        fs::write(dir.join("src/a.rs"), b"fn main() {}").unwrap();
4792        fs::write(dir.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
4793
4794        let key = test_key_ignoring(
4795            &dir,
4796            IgnoreSpec {
4797                gitignore: true,
4798                dot_ignore: true,
4799                exclude_git: true,
4800                patterns: Vec::new(),
4801            },
4802        );
4803        let (sent, handle, hint) = drive_engine_keyed(key);
4804        let mut mirror = FsMirror::new();
4805        let mut seen = 0usize;
4806        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4807            m.live.contains_key("src/a.rs")
4808        });
4809        assert_eq!(
4810            mirror.live.keys().cloned().collect::<Vec<_>>(),
4811            ["", ".gitignore", "src", "src/a.rs"],
4812            "the whole checkout, and nothing the exclusions cover"
4813        );
4814
4815        // Churn under an excluded path yields nothing; a visible write in
4816        // the same batch proves the pipeline was live while it did.
4817        let quiet = count_updates(&sent);
4818        fs::write(dir.join("target/debug/fresh.bin"), b"y").unwrap();
4819        fs::write(dir.join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
4820        hint.send(Hint::Dirty(dir.join("target/debug/fresh.bin")));
4821        hint.send(Hint::Dirty(dir.join(".git/HEAD")));
4822        std::thread::sleep(Duration::from_millis(50));
4823        assert_eq!(count_updates(&sent), quiet, "excluded churn woke the sync");
4824
4825        fs::write(dir.join("src/b.rs"), b"pub fn b() {}").unwrap();
4826        hint.send(Hint::Dirty(dir.join("src/b.rs")));
4827        pump_until(&sent, &handle, &mut mirror, &mut seen, "src/b.rs", |m| {
4828            m.live.contains_key("src/b.rs")
4829        });
4830        assert!(
4831            !mirror
4832                .live
4833                .keys()
4834                .any(|k| k.starts_with("target") || k.starts_with(".git/")),
4835            "live = {:?}",
4836            mirror.live.keys().collect::<Vec<_>>()
4837        );
4838
4839        // A new rule arrives as a DELETE of what it now covers…
4840        fs::write(dir.join(".gitignore"), "target/\nnode_modules/\nsrc/a.rs\n").unwrap();
4841        hint.send(Hint::Dirty(dir.join(".gitignore")));
4842        pump_until(&sent, &handle, &mut mirror, &mut seen, "a.rs gone", |m| {
4843            !m.live.contains_key("src/a.rs")
4844        });
4845        assert!(mirror.live.contains_key("src/b.rs"), "only the rule's path");
4846
4847        // …and removing it as an UPSERT of what it uncovers.
4848        fs::write(dir.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
4849        hint.send(Hint::Dirty(dir.join(".gitignore")));
4850        pump_until(&sent, &handle, &mut mirror, &mut seen, "a.rs back", |m| {
4851            m.live.contains_key("src/a.rs")
4852        });
4853        handle.command(Command::Stop);
4854    }
4855
4856    /// An ignore file *above* the root re-classifies the tree when it is
4857    /// edited. Nothing inside the root could ever hint at it, so the
4858    /// reconciler watches the directories holding those sources; without
4859    /// that, a sync of `repo/crates` kept `repo/.gitignore` as it read it
4860    /// at open, for the life of the sync.
4861    #[cfg(target_os = "linux")]
4862    #[test]
4863    fn an_edit_to_an_ignore_file_above_the_root_reaches_the_client() {
4864        let top = temp_dir().canonicalize().unwrap();
4865        fs::create_dir_all(top.join(".git")).unwrap();
4866        let root = top.join("crates");
4867        fs::create_dir_all(&root).unwrap();
4868        fs::write(top.join(".gitignore"), "*.bak\n").unwrap();
4869        fs::write(root.join("a.rs"), b"x").unwrap();
4870        fs::write(root.join("old.bak"), b"x").unwrap();
4871
4872        let key = test_key_ignoring(
4873            &root,
4874            IgnoreSpec {
4875                gitignore: true,
4876                ..Default::default()
4877            },
4878        );
4879        let shared = open_root(key).expect("arm native watch");
4880        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4881        let sent2 = sent.clone();
4882        let handle = start_sync(
4883            &shared,
4884            9,
4885            SyncOptions {
4886                latency: Duration::from_millis(5),
4887                ..Default::default()
4888            },
4889            Box::new(move |msg| {
4890                sent2.lock().unwrap().push(msg);
4891                true
4892            }),
4893        );
4894        let mut mirror = FsMirror::new();
4895        let mut seen = 0usize;
4896        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4897            m.live.contains_key("a.rs")
4898        });
4899        assert!(!mirror.live.contains_key("old.bak"), "inherited from above");
4900
4901        // Relax the parent rule: what it hid must come back, with no hint
4902        // from inside the tree to prompt it.
4903        fs::write(top.join(".gitignore"), "*.tmp\n").unwrap();
4904        pump_until(&sent, &handle, &mut mirror, &mut seen, "uncovered", |m| {
4905            m.live.contains_key("old.bak")
4906        });
4907
4908        // And tighten it again, this time covering a file that was visible.
4909        fs::write(top.join(".gitignore"), "*.rs\n").unwrap();
4910        pump_until(&sent, &handle, &mut mirror, &mut seen, "covered", |m| {
4911            !m.live.contains_key("a.rs")
4912        });
4913        handle.command(Command::Stop);
4914    }
4915
4916    /// A `build/` pattern excludes a *symlinked* directory too. This sync
4917    /// enumerates through such a link (docs/design/fs-watch.md § Links),
4918    /// unlike git, so treating it as git does — a file, unmatchable by a
4919    /// directory-only pattern — would leave the one hole through which a
4920    /// whole excluded subtree still reaches the client.
4921    #[test]
4922    fn a_directory_pattern_excludes_a_symlinked_directory_and_its_subtree() {
4923        let dir = temp_dir().canonicalize().unwrap();
4924        fs::create_dir_all(dir.join("real/inner")).unwrap();
4925        fs::write(dir.join("real/inner/heavy.bin"), b"x").unwrap();
4926        fs::write(dir.join("keep.txt"), b"k").unwrap();
4927        std::os::unix::fs::symlink(dir.join("real"), dir.join("build")).unwrap();
4928
4929        let key = test_key_ignoring(
4930            &dir,
4931            IgnoreSpec {
4932                patterns: vec!["build/".into()],
4933                ..Default::default()
4934            },
4935        );
4936        let (sent, handle, _hint) = drive_engine_keyed(key);
4937        let mut mirror = FsMirror::new();
4938        let mut seen = 0usize;
4939        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4940            m.live.contains_key("keep.txt")
4941        });
4942        assert!(
4943            !mirror.live.keys().any(|k| k.starts_with("build")),
4944            "the link and everything enumerated through it; live = {:?}",
4945            mirror.live.keys().collect::<Vec<_>>()
4946        );
4947        // The real path is untouched by the pattern — only the alias matched.
4948        assert!(mirror.live.contains_key("real/inner/heavy.bin"));
4949        handle.command(Command::Stop);
4950    }
4951
4952    /// An excluded path is absent, not marked, so a client cannot tell an
4953    /// empty directory from a filtered one. `FS_ENTRY_FILTERED` on the
4954    /// *parent* is that signal — what lets a file tree say "some items
4955    /// hidden" — and it tracks the directory's real state as rules and
4956    /// contents change.
4957    #[test]
4958    fn a_directory_reports_that_it_hid_children() {
4959        let dir = temp_dir().canonicalize().unwrap();
4960        fs::create_dir_all(dir.join("src")).unwrap();
4961        fs::create_dir_all(dir.join("plain")).unwrap();
4962        fs::write(dir.join("src/a.rs"), b"x").unwrap();
4963        fs::write(dir.join("src/a.tmp"), b"x").unwrap();
4964        fs::write(dir.join("plain/b.rs"), b"x").unwrap();
4965
4966        let key = test_key_ignoring(
4967            &dir,
4968            IgnoreSpec {
4969                patterns: vec!["*.tmp".into()],
4970                ..Default::default()
4971            },
4972        );
4973        let (sent, handle, hint) = drive_engine_keyed(key);
4974        let mut mirror = FsMirror::new();
4975        let mut seen = 0usize;
4976        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4977            m.live.contains_key("plain/b.rs")
4978        });
4979        let filtered = |m: &FsMirror, path: &str| {
4980            m.live
4981                .get(path)
4982                .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED != 0)
4983        };
4984        assert!(filtered(&mirror, "src"), "src hid a.tmp");
4985        assert!(!filtered(&mirror, "plain"), "plain hid nothing");
4986        assert!(!filtered(&mirror, ""), "nor did the root");
4987
4988        // A newly excluded child sets it on a directory that had none.
4989        fs::write(dir.join("plain/c.tmp"), b"x").unwrap();
4990        hint.send(Hint::Dirty(dir.join("plain/c.tmp")));
4991        pump_until_nudging(
4992            &sent,
4993            &handle,
4994            &mut mirror,
4995            &mut seen,
4996            "plain hides",
4997            || {
4998                hint.send(Hint::Dirty(dir.join("plain/c.tmp")));
4999            },
5000            |m| {
5001                m.live
5002                    .get("plain")
5003                    .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED != 0)
5004            },
5005        );
5006
5007        // …and removing the last one clears it again, on the next listing
5008        // of that directory.
5009        fs::remove_file(dir.join("plain/c.tmp")).unwrap();
5010        hint.send(Hint::Dirty(dir.join("plain")));
5011        pump_until_nudging(
5012            &sent,
5013            &handle,
5014            &mut mirror,
5015            &mut seen,
5016            "plain clears",
5017            || {
5018                hint.send(Hint::Dirty(dir.join("plain")));
5019            },
5020            |m| {
5021                m.live
5022                    .get("plain")
5023                    .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED == 0)
5024            },
5025        );
5026        assert!(filtered(&mirror, "src"), "src still hides a.tmp");
5027        handle.command(Command::Stop);
5028    }
5029
5030    /// Client patterns outrank the ignore files (`!keep.log` re-includes
5031    /// what `*.log` hid), and the exclusion set is part of the shared
5032    /// root's identity: syncs excluding different things index different
5033    /// trees and cannot share one reconciler.
5034    #[test]
5035    fn client_patterns_outrank_ignore_files_and_key_the_root() {
5036        let dir = temp_dir().canonicalize().unwrap();
5037        fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
5038        fs::write(dir.join("a.log"), b"x").unwrap();
5039        fs::write(dir.join("keep.log"), b"x").unwrap();
5040        fs::write(dir.join("notes.txt"), b"x").unwrap();
5041
5042        let spec = IgnoreSpec {
5043            gitignore: true,
5044            dot_ignore: true,
5045            exclude_git: false,
5046            patterns: IgnoreSpec::parse_patterns("!keep.log\nnotes.txt"),
5047        };
5048        let (sent, handle, _hint) = drive_engine_keyed(test_key_ignoring(&dir, spec.clone()));
5049        let mut mirror = FsMirror::new();
5050        let mut seen = 0usize;
5051        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5052            m.live.contains_key("keep.log")
5053        });
5054        assert_eq!(
5055            mirror.live.keys().cloned().collect::<Vec<_>>(),
5056            ["", ".gitignore", "keep.log"]
5057        );
5058
5059        let same = open_root_unwatched(test_key_ignoring(&dir, spec.clone()));
5060        let again = open_root_unwatched(test_key_ignoring(&dir, spec));
5061        assert!(Arc::ptr_eq(&same, &again), "one spec, one shared root");
5062        let unfiltered = open_root_unwatched(test_key(&dir));
5063        assert!(
5064            !Arc::ptr_eq(&same, &unfiltered),
5065            "an unfiltered sync indexes a different tree"
5066        );
5067        handle.command(Command::Stop);
5068    }
5069
5070    /// A root whose name contains `%` survives the FS_SYNCED round trip. The
5071    /// echo is `escape_path(canonical_root)`, so a literal `%` comes back as
5072    /// `%25`; clients build further sync roots from that echo, and without a
5073    /// decode on the way in such a path could be listed but never re-opened.
5074    /// A file genuinely named `50%25.txt` still takes precedence over the
5075    /// decoded reading of `50%.txt`.
5076    #[test]
5077    fn percent_in_root_survives_the_wire_round_trip() {
5078        let dir = temp_dir().canonicalize().unwrap();
5079        let literal = dir.join("50%.txt");
5080        fs::write(&literal, b"x").unwrap();
5081
5082        // What the server echoes for this path.
5083        let echoed = escape_path(&literal);
5084        assert!(echoed.ends_with("50%25.txt"), "echo escapes the percent");
5085
5086        // Both the raw form a CLI types and the escaped echo must resolve.
5087        assert_eq!(
5088            validate_root(&literal.to_string_lossy()).unwrap(),
5089            literal.canonicalize().unwrap(),
5090            "a raw path containing % still works"
5091        );
5092        assert_eq!(
5093            validate_root(&echoed).unwrap(),
5094            literal.canonicalize().unwrap(),
5095            "the escaped echo resolves back to the same file"
5096        );
5097
5098        // A file actually named `50%25.txt` wins the literal reading.
5099        let ambiguous = dir.join("50%25.txt");
5100        fs::write(&ambiguous, b"y").unwrap();
5101        assert_eq!(
5102            validate_root(&echoed).unwrap(),
5103            ambiguous.canonicalize().unwrap(),
5104            "literal match takes precedence over the decoded one"
5105        );
5106    }
5107
5108    /// A symlinked directory is enumerated like a real one, flagged
5109    /// `FS_ENTRY_LINK_DIR` so a client knows it is expandable, and a link that
5110    /// points back into its own tree stops instead of recursing forever.
5111    /// Previously the scan reported the link as a childless entry, which made
5112    /// the file browser a dead end at every symlinked directory.
5113    #[cfg(unix)]
5114    #[test]
5115    fn symlinked_directories_are_traversed_and_cycle_safe() {
5116        let dir = temp_dir().canonicalize().unwrap();
5117        // real/inner/deep.txt, plus link -> real, and a cycle real/loop -> real
5118        fs::create_dir_all(dir.join("real/inner")).unwrap();
5119        fs::write(dir.join("real/inner/deep.txt"), b"payload").unwrap();
5120        fs::write(dir.join("real/top.txt"), b"top").unwrap();
5121        std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
5122        std::os::unix::fs::symlink(dir.join("real"), dir.join("real/loop")).unwrap();
5123        // A link to a file, and a dangling one: neither is enumerable.
5124        std::os::unix::fs::symlink(dir.join("real/top.txt"), dir.join("tolink")).unwrap();
5125        std::os::unix::fs::symlink(dir.join("nope"), dir.join("dangling")).unwrap();
5126
5127        let (sent, handle, _hint) = drive_engine(&dir);
5128        let mut mirror = FsMirror::new();
5129        let mut seen = 0usize;
5130        pump_until(
5131            &sent,
5132            &handle,
5133            &mut mirror,
5134            &mut seen,
5135            "link subtree",
5136            |m| m.live.contains_key("link/inner/deep.txt"),
5137        );
5138
5139        // The link is reported as a symlink, but flagged as enumerable.
5140        let link = &mirror.live["link"];
5141        assert_eq!(link.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5142        assert_ne!(
5143            link.entry_flags & FS_ENTRY_LINK_DIR,
5144            0,
5145            "a symlinked directory must advertise that it can be expanded"
5146        );
5147        // Its contents are reachable through the link's own path.
5148        assert!(mirror.live.contains_key("link/top.txt"));
5149        assert_eq!(
5150            mirror.live["link/inner/deep.txt"].content.as_deref(),
5151            Some(&b"payload"[..])
5152        );
5153
5154        // A link to a file is not enumerable, and neither is a dangling one.
5155        assert_eq!(mirror.live["tolink"].entry_flags & FS_ENTRY_LINK_DIR, 0);
5156        assert_eq!(mirror.live["dangling"].entry_flags & FS_ENTRY_LINK_DIR, 0);
5157
5158        // `real/loop` points at its own ancestor, so it is reported but never
5159        // descended — no redundant copy of the subtree, no recursion.
5160        assert!(mirror.live.contains_key("real/loop"));
5161        assert!(
5162            !mirror.live.keys().any(|k| k.starts_with("real/loop/")),
5163            "a link to an ancestor must not be descended: {:?}",
5164            mirror.live.keys().collect::<Vec<_>>()
5165        );
5166        // The same link reached through `link` is equally bounded.
5167        assert!(mirror.live.contains_key("link/loop"));
5168        assert!(
5169            !mirror.live.keys().any(|k| k.starts_with("link/loop/")),
5170            "cycle detection must hold through a symlinked path too: {:?}",
5171            mirror.live.keys().collect::<Vec<_>>()
5172        );
5173        handle.command(Command::Stop);
5174    }
5175
5176    /// error, files canonicalize, missing paths keep their status.
5177    #[test]
5178    fn single_root_validation() {
5179        use blit_remote::fs::{FS_STATUS_NOT_FOUND, FS_STATUS_OTHER};
5180        let dir = temp_dir();
5181        let file = dir.join("f.txt");
5182        fs::write(&file, b"x").unwrap();
5183        assert_eq!(
5184            validate_single_root(&file.to_string_lossy()).unwrap(),
5185            file.canonicalize().unwrap()
5186        );
5187        let (status, _) = validate_single_root(&dir.to_string_lossy()).unwrap_err();
5188        assert_eq!(status, FS_STATUS_OTHER, "directory root refused");
5189        let (status, _) =
5190            validate_single_root(&dir.join("missing.txt").to_string_lossy()).unwrap_err();
5191        assert_eq!(status, FS_STATUS_NOT_FOUND);
5192        let _ = fs::remove_dir_all(&dir);
5193    }
5194
5195    /// Copy `from`'s mtime onto `to`, so a rewrite can be made
5196    /// indistinguishable by stat — what a coarse filesystem clock does on
5197    /// its own when two writes land in the same granule.
5198    fn copy_mtime(from: &Path, to: &Path) {
5199        let status = std::process::Command::new("touch")
5200            .arg("-r")
5201            .arg(from)
5202            .arg(to)
5203            .status()
5204            .expect("touch");
5205        assert!(status.success(), "touch -r failed");
5206        assert_eq!(
5207            stat_meta(from).unwrap().mtime_ns,
5208            stat_meta(to).unwrap().mtime_ns,
5209            "mtimes must be identical for the test to mean anything"
5210        );
5211    }
5212
5213    /// A same-size rewrite inside one filesystem timestamp granule
5214    /// (docs/design/fs-watch.md "Racily-clean entries"): size, identity and
5215    /// mtime are all unchanged, so only content distinguishes the two
5216    /// versions and the new bytes must still reach the client.
5217    ///
5218    /// Ubuntu's coarse inode clock produces exactly this from two ordinary
5219    /// `write`s a millisecond apart; `touch -r` reproduces it everywhere.
5220    #[test]
5221    fn single_sync_same_stat_rewrite() {
5222        let dir = temp_dir().canonicalize().unwrap();
5223        let reference = dir.join("reference");
5224        let file = dir.join("note.txt");
5225        fs::write(&reference, b"").unwrap();
5226        fs::write(&file, b"one").unwrap();
5227        copy_mtime(&reference, &file);
5228
5229        let (sent, handle, hint) = drive_single_engine(&file);
5230        let mut mirror = FsMirror::new();
5231        let mut seen = 0usize;
5232        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5233            m.live
5234                .get("")
5235                .is_some_and(|n| n.content.as_deref() == Some(&b"one"[..]))
5236        });
5237
5238        fs::write(&file, b"two").unwrap();
5239        copy_mtime(&reference, &file);
5240        hint.send(Hint::Dirty(file.clone()));
5241        pump_until(
5242            &sent,
5243            &handle,
5244            &mut mirror,
5245            &mut seen,
5246            "same-stat rewrite",
5247            |m| {
5248                m.live
5249                    .get("")
5250                    .is_some_and(|n| n.content.as_deref() == Some(&b"two"[..]))
5251            },
5252        );
5253
5254        handle.command(Command::Stop);
5255        let _ = fs::remove_dir_all(&dir);
5256    }
5257
5258    /// Writes through a SINGLE sync address the empty path: CAS write-
5259    /// through works, non-"" paths are INVALID, create-exclusive on the
5260    /// existing root conflicts, and a conditional REMOVE of "" lands.
5261    #[test]
5262    fn single_sync_write_through() {
5263        let dir = temp_dir().canonicalize().unwrap();
5264        let file = dir.join("doc.txt");
5265        fs::write(&file, b"hello").unwrap();
5266        let (sent, handle, _hint) = drive_single_engine(&file);
5267
5268        let mut mirror = FsMirror::new();
5269        let mut seen = 0usize;
5270        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5271            m.live.contains_key("")
5272        });
5273        let base = mirror.live[""].hash;
5274        assert_eq!(base, blake3_128(b"hello"));
5275
5276        // CAS write-through at "".
5277        let (s, h, _) = await_done(&handle, &sent, 1, write_req(1, "", base, 0, b"world"));
5278        assert_eq!(s, FS_DONE_OK);
5279        assert_eq!(h, blake3_128(b"world"));
5280        assert_eq!(fs::read(&file).unwrap(), b"world");
5281        // The echo re-enters the writer's own mirror (metadata-only, hash
5282        // updated — self-echo suppression keeps the bytes it wrote).
5283        pump_until(&sent, &handle, &mut mirror, &mut seen, "echo", |m| {
5284            m.live
5285                .get("")
5286                .is_some_and(|n| n.hash == blake3_128(b"world"))
5287        });
5288
5289        // A stale base conflicts, carrying the live hash.
5290        let (s, disk, _) = await_done(&handle, &sent, 2, write_req(2, "", base, 0, b"x"));
5291        assert_eq!(s, FS_DONE_CONFLICT);
5292        assert_eq!(disk, blake3_128(b"world"));
5293
5294        // Non-"" paths do not exist in a SINGLE sync's namespace.
5295        let (s, _, _) = await_done(&handle, &sent, 3, write_req(3, "other.txt", 0, 0, b"no"));
5296        assert_eq!(s, FS_DONE_INVALID);
5297
5298        // Create-exclusive on the existing root conflicts.
5299        let (s, _, _) = await_done(&handle, &sent, 4, write_req(4, "", 0, 0, b"no"));
5300        assert_eq!(s, FS_DONE_CONFLICT);
5301
5302        // Conditional REMOVE of "" deletes the file; the mirror empties.
5303        let (s, _, _) = await_done(
5304            &handle,
5305            &sent,
5306            5,
5307            Command::Op(OpReq {
5308                nonce: 5,
5309                op: FS_OP_REMOVE,
5310                a: String::new(),
5311                b: String::new(),
5312                base: blake3_128(b"world"),
5313                mode: 0,
5314                flags: 0,
5315                inflight: None,
5316            }),
5317        );
5318        assert_eq!(s, FS_DONE_OK);
5319        assert!(!file.exists());
5320        pump_until(&sent, &handle, &mut mirror, &mut seen, "removed", |m| {
5321            m.live.is_empty()
5322        });
5323        assert_eq!(count_closed(&sent), 0, "REMOVE of '' must not close");
5324
5325        handle.command(Command::Stop);
5326        let _ = fs::remove_dir_all(&dir);
5327    }
5328
5329    #[test]
5330    fn write_cas_semantics() {
5331        // Production always canonicalizes the root (validate_root); the
5332        // write guard relies on it.
5333        let root = temp_dir().canonicalize().unwrap();
5334        let (sent, handle, _hint) = drive_engine(&root);
5335
5336        // Create-exclusive (base 0): first ok, second conflicts with the
5337        // current disk hash.
5338        let (s, hash, _) = await_done(&handle, &sent, 1, write_req(1, "a.txt", 0, 0, b"hello"));
5339        assert_eq!(s, FS_DONE_OK);
5340        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"hello");
5341        assert_eq!(hash, blake3_128(b"hello"));
5342        let (s, disk, _) = await_done(&handle, &sent, 2, write_req(2, "a.txt", 0, 0, b"x"));
5343        assert_eq!(s, FS_DONE_CONFLICT);
5344        assert_eq!(disk, hash, "conflict carries the live disk hash");
5345        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"hello", "unchanged");
5346
5347        // CAS overwrite: correct base succeeds, a stale base conflicts.
5348        let (s, h2, _) = await_done(&handle, &sent, 3, write_req(3, "a.txt", hash, 0, b"world"));
5349        assert_eq!(s, FS_DONE_OK);
5350        assert_eq!(h2, blake3_128(b"world"));
5351        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"world");
5352        let (s, _, _) = await_done(&handle, &sent, 4, write_req(4, "a.txt", hash, 0, b"z"));
5353        assert_eq!(s, FS_DONE_CONFLICT, "stale base rejected");
5354
5355        // NO_CAS overwrites unconditionally.
5356        let (s, _, _) = await_done(
5357            &handle,
5358            &sent,
5359            5,
5360            write_req(5, "a.txt", 0, FS_WRITE_NO_CAS, b"forced"),
5361        );
5362        assert_eq!(s, FS_DONE_OK);
5363        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"forced");
5364
5365        // MKPARENTS creates the chain.
5366        let (s, _, _) = await_done(
5367            &handle,
5368            &sent,
5369            6,
5370            write_req(6, "d/e/f.txt", 0, FS_WRITE_MKPARENTS, b"deep"),
5371        );
5372        assert_eq!(s, FS_DONE_OK);
5373        assert_eq!(fs::read(root.join("d/e/f.txt")).unwrap(), b"deep");
5374
5375        handle.command(Command::Stop);
5376        let _ = fs::remove_dir_all(&root);
5377    }
5378
5379    fn delta_req(nonce: u16, path: &str, base: u128, flags: u8, ops: &[u8]) -> Command {
5380        Command::Write(WriteReq {
5381            nonce,
5382            path: path.into(),
5383            base,
5384            mode: 0,
5385            flags,
5386            content_kind: blit_remote::fs::FS_WRITE_CONTENT_DELTA,
5387            content: ops.to_vec(),
5388            inflight: None,
5389        })
5390    }
5391
5392    /// C2S delta writes (docs/design/fs-write.md content_kind 2): the ops
5393    /// apply against the exact bytes `base` names; a stale base answers
5394    /// CONFLICT with the live hash and never a corrupted apply; a delta
5395    /// without a CAS anchor (NO_CAS or zero base) and a malformed stream
5396    /// are INVALID.
5397    #[test]
5398    fn write_delta_applies_against_cas_base() {
5399        let root = temp_dir().canonicalize().unwrap();
5400        let (sent, handle, _hint) = drive_engine(&root);
5401
5402        // Seed via a full write.
5403        let old = b"hello world".as_slice();
5404        let (s, h1, _) = await_done(&handle, &sent, 1, write_req(1, "a.txt", 0, 0, old));
5405        assert_eq!(s, FS_DONE_OK);
5406
5407        // Full-file delta round-trip through a sync write.
5408        let new = b"hello brave world".as_slice();
5409        let ops = encode_delta(old, new);
5410        assert_eq!(
5411            blit_remote::fs::apply_fs_delta(old, &ops).as_deref(),
5412            Some(new)
5413        );
5414        let (s, h2, _) = await_done(&handle, &sent, 2, delta_req(2, "a.txt", h1, 0, &ops));
5415        assert_eq!(s, FS_DONE_OK);
5416        assert_eq!(h2, blake3_128(new));
5417        assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5418
5419        // A stale base rejects with the live hash; the file is untouched.
5420        let (s, disk, _) = await_done(&handle, &sent, 3, delta_req(3, "a.txt", h1, 0, &ops));
5421        assert_eq!(s, FS_DONE_CONFLICT, "stale delta base must conflict");
5422        assert_eq!(disk, h2, "conflict carries the live disk hash");
5423        assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5424
5425        // No CAS anchor: NO_CAS and the zero (absent) base are INVALID.
5426        let (s, _, _) = await_done(
5427            &handle,
5428            &sent,
5429            4,
5430            delta_req(4, "a.txt", h2, FS_WRITE_NO_CAS, &ops),
5431        );
5432        assert_eq!(s, FS_DONE_INVALID);
5433        let (s, _, _) = await_done(&handle, &sent, 5, delta_req(5, "a.txt", 0, 0, &ops));
5434        assert_eq!(s, FS_DONE_INVALID);
5435
5436        // A malformed instruction stream is INVALID and writes nothing.
5437        let (s, _, _) = await_done(&handle, &sent, 6, delta_req(6, "a.txt", h2, 0, &[0xFF, 1]));
5438        assert_eq!(s, FS_DONE_INVALID);
5439        assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5440
5441        // A delta against a missing file conflicts with the absent (zero)
5442        // sentinel — the base cannot be produced.
5443        let (s, disk, _) = await_done(&handle, &sent, 7, delta_req(7, "gone.txt", h2, 0, &ops));
5444        assert_eq!(s, FS_DONE_CONFLICT);
5445        assert_eq!(disk, 0);
5446
5447        // The writer's mirror converges on the applied bytes' hash.
5448        let mut mirror = FsMirror::new();
5449        let mut seen = 0usize;
5450        pump_until(&sent, &handle, &mut mirror, &mut seen, "delta echo", |m| {
5451            m.live.get("a.txt").is_some_and(|n| n.hash == h2)
5452        });
5453
5454        handle.command(Command::Stop);
5455        let _ = fs::remove_dir_all(&root);
5456    }
5457
5458    /// A delta write through a SINGLE sync addresses "" like any other
5459    /// write, and chains off the returned hash.
5460    #[test]
5461    fn write_delta_on_single_sync() {
5462        let dir = temp_dir().canonicalize().unwrap();
5463        let file = dir.join("buf.txt");
5464        fs::write(&file, b"alpha").unwrap();
5465        let (sent, handle, _hint) = drive_single_engine(&file);
5466
5467        let mut mirror = FsMirror::new();
5468        let mut seen = 0usize;
5469        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5470            m.live.contains_key("")
5471        });
5472        let h0 = mirror.live[""].hash;
5473
5474        let ops = encode_delta(b"alpha", b"alpha beta");
5475        let (s, h1, _) = await_done(&handle, &sent, 1, delta_req(1, "", h0, 0, &ops));
5476        assert_eq!(s, FS_DONE_OK);
5477        assert_eq!(h1, blake3_128(b"alpha beta"));
5478        assert_eq!(fs::read(&file).unwrap(), b"alpha beta");
5479
5480        // Chain a second delta off the *returned* hash (the fs-write.md
5481        // rapid-save rule), while the echo is still in flight.
5482        let ops2 = encode_delta(b"alpha beta", b"alpha beta gamma");
5483        let (s, h2, _) = await_done(&handle, &sent, 2, delta_req(2, "", h1, 0, &ops2));
5484        assert_eq!(s, FS_DONE_OK);
5485        assert_eq!(h2, blake3_128(b"alpha beta gamma"));
5486        assert_eq!(fs::read(&file).unwrap(), b"alpha beta gamma");
5487
5488        // Non-"" delta targets are INVALID on a SINGLE sync.
5489        let (s, _, _) = await_done(&handle, &sent, 3, delta_req(3, "x.txt", h2, 0, &ops2));
5490        assert_eq!(s, FS_DONE_INVALID);
5491
5492        pump_until(&sent, &handle, &mut mirror, &mut seen, "echo", |m| {
5493            m.live.get("").is_some_and(|n| n.hash == h2)
5494        });
5495
5496        handle.command(Command::Stop);
5497        let _ = fs::remove_dir_all(&dir);
5498    }
5499
5500    #[test]
5501    fn write_refuses_traversal() {
5502        // Production always canonicalizes the root (validate_root); the
5503        // write guard relies on it.
5504        let root = temp_dir().canonicalize().unwrap();
5505        let sibling = root.parent().unwrap().join("blit-escape-victim.txt");
5506        let _ = fs::remove_file(&sibling);
5507        let (sent, handle, _hint) = drive_engine(&root);
5508
5509        // Plain and percent-encoded dot-dot both refuse and write nothing.
5510        for (i, p) in ["../blit-escape-victim.txt", "%2E%2E/blit-escape-victim.txt"]
5511            .iter()
5512            .enumerate()
5513        {
5514            let (s, _, _) = await_done(
5515                &handle,
5516                &sent,
5517                i as u16 + 1,
5518                write_req(i as u16 + 1, p, 0, 0, b"pwn"),
5519            );
5520            assert_eq!(s, FS_DONE_INVALID, "traversal {p} must be refused");
5521        }
5522        assert!(!sibling.exists(), "nothing escaped the root");
5523
5524        handle.command(Command::Stop);
5525        let _ = fs::remove_dir_all(&root);
5526    }
5527
5528    /// FS_FETCH must confine exactly like the write path: an in-tree symlink
5529    /// whose target escapes the root cannot be used to read a file outside
5530    /// it (resolve_wire_path alone does no symlink resolution).
5531    #[cfg(unix)]
5532    #[test]
5533    fn fetch_refuses_symlink_escape() {
5534        let root = temp_dir().canonicalize().unwrap();
5535        // A secret outside the root, plus an in-tree symlink pointing at the
5536        // root's parent so `pub/<name>` resolves out of the confinement.
5537        let secret = root.parent().unwrap().join("blit-fetch-secret.txt");
5538        fs::write(&secret, b"top secret").unwrap();
5539        std::os::unix::fs::symlink(root.parent().unwrap(), root.join("pub")).unwrap();
5540        let (sent, handle, _hint) = drive_engine(&root);
5541
5542        let await_file = |nonce: u16| -> (u8, Vec<u8>) {
5543            let deadline = Instant::now() + Duration::from_secs(5);
5544            loop {
5545                for msg in sent.lock().unwrap().iter() {
5546                    if msg[0] == blit_remote::fs::S2C_FS_FILE
5547                        && let Some((n, status, data)) = blit_remote::fs::parse_fs_file(msg)
5548                        && n == nonce
5549                    {
5550                        return (status, data.to_vec());
5551                    }
5552                }
5553                assert!(Instant::now() < deadline, "no FS_FILE for nonce {nonce}");
5554                std::thread::sleep(Duration::from_millis(2));
5555            }
5556        };
5557
5558        handle.command(Command::Fetch {
5559            nonce: 1,
5560            path: "pub/blit-fetch-secret.txt".into(),
5561        });
5562        let (status, data) = await_file(1);
5563        assert_ne!(status, FS_FILE_OK, "escape must be refused");
5564        assert!(data.is_empty(), "no bytes leak past the confinement");
5565
5566        handle.command(Command::Stop);
5567        let _ = fs::remove_file(&secret);
5568        let _ = fs::remove_dir_all(&root);
5569    }
5570
5571    #[test]
5572    fn fs_ops_mkdir_rename_remove() {
5573        // Production always canonicalizes the root (validate_root); the
5574        // write guard relies on it.
5575        let root = temp_dir().canonicalize().unwrap();
5576        let (sent, handle, _hint) = drive_engine(&root);
5577        let op = |nonce: u16, op: u8, a: &str, b: &str, base: u128, flags: u8| {
5578            Command::Op(OpReq {
5579                nonce,
5580                op,
5581                a: a.into(),
5582                b: b.into(),
5583                base,
5584                mode: 0,
5585                flags,
5586                inflight: None,
5587            })
5588        };
5589
5590        // mkdir
5591        let (s, _, _) = await_done(&handle, &sent, 1, op(1, FS_OP_MKDIR, "sub", "", 0, 0));
5592        assert_eq!(s, FS_DONE_OK);
5593        assert!(root.join("sub").is_dir());
5594        // idempotent
5595        let (s, _, _) = await_done(&handle, &sent, 2, op(2, FS_OP_MKDIR, "sub", "", 0, 0));
5596        assert_eq!(s, FS_DONE_OK);
5597
5598        // write then rename
5599        let (_, _, _) = await_done(&handle, &sent, 3, write_req(3, "sub/x.txt", 0, 0, b"hi"));
5600        let (s, _, _) = await_done(
5601            &handle,
5602            &sent,
5603            4,
5604            op(4, FS_OP_RENAME, "sub/x.txt", "sub/y.txt", 0, 0),
5605        );
5606        assert_eq!(s, FS_DONE_OK);
5607        assert!(!root.join("sub/x.txt").exists());
5608        assert_eq!(fs::read(root.join("sub/y.txt")).unwrap(), b"hi");
5609
5610        // rename of a missing source is NOT_FOUND
5611        let (s, _, _) = await_done(
5612            &handle,
5613            &sent,
5614            5,
5615            op(5, FS_OP_RENAME, "sub/gone.txt", "sub/z.txt", 0, 0),
5616        );
5617        assert_eq!(s, FS_DONE_NOT_FOUND);
5618
5619        // remove the subtree
5620        let (s, _, _) = await_done(&handle, &sent, 6, op(6, FS_OP_REMOVE, "sub", "", 0, 0));
5621        assert_eq!(s, FS_DONE_OK);
5622        assert!(!root.join("sub").exists());
5623        // removing a missing path is NOT_FOUND
5624        let (s, _, _) = await_done(&handle, &sent, 7, op(7, FS_OP_REMOVE, "sub", "", 0, 0));
5625        assert_eq!(s, FS_DONE_NOT_FOUND);
5626
5627        handle.command(Command::Stop);
5628        let _ = fs::remove_dir_all(&root);
5629    }
5630
5631    /// Symlink and hard-link ops: create-exclusive, CAS retarget, conflict
5632    /// carrying the live target hash, type refusals, and the read side
5633    /// treating a symlink's target as its content (mirror and FETCH).
5634    #[cfg(unix)]
5635    #[test]
5636    fn fs_ops_symlink_hardlink() {
5637        // Production always canonicalizes the root (validate_root); the
5638        // write guard relies on it.
5639        let root = temp_dir().canonicalize().unwrap();
5640        let (sent, handle, hint) = drive_engine(&root);
5641        let op = |nonce: u16, op: u8, a: &str, b: &str, base: u128, flags: u8| {
5642            Command::Op(OpReq {
5643                nonce,
5644                op,
5645                a: a.into(),
5646                b: b.into(),
5647                base,
5648                mode: 0,
5649                flags,
5650                inflight: None,
5651            })
5652        };
5653
5654        // Create-exclusive symlink; the returned hash covers the target.
5655        let (s, h, _) = await_done(&handle, &sent, 1, op(1, FS_OP_SYMLINK, "a.txt", "ln", 0, 0));
5656        assert_eq!(s, FS_DONE_OK);
5657        assert_eq!(fs::read_link(root.join("ln")).unwrap(), Path::new("a.txt"));
5658        assert_eq!(h, blake3_128(b"a.txt"));
5659        // An existing entry conflicts, carrying the live target hash.
5660        let (s, disk, _) = await_done(&handle, &sent, 2, op(2, FS_OP_SYMLINK, "other", "ln", 0, 0));
5661        assert_eq!(s, FS_DONE_CONFLICT);
5662        assert_eq!(disk, h);
5663        // CAS retarget: the correct base wins…
5664        let (s, h2, _) = await_done(&handle, &sent, 3, op(3, FS_OP_SYMLINK, "b.txt", "ln", h, 0));
5665        assert_eq!(s, FS_DONE_OK);
5666        assert_eq!(h2, blake3_128(b"b.txt"));
5667        assert_eq!(fs::read_link(root.join("ln")).unwrap(), Path::new("b.txt"));
5668        // …and a stale base conflicts.
5669        let (s, _, _) = await_done(&handle, &sent, 4, op(4, FS_OP_SYMLINK, "c", "ln", h, 0));
5670        assert_eq!(s, FS_DONE_CONFLICT);
5671        // NO_CAS replaces unconditionally; a dangling target is legitimate.
5672        let (s, _, _) = await_done(
5673            &handle,
5674            &sent,
5675            5,
5676            op(5, FS_OP_SYMLINK, "gone/dangling", "ln", 0, FS_OP_NO_CAS),
5677        );
5678        assert_eq!(s, FS_DONE_OK);
5679        assert_eq!(
5680            fs::read_link(root.join("ln")).unwrap(),
5681            Path::new("gone/dangling")
5682        );
5683        // A directory at the link path refuses.
5684        fs::create_dir(root.join("d")).unwrap();
5685        let (s, _, _) = await_done(
5686            &handle,
5687            &sent,
5688            6,
5689            op(6, FS_OP_SYMLINK, "x", "d", 0, FS_OP_NO_CAS),
5690        );
5691        assert_eq!(s, FS_DONE_WRONG_TYPE);
5692
5693        // Hard link: same content hash as the source, same inode.
5694        let (s, fh, _) = await_done(&handle, &sent, 10, write_req(10, "f.txt", 0, 0, b"hello"));
5695        assert_eq!(s, FS_DONE_OK);
5696        let (s, lh, _) = await_done(
5697            &handle,
5698            &sent,
5699            11,
5700            op(11, FS_OP_HARDLINK, "f.txt", "f2.txt", 0, 0),
5701        );
5702        assert_eq!(s, FS_DONE_OK);
5703        assert_eq!(lh, fh);
5704        assert_eq!(fs::read(root.join("f2.txt")).unwrap(), b"hello");
5705        {
5706            use std::os::unix::fs::MetadataExt;
5707            assert_eq!(
5708                fs::metadata(root.join("f.txt")).unwrap().ino(),
5709                fs::metadata(root.join("f2.txt")).unwrap().ino()
5710            );
5711        }
5712        // Create-exclusive on an existing destination conflicts.
5713        let (s, _, _) = await_done(
5714            &handle,
5715            &sent,
5716            12,
5717            op(12, FS_OP_HARDLINK, "f.txt", "f2.txt", 0, 0),
5718        );
5719        assert_eq!(s, FS_DONE_CONFLICT);
5720        // The source must be a regular file; a symlink source refuses.
5721        let (s, _, _) = await_done(
5722            &handle,
5723            &sent,
5724            13,
5725            op(13, FS_OP_HARDLINK, "ln", "ln2", 0, 0),
5726        );
5727        assert_eq!(s, FS_DONE_WRONG_TYPE);
5728        // A missing source is NOT_FOUND.
5729        let (s, _, _) = await_done(
5730            &handle,
5731            &sent,
5732            14,
5733            op(14, FS_OP_HARDLINK, "nope", "n2", 0, 0),
5734        );
5735        assert_eq!(s, FS_DONE_NOT_FOUND);
5736
5737        // The writer's own echo for "ln" is metadata-only (prime_echo marks
5738        // it as held), but must still carry the target hash. An externally
5739        // created symlink syncs with its target as inline content.
5740        std::os::unix::fs::symlink("ext-target", root.join("ext")).unwrap();
5741        hint.send(Hint::Dirty(root.join("ext")));
5742        let mut mirror = FsMirror::new();
5743        let mut seen = 0usize;
5744        let deadline = Instant::now() + Duration::from_secs(5);
5745        loop {
5746            for msg in sent.lock().unwrap().clone()[seen..].iter() {
5747                seen += 1;
5748                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5749                    let id = mirror.apply_update(msg).unwrap();
5750                    handle.command(Command::Ack(id));
5751                }
5752            }
5753            if mirror
5754                .live
5755                .get("ext")
5756                .is_some_and(|n| n.content.as_deref() == Some(&b"ext-target"[..]))
5757                && mirror.live.contains_key("ln")
5758            {
5759                break;
5760            }
5761            assert!(Instant::now() < deadline, "symlink content never synced");
5762            std::thread::sleep(Duration::from_millis(2));
5763        }
5764        let node = mirror.live.get("ext").unwrap();
5765        assert_eq!(node.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5766        assert_eq!(node.hash, blake3_128(b"ext-target"));
5767        assert_eq!(node.size, "ext-target".len() as u64);
5768        let own = mirror.live.get("ln").unwrap();
5769        assert_eq!(own.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5770        assert_eq!(own.hash, blake3_128(b"gone/dangling"));
5771        handle.command(Command::Fetch {
5772            nonce: 20,
5773            path: "ln".into(),
5774        });
5775        let deadline = Instant::now() + Duration::from_secs(5);
5776        'fetch: loop {
5777            for msg in sent.lock().unwrap().iter() {
5778                if msg[0] == blit_remote::fs::S2C_FS_FILE
5779                    && let Some((20, status, data)) = blit_remote::fs::parse_fs_file(msg)
5780                {
5781                    assert_eq!(status, FS_FILE_OK);
5782                    assert_eq!(data, b"gone/dangling");
5783                    break 'fetch;
5784                }
5785            }
5786            assert!(Instant::now() < deadline, "no FS_FILE for the symlink");
5787            std::thread::sleep(Duration::from_millis(2));
5788        }
5789
5790        handle.command(Command::Stop);
5791        let _ = fs::remove_dir_all(&root);
5792    }
5793
5794    /// Finding: a transiently-unreadable file must not poison the mirror.
5795    /// After the read races a permission flip, the retry set re-reads it
5796    /// once readable, so content still converges.
5797    #[cfg(unix)]
5798    #[test]
5799    fn unreadable_content_recovers_when_readable() {
5800        use std::os::unix::fs::PermissionsExt;
5801        let root = temp_dir();
5802        let file = root.join("secret.txt");
5803        fs::write(&file, b"classified").unwrap();
5804        fs::set_permissions(&file, fs::Permissions::from_mode(0o000)).unwrap();
5805        // Skip under root (chmod 000 doesn't stop root reads).
5806        if fs::read(&file).is_ok() {
5807            let _ = fs::remove_dir_all(&root);
5808            return;
5809        }
5810        let (sent, handle, _hint) = drive_engine(&root);
5811
5812        let mut mirror = FsMirror::new();
5813        let mut acked = 0usize;
5814        let pump = |mirror: &mut FsMirror, acked: &mut usize| {
5815            for msg in sent.lock().unwrap().clone()[*acked..].iter() {
5816                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5817                    let id = mirror.apply_update(msg).unwrap();
5818                    handle.command(Command::Ack(id));
5819                    *acked += 1;
5820                } else {
5821                    *acked += 1;
5822                }
5823            }
5824        };
5825        // Initial snapshot: the file is present but content-less + UNREADABLE.
5826        for _ in 0..200 {
5827            pump(&mut mirror, &mut acked);
5828            if let Some(node) = mirror.live.get("secret.txt")
5829                && node.entry_flags & FS_ENTRY_UNREADABLE != 0
5830            {
5831                break;
5832            }
5833            std::thread::sleep(Duration::from_millis(5));
5834        }
5835        let node = mirror.live.get("secret.txt").expect("file present");
5836        assert_ne!(
5837            node.entry_flags & FS_ENTRY_UNREADABLE,
5838            0,
5839            "expected UNREADABLE"
5840        );
5841        assert!(node.content.is_none());
5842
5843        // Make it readable; the retry set re-reads without any new hint.
5844        fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
5845        let deadline = Instant::now() + Duration::from_secs(10);
5846        loop {
5847            pump(&mut mirror, &mut acked);
5848            if mirror.live["secret.txt"].content.as_deref() == Some(&b"classified"[..]) {
5849                break;
5850            }
5851            assert!(Instant::now() < deadline, "content never recovered");
5852            std::thread::sleep(Duration::from_millis(5));
5853        }
5854        handle.command(Command::Stop);
5855        let _ = fs::remove_dir_all(&root);
5856    }
5857
5858    /// Finding: an UNSTABLE/UNREADABLE file's pending re-read must survive a
5859    /// rename within the same settle window — the retry set is rekeyed by
5860    /// the MOVE, so content still arrives at the new path.
5861    #[cfg(unix)]
5862    #[test]
5863    fn retry_survives_rename() {
5864        use std::os::unix::fs::PermissionsExt;
5865        let root = temp_dir();
5866        let old = root.join("a.txt");
5867        fs::write(&old, b"payload").unwrap();
5868        fs::set_permissions(&old, fs::Permissions::from_mode(0o000)).unwrap();
5869        if fs::read(&old).is_ok() {
5870            let _ = fs::remove_dir_all(&root);
5871            return;
5872        }
5873        let (sent, handle, hint_tx) = drive_engine(&root);
5874
5875        let mut mirror = FsMirror::new();
5876        let mut acked = 0usize;
5877        let pump = |mirror: &mut FsMirror, acked: &mut usize| {
5878            for msg in sent.lock().unwrap().clone()[*acked..].iter() {
5879                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5880                    let id = mirror.apply_update(msg).unwrap();
5881                    handle.command(Command::Ack(id));
5882                }
5883                *acked += 1;
5884            }
5885        };
5886        // Wait until "a.txt" is known (UNREADABLE, content-less).
5887        for _ in 0..200 {
5888            pump(&mut mirror, &mut acked);
5889            if mirror.live.contains_key("a.txt") {
5890                break;
5891            }
5892            std::thread::sleep(Duration::from_millis(5));
5893        }
5894        assert!(mirror.live["a.txt"].content.is_none());
5895
5896        // Make readable and rename in the same window; the pending re-read
5897        // must follow to "b.txt".
5898        fs::set_permissions(&old, fs::Permissions::from_mode(0o644)).unwrap();
5899        let new = root.join("b.txt");
5900        fs::rename(&old, &new).unwrap();
5901        hint_tx.send(Hint::Dirty(old));
5902        hint_tx.send(Hint::Dirty(new));
5903        let deadline = Instant::now() + Duration::from_secs(10);
5904        loop {
5905            pump(&mut mirror, &mut acked);
5906            if mirror.live.get("b.txt").and_then(|n| n.content.as_deref()) == Some(&b"payload"[..])
5907            {
5908                break;
5909            }
5910            assert!(
5911                Instant::now() < deadline,
5912                "content did not follow the rename: {:?}",
5913                mirror.live.get("b.txt")
5914            );
5915            std::thread::sleep(Duration::from_millis(5));
5916        }
5917        assert!(!mirror.live.contains_key("a.txt"));
5918        handle.command(Command::Stop);
5919        let _ = fs::remove_dir_all(&root);
5920    }
5921
5922    #[test]
5923    fn diff_plain_changes() {
5924        let mut prev = Index::new();
5925        prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5926        prev.insert("a".into(), meta(FS_ENTRY_FILE, 1, 1, 2));
5927        prev.insert("b".into(), meta(FS_ENTRY_FILE, 1, 1, 3));
5928        let mut curr = Index::new();
5929        curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5930        curr.insert("a".into(), meta(FS_ENTRY_FILE, 2, 2, 2)); // grew
5931        curr.insert("c".into(), meta(FS_ENTRY_FILE, 1, 1, 9)); // new
5932        let ops = diff(&prev, &curr);
5933        assert!(ops.contains(&DiffOp::Delete { path: "b".into() }));
5934        assert!(ops.contains(&DiffOp::Upsert {
5935            path: "a".into(),
5936            content_changed: true
5937        }));
5938        assert!(ops.contains(&DiffOp::Upsert {
5939            path: "c".into(),
5940            content_changed: true
5941        }));
5942        assert_eq!(ops.len(), 3);
5943    }
5944
5945    /// Mass deletes prune to the shallowest removed ancestors — including
5946    /// the sort-order trap where "a!x" and "ab" interleave with "a"'s
5947    /// subtree ('!' < '/' < 'b').
5948    #[test]
5949    fn diff_mass_delete_prunes_to_ancestors() {
5950        let mut prev = Index::new();
5951        prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5952        prev.insert("a".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5953        prev.insert("a!x".into(), meta(FS_ENTRY_FILE, 1, 1, 3));
5954        prev.insert("a/b".into(), meta(FS_ENTRY_DIR, 0, 0, 4));
5955        prev.insert("a/b/c".into(), meta(FS_ENTRY_FILE, 1, 1, 5));
5956        prev.insert("ab".into(), meta(FS_ENTRY_FILE, 1, 1, 6));
5957        let mut curr = Index::new();
5958        curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5959        let mut deleted: Vec<String> = diff(&prev, &curr)
5960            .into_iter()
5961            .map(|op| match op {
5962                DiffOp::Delete { path } => path,
5963                other => panic!("unexpected {other:?}"),
5964            })
5965            .collect();
5966        deleted.sort();
5967        assert_eq!(deleted, ["a", "a!x", "ab"].map(String::from));
5968    }
5969
5970    /// The changed-key diff must agree with the full walk — moves, subtree
5971    /// deletes, adds, metadata changes — and skip keys that turn out equal
5972    /// (a hash fill-in rides the changed set but must emit nothing).
5973    #[test]
5974    fn diff_changed_matches_full_diff() {
5975        let mut prev = Index::new();
5976        prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5977        prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5978        prev.insert("d/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
5979        prev.insert("gone".into(), meta(FS_ENTRY_DIR, 0, 0, 4));
5980        prev.insert("gone/x".into(), meta(FS_ENTRY_FILE, 1, 1, 5));
5981        prev.insert("same".into(), meta(FS_ENTRY_FILE, 2, 2, 6));
5982        prev.insert("touched".into(), meta(FS_ENTRY_FILE, 3, 3, 7));
5983        let mut curr = Index::new();
5984        curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5985        curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5986        curr.insert("e/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
5987        curr.insert("same".into(), meta(FS_ENTRY_FILE, 2, 2, 6));
5988        curr.insert("touched".into(), meta(FS_ENTRY_FILE, 9, 9, 7));
5989        curr.insert("new".into(), meta(FS_ENTRY_FILE, 1, 1, 8));
5990        let changed: std::collections::BTreeSet<String> = [
5991            "d", "d/f", "e", "e/f", "gone", "gone/x", "touched", "new", "same",
5992        ]
5993        .into_iter()
5994        .map(String::from)
5995        .collect();
5996        let full = diff(&prev, &curr);
5997        assert_eq!(diff_changed(&prev, &curr, &changed), full);
5998        assert!(full.contains(&DiffOp::Move {
5999            from: "d".into(),
6000            to: "e".into()
6001        }));
6002        assert!(full.contains(&DiffOp::Delete {
6003            path: "gone".into()
6004        }));
6005        assert!(!full.iter().any(
6006            |op| matches!(op, DiffOp::Upsert { path, .. } | DiffOp::Delete { path } if path == "same")
6007        ));
6008    }
6009
6010    #[test]
6011    fn retry_backoff_doubles_and_caps() {
6012        let latency = Duration::from_millis(20);
6013        assert_eq!(retry_backoff(1, latency), Duration::from_millis(20));
6014        assert_eq!(retry_backoff(2, latency), Duration::from_millis(40));
6015        assert_eq!(retry_backoff(5, latency), Duration::from_millis(320));
6016        assert_eq!(retry_backoff(8, latency), Duration::from_secs(2));
6017        // Shift overflow saturates at the cap instead of wrapping.
6018        assert_eq!(retry_backoff(64, latency), Duration::from_secs(2));
6019    }
6020
6021    /// End-to-end: engine over a real directory with the fake backend;
6022    /// a mirror applying its updates must converge on the disk state.
6023    #[test]
6024    fn engine_converges() {
6025        let root = temp_dir();
6026        fs::write(root.join("hello.txt"), b"hello").unwrap();
6027        fs::create_dir(root.join("sub")).unwrap();
6028        fs::write(root.join("sub/nested.txt"), b"nested").unwrap();
6029
6030        let shared = open_root_unwatched(test_key(&root));
6031        let hint_tx = shared.hint_sender();
6032        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6033        let sent2 = sent.clone();
6034        let opts = SyncOptions {
6035            content: true,
6036            latency: Duration::from_millis(5),
6037            ..Default::default()
6038        };
6039        let handle = start_sync(
6040            &shared,
6041            7,
6042            opts,
6043            Box::new(move |msg| {
6044                sent2.lock().unwrap().push(msg);
6045                true
6046            }),
6047        );
6048
6049        let wait_updates = |min: usize| {
6050            for _ in 0..200 {
6051                if sent.lock().unwrap().len() >= min {
6052                    return;
6053                }
6054                std::thread::sleep(Duration::from_millis(5));
6055            }
6056            panic!("timed out waiting for {min} updates");
6057        };
6058
6059        wait_updates(1);
6060        let mut mirror = FsMirror::new();
6061        let mut acked = 0usize;
6062        let apply_all = |mirror: &mut FsMirror, acked: &mut usize| {
6063            let msgs = sent.lock().unwrap().clone();
6064            for msg in &msgs[*acked..] {
6065                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6066                    let id = mirror.apply_update(msg).expect("valid update");
6067                    handle.command(Command::Ack(id));
6068                }
6069            }
6070            *acked = msgs.len();
6071        };
6072        apply_all(&mut mirror, &mut acked);
6073        assert_eq!(
6074            mirror.live["hello.txt"].content.as_deref(),
6075            Some(&b"hello"[..])
6076        );
6077        assert_eq!(
6078            mirror.live["sub/nested.txt"].content.as_deref(),
6079            Some(&b"nested"[..])
6080        );
6081        assert!(mirror.live.contains_key("")); // the root itself
6082        assert!(mirror.live.contains_key("sub"));
6083
6084        // Mutate and hint.
6085        fs::write(root.join("hello.txt"), b"changed").unwrap();
6086        fs::remove_file(root.join("sub/nested.txt")).unwrap();
6087        fs::write(root.join("sub/other.txt"), b"other").unwrap();
6088        hint_tx.send(Hint::Dirty(root.join("hello.txt")));
6089        hint_tx.send(Hint::Dirty(root.join("sub")));
6090        wait_updates(acked + 1);
6091        std::thread::sleep(Duration::from_millis(30));
6092        apply_all(&mut mirror, &mut acked);
6093        assert_eq!(
6094            mirror.live["hello.txt"].content.as_deref(),
6095            Some(&b"changed"[..])
6096        );
6097        assert!(!mirror.live.contains_key("sub/nested.txt"));
6098        assert_eq!(
6099            mirror.live["sub/other.txt"].content.as_deref(),
6100            Some(&b"other"[..])
6101        );
6102
6103        // Rescan hint (overflow path) must also converge, invisibly.
6104        fs::write(root.join("late.txt"), b"late").unwrap();
6105        hint_tx.send(Hint::Rescan);
6106        wait_updates(acked + 1);
6107        std::thread::sleep(Duration::from_millis(30));
6108        apply_all(&mut mirror, &mut acked);
6109        assert_eq!(
6110            mirror.live["late.txt"].content.as_deref(),
6111            Some(&b"late"[..])
6112        );
6113
6114        handle.command(Command::Stop);
6115        let _ = fs::remove_dir_all(&root);
6116    }
6117
6118    /// The initial snapshot must not outrun the ack window: with a tiny
6119    /// window and many files, the engine stalls until acks arrive and the
6120    /// unacked byte total stays bounded throughout.
6121    #[test]
6122    fn snapshot_respects_ack_window() {
6123        let root = temp_dir();
6124        for i in 0..50 {
6125            fs::write(root.join(format!("f{i:02}.txt")), vec![b'x'; 256]).unwrap();
6126        }
6127        let shared = open_root_unwatched(test_key(&root));
6128        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6129        let sent2 = sent.clone();
6130        let window = 2048usize;
6131        let opts = SyncOptions {
6132            content: true,
6133            latency: Duration::from_millis(5),
6134            window_bytes: window,
6135            batch_target: 512,
6136            ..Default::default()
6137        };
6138        let handle = start_sync(
6139            &shared,
6140            3,
6141            opts,
6142            Box::new(move |msg| {
6143                sent2.lock().unwrap().push(msg);
6144                true
6145            }),
6146        );
6147
6148        let mut mirror = FsMirror::new();
6149        let mut applied = 0usize;
6150        let mut synced = false;
6151        for _ in 0..400 {
6152            std::thread::sleep(Duration::from_millis(5));
6153            let msgs = sent.lock().unwrap().clone();
6154            // Unacked bytes may exceed the window by at most one in-flight
6155            // update (credit is checked before each send).
6156            let outstanding: usize = msgs[applied..].iter().map(|m| m.len()).sum();
6157            let max_update = msgs.iter().map(|m| m.len()).max().unwrap_or(0);
6158            assert!(
6159                outstanding <= window + max_update,
6160                "engine outran the window: {outstanding} unacked bytes"
6161            );
6162            for msg in &msgs[applied..] {
6163                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6164                    let flags = msg[7];
6165                    let id = mirror.apply_update(msg).expect("valid update");
6166                    handle.command(Command::Ack(id));
6167                    if flags & FS_UPDATE_SYNC != 0 {
6168                        synced = true;
6169                    }
6170                }
6171            }
6172            applied = msgs.len();
6173            if synced {
6174                break;
6175            }
6176        }
6177        assert!(synced, "snapshot never reached SYNC");
6178        assert_eq!(
6179            mirror
6180                .live
6181                .iter()
6182                .filter(|(_, n)| n.content.is_some())
6183                .count(),
6184            50
6185        );
6186        // Multiple bounded updates, not one giant one.
6187        assert!(
6188            applied > 5,
6189            "expected a paced series, got {applied} updates"
6190        );
6191
6192        handle.command(Command::Stop);
6193        let _ = fs::remove_dir_all(&root);
6194    }
6195
6196    /// Full path: real notify backend → hints → engine → mirror.
6197    #[test]
6198    fn native_backend_delivers_changes() {
6199        // Canonicalize like `validate_root` does in production: on macOS the
6200        // temp dir lives behind the /var → /private/var symlink, and
6201        // FSEvents reports resolved paths.
6202        let root = temp_dir().canonicalize().unwrap();
6203        fs::write(root.join("seed.txt"), b"seed").unwrap();
6204
6205        // The watcher arms inside open_root, before the initial scan.
6206        let shared = open_root(test_key(&root)).expect("arm native watch");
6207        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6208        let sent2 = sent.clone();
6209        let opts = SyncOptions {
6210            content: true,
6211            latency: Duration::from_millis(5),
6212            ..Default::default()
6213        };
6214        let handle = start_sync(
6215            &shared,
6216            9,
6217            opts,
6218            Box::new(move |msg| {
6219                sent2.lock().unwrap().push(msg);
6220                true
6221            }),
6222        );
6223
6224        let mut mirror = FsMirror::new();
6225        let mut applied = 0usize;
6226        let apply_all = |mirror: &mut FsMirror, applied: &mut usize| {
6227            let msgs = sent.lock().unwrap().clone();
6228            for msg in &msgs[*applied..] {
6229                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6230                    let id = mirror.apply_update(msg).expect("valid update");
6231                    handle.command(Command::Ack(id));
6232                }
6233            }
6234            *applied = msgs.len();
6235        };
6236
6237        // Initial snapshot.
6238        for _ in 0..200 {
6239            apply_all(&mut mirror, &mut applied);
6240            if mirror.live.contains_key("seed.txt") {
6241                break;
6242            }
6243            std::thread::sleep(Duration::from_millis(5));
6244        }
6245        assert!(mirror.live.contains_key("seed.txt"));
6246
6247        // A change observed purely through the native backend.
6248        fs::create_dir(root.join("dir")).unwrap();
6249        fs::write(root.join("dir/new.txt"), b"native").unwrap();
6250        let deadline = Instant::now() + Duration::from_secs(10);
6251        loop {
6252            apply_all(&mut mirror, &mut applied);
6253            if mirror
6254                .live
6255                .get("dir/new.txt")
6256                .is_some_and(|n| n.content.as_deref() == Some(b"native"))
6257            {
6258                break;
6259            }
6260            assert!(
6261                Instant::now() < deadline,
6262                "native backend never delivered the change; live = {:?}",
6263                mirror.live.keys().collect::<Vec<_>>()
6264            );
6265            std::thread::sleep(Duration::from_millis(10));
6266        }
6267
6268        handle.command(Command::Stop);
6269        let _ = fs::remove_dir_all(&root);
6270    }
6271
6272    /// SINGLE + real backend: the watch sits on the file's PARENT, so
6273    /// modify, delete, and recreate of the file all flow with no manual
6274    /// hints — including delete, which a watch armed on the file itself
6275    /// (inode-following) would go silent after.
6276    #[test]
6277    fn single_native_backend_follows_file() {
6278        let dir = temp_dir().canonicalize().unwrap();
6279        let file = dir.join("watched.txt");
6280        fs::write(&file, b"one").unwrap();
6281
6282        let shared = open_single_root(file.clone()).expect("arm native watch on parent");
6283        assert!(shared.is_single());
6284        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6285        let sent2 = sent.clone();
6286        let opts = SyncOptions {
6287            content: true,
6288            recursive: false,
6289            latency: Duration::from_millis(5),
6290            ..Default::default()
6291        };
6292        let handle = start_sync(
6293            &shared,
6294            15,
6295            opts,
6296            Box::new(move |msg| {
6297                sent2.lock().unwrap().push(msg);
6298                true
6299            }),
6300        );
6301
6302        let mut mirror = FsMirror::new();
6303        let mut seen = 0usize;
6304        pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
6305            m.live
6306                .get("")
6307                .is_some_and(|n| n.content.as_deref() == Some(&b"one"[..]))
6308        });
6309        assert_eq!(mirror.live.len(), 1);
6310
6311        fs::write(&file, b"two").unwrap();
6312        pump_until(
6313            &sent,
6314            &handle,
6315            &mut mirror,
6316            &mut seen,
6317            "native modify",
6318            |m| {
6319                m.live
6320                    .get("")
6321                    .is_some_and(|n| n.content.as_deref() == Some(&b"two"[..]))
6322            },
6323        );
6324
6325        fs::remove_file(&file).unwrap();
6326        pump_until(
6327            &sent,
6328            &handle,
6329            &mut mirror,
6330            &mut seen,
6331            "native delete",
6332            |m| m.live.is_empty(),
6333        );
6334        assert_eq!(count_closed(&sent), 0, "delete must not close the sync");
6335
6336        fs::write(&file, b"three").unwrap();
6337        pump_until(
6338            &sent,
6339            &handle,
6340            &mut mirror,
6341            &mut seen,
6342            "native recreate",
6343            |m| {
6344                m.live
6345                    .get("")
6346                    .is_some_and(|n| n.content.as_deref() == Some(&b"three"[..]))
6347            },
6348        );
6349
6350        handle.command(Command::Stop);
6351        let _ = fs::remove_dir_all(&dir);
6352    }
6353
6354    /// The engine's single-property spec: for arbitrary mutation sequences
6355    /// and arbitrary ack timing, applying updates always yields the final
6356    /// tree.
6357    ///
6358    /// A seeded RNG drives random writes/mkdirs/removes/renames over a small
6359    /// path universe while the engine runs, hinting like a backend would
6360    /// (touched path + parent, occasional spurious rescans). Acks are
6361    /// withheld at random so the engine's credit-blocking path is exercised;
6362    /// after the last mutation the mirror must converge on exactly the
6363    /// on-disk tree, content included.
6364    #[test]
6365    fn property_random_mutations_converge() {
6366        for seed in [1u64, 7, 42, 0xdead_beef] {
6367            property_run(seed);
6368        }
6369    }
6370
6371    fn xorshift(state: &mut u64) -> u64 {
6372        *state ^= *state << 13;
6373        *state ^= *state >> 7;
6374        *state ^= *state << 17;
6375        *state
6376    }
6377
6378    fn scan_disk(root: &Path) -> BTreeMap<String, Option<Vec<u8>>> {
6379        fn walk(map: &mut BTreeMap<String, Option<Vec<u8>>>, abs: &Path, rel: &str) {
6380            let Ok(md) = fs::symlink_metadata(abs) else {
6381                return;
6382            };
6383            if md.is_dir() {
6384                map.insert(rel.to_string(), None);
6385                let Ok(entries) = fs::read_dir(abs) else {
6386                    return;
6387                };
6388                for entry in entries.flatten() {
6389                    let name = entry.file_name().to_string_lossy().into_owned();
6390                    let child_rel = if rel.is_empty() {
6391                        name.clone()
6392                    } else {
6393                        format!("{rel}/{name}")
6394                    };
6395                    walk(map, &entry.path(), &child_rel);
6396                }
6397            } else if md.is_file() {
6398                map.insert(rel.to_string(), fs::read(abs).ok());
6399            }
6400        }
6401        let mut map = BTreeMap::new();
6402        walk(&mut map, root, "");
6403        map
6404    }
6405
6406    fn mirror_state(mirror: &FsMirror) -> BTreeMap<String, Option<Vec<u8>>> {
6407        mirror
6408            .live
6409            .iter()
6410            .map(|(path, node)| {
6411                let content = if node.entry_flags & FS_ENTRY_TYPE_MASK == FS_ENTRY_FILE {
6412                    node.content.clone()
6413                } else {
6414                    None
6415                };
6416                (path.clone(), content)
6417            })
6418            .collect()
6419    }
6420
6421    /// One client of a shared root, with its own mirror and ack schedule.
6422    struct PropClient {
6423        sent: Arc<Mutex<Vec<Vec<u8>>>>,
6424        handle: SyncHandle,
6425        mirror: FsMirror,
6426        applied: usize,
6427        highest_unacked: Option<u32>,
6428    }
6429
6430    impl PropClient {
6431        fn start(shared: &Arc<SharedRootHandle>, sync_id: u16) -> Self {
6432            let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6433            let sent2 = sent.clone();
6434            let opts = SyncOptions {
6435                content: true,
6436                latency: Duration::from_millis(3),
6437                window_bytes: 4096,
6438                batch_target: 1024,
6439                ..Default::default()
6440            };
6441            let handle = start_sync(
6442                shared,
6443                sync_id,
6444                opts,
6445                Box::new(move |msg| {
6446                    sent2.lock().unwrap().push(msg);
6447                    true
6448                }),
6449            );
6450            PropClient {
6451                sent,
6452                handle,
6453                mirror: FsMirror::new(),
6454                applied: 0,
6455                highest_unacked: None,
6456            }
6457        }
6458
6459        /// Apply every new message; ack the highest applied id only with
6460        /// probability 1/2 (cumulative acks make withholding harmless for
6461        /// correctness — only pacing may stall until the final flush).
6462        fn pump(&mut self, rng: &mut u64, flush: bool) {
6463            use blit_remote::fs::S2C_FS_UPDATE;
6464            let msgs = self.sent.lock().unwrap().clone();
6465            for msg in &msgs[self.applied..] {
6466                if msg[0] == S2C_FS_UPDATE {
6467                    let id = self.mirror.apply_update(msg).expect("valid update");
6468                    self.highest_unacked = Some(id);
6469                }
6470            }
6471            self.applied = msgs.len();
6472            if let Some(id) = self.highest_unacked
6473                && (flush || xorshift(rng).is_multiple_of(2))
6474            {
6475                self.handle.command(Command::Ack(id));
6476                self.highest_unacked = None;
6477            }
6478        }
6479    }
6480
6481    fn property_run(seed: u64) {
6482        let root = temp_dir();
6483        let shared = open_root_unwatched(test_key(&root));
6484        let hint_tx = shared.hint_sender();
6485        // Two independently paced clients of one shared root: convergence
6486        // must hold for both, whatever their ack schedules.
6487        let mut clients = [
6488            PropClient::start(&shared, 11),
6489            PropClient::start(&shared, 12),
6490        ];
6491
6492        let mut rng = seed | 1;
6493        let dirs = ["", "d0", "d1", "d0/d2"];
6494        let names = ["f0", "f1", "f2", "f3"];
6495
6496        for _round in 0..25 {
6497            let mutations = 1 + xorshift(&mut rng) % 3;
6498            for _ in 0..mutations {
6499                let dir = dirs[(xorshift(&mut rng) % dirs.len() as u64) as usize];
6500                let name = names[(xorshift(&mut rng) % names.len() as u64) as usize];
6501                let rel: PathBuf = if dir.is_empty() {
6502                    name.into()
6503                } else {
6504                    Path::new(dir).join(name)
6505                };
6506                let abs = root.join(&rel);
6507                match xorshift(&mut rng) % 5 {
6508                    // Write a file (creating parents).
6509                    0 | 1 => {
6510                        let _ = fs::create_dir_all(abs.parent().unwrap());
6511                        let len = (xorshift(&mut rng) % 64) as usize;
6512                        let byte = (xorshift(&mut rng) & 0xFF) as u8;
6513                        let _ = fs::write(&abs, vec![byte; len]);
6514                    }
6515                    // Make a directory.
6516                    2 => {
6517                        let _ = fs::create_dir_all(&abs);
6518                    }
6519                    // Remove whatever is there.
6520                    3 => {
6521                        if abs.is_dir() {
6522                            let _ = fs::remove_dir_all(&abs);
6523                        } else {
6524                            let _ = fs::remove_file(&abs);
6525                        }
6526                    }
6527                    // Rename to a sibling slot.
6528                    _ => {
6529                        let target = abs.with_file_name(
6530                            names[(xorshift(&mut rng) % names.len() as u64) as usize],
6531                        );
6532                        if target != abs {
6533                            let _ = fs::rename(&abs, &target);
6534                            hint_tx.send(Hint::Dirty(target));
6535                        }
6536                    }
6537                }
6538                // Hint like a backend: the touched path and its parent.
6539                hint_tx.send(Hint::Dirty(abs.clone()));
6540                hint_tx.send(Hint::Dirty(abs.parent().unwrap().to_path_buf()));
6541            }
6542            // Occasional loss signal: everything degrades to a rescan.
6543            if xorshift(&mut rng).is_multiple_of(16) {
6544                hint_tx.send(Hint::Rescan);
6545            }
6546            for client in &mut clients {
6547                client.pump(&mut rng, false);
6548            }
6549            std::thread::sleep(Duration::from_millis(xorshift(&mut rng) % 8));
6550        }
6551
6552        // Convergence: with mutations stopped and acks flushed, every
6553        // client's mirror must reach exactly the on-disk state.
6554        let disk = scan_disk(&root);
6555        let deadline = Instant::now() + Duration::from_secs(30);
6556        loop {
6557            for client in &mut clients {
6558                client.pump(&mut rng, true);
6559            }
6560            if clients
6561                .iter()
6562                .all(|client| mirror_state(&client.mirror) == disk)
6563            {
6564                break;
6565            }
6566            assert!(
6567                Instant::now() < deadline,
6568                "seed {seed}: mirrors never converged\n first: {:?}\n second: {:?}\n disk: {:?}",
6569                mirror_state(&clients[0].mirror).keys().collect::<Vec<_>>(),
6570                mirror_state(&clients[1].mirror).keys().collect::<Vec<_>>(),
6571                disk.keys().collect::<Vec<_>>(),
6572            );
6573            std::thread::sleep(Duration::from_millis(5));
6574        }
6575
6576        for client in &clients {
6577            client.handle.command(Command::Stop);
6578        }
6579        let _ = fs::remove_dir_all(&root);
6580    }
6581
6582    /// Two opens of the same key share one root (same Arc, one reconciler),
6583    /// and both clients see live changes.
6584    #[test]
6585    fn shared_root_serves_multiple_clients() {
6586        let root = temp_dir();
6587        fs::write(root.join("a.txt"), b"alpha").unwrap();
6588        let shared = open_root_unwatched(test_key(&root));
6589        let joined = open_root_unwatched(test_key(&root));
6590        assert!(Arc::ptr_eq(&shared, &joined));
6591        let hint_tx = shared.hint_sender();
6592
6593        let start = |sync_id: u16| {
6594            let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6595            let sent2 = sent.clone();
6596            let opts = SyncOptions {
6597                content: true,
6598                latency: Duration::from_millis(5),
6599                ..Default::default()
6600            };
6601            let handle = start_sync(
6602                &shared,
6603                sync_id,
6604                opts,
6605                Box::new(move |msg| {
6606                    sent2.lock().unwrap().push(msg);
6607                    true
6608                }),
6609            );
6610            (sent, handle)
6611        };
6612        let (sent_a, handle_a) = start(21);
6613        let (sent_b, handle_b) = start(22);
6614
6615        let converge = |sent: &Arc<Mutex<Vec<Vec<u8>>>>,
6616                        handle: &SyncHandle,
6617                        mirror: &mut FsMirror,
6618                        applied: &mut usize,
6619                        path: &str,
6620                        want: &[u8]| {
6621            for _ in 0..400 {
6622                let msgs = sent.lock().unwrap().clone();
6623                for msg in &msgs[*applied..] {
6624                    if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6625                        let id = mirror.apply_update(msg).expect("valid update");
6626                        handle.command(Command::Ack(id));
6627                    }
6628                }
6629                *applied = msgs.len();
6630                if mirror
6631                    .live
6632                    .get(path)
6633                    .is_some_and(|n| n.content.as_deref() == Some(want))
6634                {
6635                    return;
6636                }
6637                std::thread::sleep(Duration::from_millis(5));
6638            }
6639            panic!("mirror never saw {path}");
6640        };
6641
6642        let mut mirror_a = FsMirror::new();
6643        let mut mirror_b = FsMirror::new();
6644        let (mut applied_a, mut applied_b) = (0usize, 0usize);
6645        converge(
6646            &sent_a,
6647            &handle_a,
6648            &mut mirror_a,
6649            &mut applied_a,
6650            "a.txt",
6651            b"alpha",
6652        );
6653        converge(
6654            &sent_b,
6655            &handle_b,
6656            &mut mirror_b,
6657            &mut applied_b,
6658            "a.txt",
6659            b"alpha",
6660        );
6661
6662        // One mutation, one hint: both clients converge on it.
6663        fs::write(root.join("b.txt"), b"beta").unwrap();
6664        hint_tx.send(Hint::Dirty(root.join("b.txt")));
6665        converge(
6666            &sent_a,
6667            &handle_a,
6668            &mut mirror_a,
6669            &mut applied_a,
6670            "b.txt",
6671            b"beta",
6672        );
6673        converge(
6674            &sent_b,
6675            &handle_b,
6676            &mut mirror_b,
6677            &mut applied_b,
6678            "b.txt",
6679            b"beta",
6680        );
6681
6682        handle_a.command(Command::Stop);
6683        handle_b.command(Command::Stop);
6684        let _ = fs::remove_dir_all(&root);
6685    }
6686
6687    #[test]
6688    fn delta_roundtrips_through_client_apply() {
6689        use blit_remote::fs::apply_fs_delta;
6690        let cases: &[(&[u8], &[u8])] = &[
6691            (b"hello world", b"hello world and more"),   // append
6692            (b"hello world", b"say: hello world"),       // prepend
6693            (b"hello cruel world", b"hello kind world"), // middle edit
6694            (b"hello world", b"hello"),                  // truncate
6695            (b"hello", b"goodbye"),                      // rewrite
6696            (b"", b"from nothing"),                      // create
6697            (b"to nothing", b""),                        // empty out
6698            (b"same", b"same"),                          // identical
6699        ];
6700        for (base, new) in cases {
6701            let ops = encode_delta(base, new);
6702            assert_eq!(
6703                apply_fs_delta(base, &ops).as_deref(),
6704                Some(*new),
6705                "case {:?} -> {:?}",
6706                base,
6707                new
6708            );
6709        }
6710        // An append's delta is one COPY plus the tail, far below full size.
6711        let base = vec![b'x'; 10_000];
6712        let mut new = base.clone();
6713        new.extend_from_slice(b"tail");
6714        let ops = encode_delta(&base, &new);
6715        assert!(
6716            ops.len() < 20,
6717            "append delta should be tiny, got {}",
6718            ops.len()
6719        );
6720        assert_eq!(apply_fs_delta(&base, &ops).unwrap(), new);
6721    }
6722
6723    #[test]
6724    fn blob_store_lru_eviction() {
6725        let mut store = BlobStore::new(1000);
6726        let blob = |b: u8| Arc::new(vec![b; 400]);
6727        store.put(1, blob(1));
6728        store.put(2, blob(2));
6729        store.get(1); // refresh: 2 is now the oldest
6730        store.put(3, blob(3)); // 1200 bytes > budget: evicts 2
6731        assert!(store.get(2).is_none());
6732        assert!(store.get(1).is_some());
6733        assert!(store.get(3).is_some());
6734        // A blob over the whole budget is refused outright.
6735        store.put(4, Arc::new(vec![0; 2000]));
6736        assert!(store.get(4).is_none());
6737    }
6738
6739    /// Engine-level: an append to a synced file must arrive as a delta
6740    /// record (not full content), an identical rewrite as metadata-only,
6741    /// and the mirror must track disk throughout.
6742    #[test]
6743    fn engine_sends_deltas() {
6744        use blit_remote::fs::{FsContent, FsRecord, fs_records, fs_update_records};
6745
6746        let root = temp_dir();
6747        let big = vec![b'x'; 4096];
6748        fs::write(root.join("log.txt"), &big).unwrap();
6749
6750        let shared = open_root_unwatched(test_key(&root));
6751        let hint_tx = shared.hint_sender();
6752        let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6753        let sent2 = sent.clone();
6754        let opts = SyncOptions {
6755            content: true,
6756            latency: Duration::from_millis(5),
6757            ..Default::default()
6758        };
6759        let handle = start_sync(
6760            &shared,
6761            13,
6762            opts,
6763            Box::new(move |msg| {
6764                sent2.lock().unwrap().push(msg);
6765                true
6766            }),
6767        );
6768
6769        let mut mirror = FsMirror::new();
6770        let mut applied = 0usize;
6771        // Collect (path, content-kind) for every upsert applied.
6772        let mut kinds: Vec<(String, &'static str)> = Vec::new();
6773        let apply_all = |mirror: &mut FsMirror,
6774                         applied: &mut usize,
6775                         kinds: &mut Vec<(String, &'static str)>| {
6776            let msgs = sent.lock().unwrap().clone();
6777            for msg in &msgs[*applied..] {
6778                if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6779                    let records = fs_update_records(msg).expect("decompress");
6780                    for record in fs_records(&records) {
6781                        if let FsRecord::Upsert { path, content, .. } = record {
6782                            let kind = match content {
6783                                FsContent::None => "none",
6784                                FsContent::Full(_) => "full",
6785                                FsContent::Delta(_) => "delta",
6786                            };
6787                            kinds.push((path.to_string(), kind));
6788                        }
6789                    }
6790                    let id = mirror.apply_update(msg).expect("valid update");
6791                    handle.command(Command::Ack(id));
6792                }
6793            }
6794            *applied = msgs.len();
6795        };
6796
6797        let wait_for = |sent: &Arc<Mutex<Vec<Vec<u8>>>>, min: usize| {
6798            for _ in 0..400 {
6799                if sent.lock().unwrap().len() >= min {
6800                    std::thread::sleep(Duration::from_millis(20));
6801                    return;
6802                }
6803                std::thread::sleep(Duration::from_millis(5));
6804            }
6805            panic!("timed out waiting for {min} messages");
6806        };
6807
6808        // Initial snapshot: full content.
6809        wait_for(&sent, 1);
6810        apply_all(&mut mirror, &mut applied, &mut kinds);
6811        assert!(kinds.contains(&("log.txt".into(), "full")));
6812        assert_eq!(mirror.live["log.txt"].content.as_deref(), Some(&big[..]));
6813
6814        // Append: must flow as a delta.
6815        kinds.clear();
6816        let mut appended = big.clone();
6817        appended.extend_from_slice(b"appended tail");
6818        fs::write(root.join("log.txt"), &appended).unwrap();
6819        hint_tx.send(Hint::Dirty(root.join("log.txt")));
6820        wait_for(&sent, applied + 1);
6821        apply_all(&mut mirror, &mut applied, &mut kinds);
6822        assert!(
6823            kinds.contains(&("log.txt".into(), "delta")),
6824            "expected a delta record, got {kinds:?}"
6825        );
6826        assert_eq!(
6827            mirror.live["log.txt"].content.as_deref(),
6828            Some(&appended[..])
6829        );
6830
6831        // Rewrite with identical bytes (mtime changes): metadata-only,
6832        // the mirror keeps its content.
6833        kinds.clear();
6834        std::thread::sleep(Duration::from_millis(10)); // ensure mtime moves
6835        fs::write(root.join("log.txt"), &appended).unwrap();
6836        hint_tx.send(Hint::Dirty(root.join("log.txt")));
6837        wait_for(&sent, applied + 1);
6838        apply_all(&mut mirror, &mut applied, &mut kinds);
6839        assert!(
6840            kinds.contains(&("log.txt".into(), "none")),
6841            "expected metadata-only, got {kinds:?}"
6842        );
6843        assert_eq!(
6844            mirror.live["log.txt"].content.as_deref(),
6845            Some(&appended[..])
6846        );
6847        assert_eq!(
6848            mirror.live["log.txt"].entry_flags & blit_remote::fs::FS_ENTRY_NO_CONTENT,
6849            0
6850        );
6851
6852        handle.command(Command::Stop);
6853        let _ = fs::remove_dir_all(&root);
6854    }
6855
6856    #[test]
6857    fn read_verified_stable() {
6858        let root = temp_dir();
6859        let f = root.join("x");
6860        fs::write(&f, b"stable").unwrap();
6861        match read_verified(&f) {
6862            ReadOutcome::Stable(data) => assert_eq!(data, b"stable"),
6863            _ => panic!("expected stable read"),
6864        }
6865        match read_verified(&root.join("missing")) {
6866            ReadOutcome::Unreadable => {}
6867            _ => panic!("expected unreadable"),
6868        }
6869        let _ = fs::remove_dir_all(&root);
6870    }
6871}