Skip to main content

blit_remote/
fs.rs

1//! Filesystem state sync wire protocol (docs/fs-watch.md).
2//!
3//! The server maintains a canonical replica of a watched tree and streams
4//! per-client state diffs (`FS_UPDATE`). Clients apply records to a map and
5//! acknowledge. Snapshots and recovery are `RESET … SYNC` staged series;
6//! loss and overflow are not wire concepts.
7//!
8//! All integers little-endian, tightly packed, as everywhere in the protocol.
9
10use std::collections::BTreeMap;
11
12/// Start (or replace) a sync: [0x40][nonce:2][flags:2][latency_ms:2][inline_max:4][path_len:2][path:N]
13/// then, when `FS_SYNC_EXCLUDE` is set, [exclude_len:2][exclude:M]; then,
14/// when `FS_SYNC_FROM_PTY` is set, [src_pty_id:2].
15pub const C2S_FS_SYNC: u8 = 0x40;
16/// Stop a sync: [0x41][sync_id:2]
17pub const C2S_FS_STOP: u8 = 0x41;
18/// Cumulative acknowledgement: [0x42][sync_id:2][update_id:4]
19pub const C2S_FS_ACK: u8 = 0x42;
20/// Fetch full content of one file: [0x43][nonce:2][sync_id:2][path_len:2][path:N]
21pub const C2S_FS_FETCH: u8 = 0x43;
22/// Write file content (CAS): [0x44][nonce:2][sync_id:2][flags:1][base:16][mode:4][content_kind:1][path_len:2][path:N][content:LZ4]
23pub const C2S_FS_WRITE: u8 = 0x44;
24/// Metadata op (mkdir/remove/rename): [0x45][nonce:2][sync_id:2][op:1][flags:1][base:16][mode:4][a_len:2][a:N][b_len:2][b:N]
25pub const C2S_FS_OP: u8 = 0x45;
26/// Fuzzy file search under a root (no sync): [0x46][nonce:2][limit:2][root_len:2][root:N][query_len:2][query:N].
27/// Returns paths (root-relative) whose basename subsequence-matches `query`.
28pub const C2S_FS_SEARCH: u8 = 0x46;
29/// Fetch the candidate file list under a root (no sync), for client-side
30/// fuzzy search (docs/design/fs-search.md): [0x47][nonce:2][flags:1][root_len:2][root:N].
31/// `flags` is reserved; nonzero answers `INVALID`.
32pub const C2S_FS_INDEX: u8 = 0x47;
33
34/// Content search under a root (no sync), docs/design/fs-grep.md:
35/// [0x48][nonce:2][flags:1][max_matches:2][max_per_file:2][root_len:2][root:N][query_len:2][query:N].
36/// `flags` is `FS_GREP_CASE_SENSITIVE` | `FS_GREP_REGEX`; zero maxima mean
37/// the server defaults. Unlike `FS_INDEX` the walk does not skip ignored
38/// files — it ranks them last.
39pub const C2S_FS_GREP: u8 = 0x48;
40
41/// Sync accepted or rejected: [0x40][nonce:2][sync_id:2][status:1][detail_len:2][detail:N]
42/// On success detail is the canonical root (UTF-8); on failure a diagnostic.
43pub const S2C_FS_SYNCED: u8 = 0x40;
44/// State diff: [0x41][sync_id:2][update_id:4][flags:1][records:LZ4]
45pub const S2C_FS_UPDATE: u8 = 0x41;
46/// Fetch response: [0x42][nonce:2][status:1][data:LZ4]
47pub const S2C_FS_FILE: u8 = 0x42;
48/// Sync terminated: [0x43][sync_id:2][reason:1]
49pub const S2C_FS_CLOSED: u8 = 0x43;
50/// Write/op result: [0x44][nonce:2][status:1][hash:16][mtime_ns:8]
51pub const S2C_FS_DONE: u8 = 0x44;
52/// Search result: [0x45][nonce:2][status:1][count:2] repeated{ [path_len:2][path:N] }
53pub const S2C_FS_SEARCH: u8 = 0x45;
54/// Index result: [0x46][nonce:2][status:1][flags:1][count:4][paths:LZ4]
55/// where the decompressed payload is repeated{ [path_len:2][path:N] },
56/// root-relative, sorted. Status is the unified table (`FS_DONE_*`).
57pub const S2C_FS_INDEX: u8 = 0x46;
58
59/// Grep result: [0x47][nonce:2][status:1][flags:1][detail_len:2][detail:N][records:LZ4]
60/// where the decompressed payload is a `[record_len:4][kind:1][..]` stream of
61/// `FILE`/`MATCH` records (docs/design/fs-grep.md). Status is the unified
62/// table (`FS_DONE_*`); `detail` carries a regex compile error on `INVALID`.
63pub const S2C_FS_GREP: u8 = 0x47;
64
65/// `S2C_HELLO` feature bit: server supports the `FS_*` message family,
66/// reads and writes alike (docs/design/fs-watch.md, docs/design/fs-write.md).
67/// A read-only deployment (`BLIT_FS_WRITE=0`) still advertises this bit and
68/// answers `FS_WRITE`/`FS_OP` with `FS_DONE_PERMISSION`.
69pub const FEATURE_FS: u32 = 1 << 6;
70
71/// `sync_id` reported by a failed `FS_SYNCED`.
72pub const FS_SYNC_ID_INVALID: u16 = 0xFFFF;
73
74// C2S_FS_SYNC flags. Two bytes: the exclusion work filled the first one,
75// and a 1-byte field with no room left is a field that forces the next
76// feature into a worse encoding.
77pub const FS_SYNC_RECURSIVE: u16 = 1 << 0;
78pub const FS_SYNC_CONTENT: u16 = 1 << 1;
79pub const FS_SYNC_CROSS_FILESYSTEM: u16 = 1 << 2;
80/// The sync root is a single FILE, not a directory: the mirror holds
81/// exactly one entry — the root itself — keyed by the empty relative path
82/// "" (the same key a directory sync gives its root). Combining with
83/// `RECURSIVE` is invalid, and a directory root answers the invalid-path
84/// error (docs/design/fs-watch.md "Single-file sync"). Content,
85/// `inline_max`, `FS_FETCH`, and the write family behave as for any other
86/// sync, addressing path "".
87pub const FS_SYNC_SINGLE: u16 = 1 << 3;
88/// Resolve the sync's base directory from a pty's live cwd: a trailing
89/// `[src_pty_id:2]` names a pty and the server joins `path` onto its cwd
90/// (docs/ide.md Decision 3). It comes last, after any `EXCLUDE` field.
91pub const FS_SYNC_FROM_PTY: u16 = 1 << 4;
92/// Omit every entry whose final component is exactly `.git` — directory or
93/// gitfile — from enumeration, hashing, hints, and records. A pure name
94/// filter: no git data is read (docs/design/fs-watch.md "Ignoring").
95pub const FS_SYNC_EXCLUDE_GIT: u16 = 1 << 5;
96/// Honor `.gitignore` in and above the root, plus the governing
97/// repository's `$GIT_DIR/info/exclude`, the user's `core.excludesFile`,
98/// and its `core.ignorecase`. Off by default, so a sync only narrows when
99/// asked.
100pub const FS_SYNC_GITIGNORE: u16 = 1 << 6;
101/// A trailing `[exclude_len:2][exclude:M]` carries client patterns —
102/// gitignore syntax, one per line, anchored at the sync root and applied
103/// above every other rule, so `!keep` re-includes. The flag is what makes
104/// the field parseable, and what makes a server too old to filter refuse
105/// the sync outright instead of silently mirroring the whole tree.
106pub const FS_SYNC_EXCLUDE: u16 = 1 << 7;
107/// Honor `.ignore` in and above the root — ripgrep's convention, which a
108/// project uses to hide things from tooling without telling git to stop
109/// tracking them. Separate from `GITIGNORE` because the two answer
110/// different questions, and `.ignore` brings none of git's repository-wide
111/// sources with it. `FS_INDEX` and `FS_GREP` apply both together; a sync
112/// picks.
113pub const FS_SYNC_DOTIGNORE: u16 = 1 << 8;
114
115/// Bits a `C2S_FS_SYNC` may set; anything else answers with the
116/// unknown-flags refusal.
117pub const FS_SYNC_FLAGS_KNOWN: u16 = FS_SYNC_RECURSIVE
118    | FS_SYNC_CONTENT
119    | FS_SYNC_CROSS_FILESYSTEM
120    | FS_SYNC_SINGLE
121    | FS_SYNC_FROM_PTY
122    | FS_SYNC_EXCLUDE_GIT
123    | FS_SYNC_GITIGNORE
124    | FS_SYNC_EXCLUDE
125    | FS_SYNC_DOTIGNORE;
126
127/// Every exclusion flag, which is also the set `SINGLE` rejects.
128pub const FS_SYNC_EXCLUSION_FLAGS: u16 =
129    FS_SYNC_EXCLUDE_GIT | FS_SYNC_GITIGNORE | FS_SYNC_EXCLUDE | FS_SYNC_DOTIGNORE;
130
131/// `C2S_FS_SYNC` flag-combination validity: `SINGLE` syncs exactly one
132/// file, so `RECURSIVE` contradicts it and the pair is rejected at
133/// validation (docs/design/fs-watch.md "Single-file sync"). The exclusion
134/// flags apply to enumeration, which `SINGLE` does none of, so they are
135/// rejected with it too rather than silently doing nothing.
136pub fn fs_sync_flags_valid(flags: u16) -> bool {
137    flags & FS_SYNC_SINGLE == 0 || flags & (FS_SYNC_RECURSIVE | FS_SYNC_EXCLUSION_FLAGS) == 0
138}
139
140// S2C_FS_UPDATE flags.
141/// Begin a staged snapshot: apply this and subsequent records to an empty
142/// staging map instead of the live map.
143pub const FS_UPDATE_RESET: u8 = 1 << 0;
144/// Atomically replace the live map with the staging map (no-op without one).
145pub const FS_UPDATE_SYNC: u8 = 1 << 1;
146
147// S2C_FS_SYNCED status.
148pub const FS_STATUS_OK: u8 = 0;
149pub const FS_STATUS_NOT_FOUND: u8 = 1;
150pub const FS_STATUS_PERMISSION_DENIED: u8 = 2;
151pub const FS_STATUS_RESOURCE_LIMIT: u8 = 3;
152pub const FS_STATUS_OTHER: u8 = 4;
153
154// S2C_FS_INDEX flags.
155/// The walk hit its entry or byte budget; the list is a prefix, not the
156/// whole tree. Clients should keep server-side `FS_SEARCH` for this root.
157pub const FS_INDEX_TRUNCATED: u8 = 1 << 0;
158/// Protocol cap on `S2C_FS_INDEX.count`. The server's entry budget clamps
159/// to this, and parsers treat a larger count as malformed — without it, a
160/// hostile count of tiny records could force a giant preallocation from a
161/// small frame (the decompression guard bounds bytes, not record counts).
162pub const FS_INDEX_MAX_COUNT: usize = 1_000_000;
163
164// S2C_FS_FILE status.
165pub const FS_FILE_OK: u8 = 0;
166pub const FS_FILE_NOT_FOUND: u8 = 1;
167pub const FS_FILE_UNREADABLE: u8 = 2;
168pub const FS_FILE_OTHER: u8 = 3;
169
170// FS_DONE status — the unified git/lsp status table (docs/git.md
171// "Statuses"), NOT FS_SYNCED's grandfathered 0-4, plus one fs addition.
172// Same numeric values as `GIT_STATUS_*` where they overlap.
173pub const FS_DONE_OK: u8 = 0;
174pub const FS_DONE_NOT_FOUND: u8 = 2;
175pub const FS_DONE_WRONG_TYPE: u8 = 3;
176pub const FS_DONE_PERMISSION: u8 = 4;
177pub const FS_DONE_TOO_LARGE: u8 = 5;
178pub const FS_DONE_BUDGET: u8 = 6;
179pub const FS_DONE_INVALID: u8 = 7;
180pub const FS_DONE_OTHER: u8 = 9;
181
182// C2S_FS_GREP flags (docs/design/fs-grep.md).
183
184/// Match case exactly. Unset (the default) is case-insensitive.
185pub const FS_GREP_CASE_SENSITIVE: u8 = 1 << 0;
186/// `query` is a regex. Unset (the default) treats it as a literal string.
187pub const FS_GREP_REGEX: u8 = 1 << 1;
188/// Search gitignored files too, ranked after every tracked one. Unset (the
189/// default) applies ignore rules and skips them — on a real repo that is
190/// the difference between milliseconds and seconds, because the ignored
191/// pass is what has to descend into `target/`.
192pub const FS_GREP_NO_IGNORE: u8 = 1 << 2;
193/// Match only whole words: the pattern is wrapped in `\b(?:...)\b` after
194/// any literal escaping, so it composes with either mode. Same semantics
195/// as `blit terminal grep --word-regexp`.
196pub const FS_GREP_WORD: u8 = 1 << 3;
197/// Bits a request may set; anything else answers `INVALID`.
198pub const FS_GREP_FLAGS_KNOWN: u8 =
199    FS_GREP_CASE_SENSITIVE | FS_GREP_REGEX | FS_GREP_NO_IGNORE | FS_GREP_WORD;
200
201// S2C_FS_GREP flags.
202
203/// A budget clipped the search: matches exist that are not in this response.
204/// Exact — set only when something was actually dropped.
205pub const FS_GREP_TRUNCATED: u8 = 1 << 0;
206
207// S2C_FS_GREP record kinds.
208
209pub const FS_GREP_RECORD_FILE: u8 = 0x01;
210pub const FS_GREP_RECORD_MATCH: u8 = 0x02;
211
212// FILE record flags.
213
214/// The file is gitignored. It still gets searched — ignore rules rank
215/// rather than filter here — but sorts after every non-ignored file.
216pub const FS_GREP_FILE_IGNORED: u8 = 1 << 0;
217
218/// Longest matched line returned, in bytes; longer lines are truncated on a
219/// UTF-8 boundary so a minified bundle costs one line of wire, not one line
220/// of megabyte.
221pub const FS_GREP_MAX_LINE: usize = 512;
222/// A precondition failed (CAS mismatch, create-exclusive on an existing
223/// path, conditional remove on a changed file). On `CONFLICT`,
224/// `FS_DONE.hash` carries the current on-disk hash so the client rebases
225/// without a round trip. Added in lsp's `10 WARMING` extension style.
226pub const FS_DONE_CONFLICT: u8 = 11;
227
228/// Human-readable name for an `FS_DONE` status code.
229pub fn fs_done_status_text(status: u8) -> &'static str {
230    match status {
231        FS_DONE_OK => "ok",
232        FS_DONE_NOT_FOUND => "not found",
233        FS_DONE_WRONG_TYPE => "wrong type",
234        FS_DONE_PERMISSION => "permission denied",
235        FS_DONE_TOO_LARGE => "too large",
236        FS_DONE_BUDGET => "budget exhausted",
237        FS_DONE_INVALID => "invalid request",
238        FS_DONE_CONFLICT => "conflict",
239        _ => "error",
240    }
241}
242
243// FS_WRITE flags.
244/// Ignore `base`; unconditional overwrite/create ("Save As, replace").
245pub const FS_WRITE_NO_CAS: u8 = 1 << 0;
246/// Create missing parent directories.
247pub const FS_WRITE_MKPARENTS: u8 = 1 << 1;
248/// fsync the file and its parent (F_FULLFSYNC on macOS) before returning.
249pub const FS_WRITE_DURABLE: u8 = 1 << 2;
250/// Write through a final-component symlink whose resolved target stays
251/// under the root; default refuses one.
252pub const FS_WRITE_FOLLOW_SYMLINK: u8 = 1 << 3;
253
254// FS_WRITE content_kind: 0/1 are full bytes; 2 is a delta-against-`base`
255// write — the COPY/INSERT instruction stream of `apply_fs_delta`, applied
256// server-side against the exact bytes the CAS `base` names, so it
257// requires a real base (NO_CAS or a zero base answers INVALID) and a
258// stale base answers CONFLICT, never a corrupted apply
259// (docs/design/fs-write.md "Wire"). A client may always send full.
260pub const FS_WRITE_CONTENT_FULL: u8 = 1;
261pub const FS_WRITE_CONTENT_DELTA: u8 = 2;
262
263// FS_OP op selector.
264pub const FS_OP_MKDIR: u8 = 1;
265pub const FS_OP_REMOVE: u8 = 2;
266pub const FS_OP_RENAME: u8 = 3;
267/// Create or retarget a symlink at `b` whose target is the verbatim string
268/// `a` (not a wire path; not confined to the root). `base` CASes on the
269/// current entry at `b` — a symlink's content hash is BLAKE3-128 of its
270/// target bytes (docs/design/fs-write.md "Links").
271pub const FS_OP_SYMLINK: u8 = 4;
272/// Create a hard link at `b` to the regular file at `a` (both wire paths
273/// under the root). `base` CASes on the current entry at `b`.
274pub const FS_OP_HARDLINK: u8 = 5;
275
276// FS_OP flags (subset of FS_WRITE's, same bit positions).
277pub const FS_OP_NO_CAS: u8 = 1 << 0;
278pub const FS_OP_MKPARENTS: u8 = 1 << 1;
279
280// S2C_FS_CLOSED reasons.
281pub const FS_CLOSED_CLIENT_REQUEST: u8 = 0;
282pub const FS_CLOSED_ROOT_GONE: u8 = 1;
283pub const FS_CLOSED_PERMISSION_LOST: u8 = 2;
284pub const FS_CLOSED_BACKEND_FAILED: u8 = 3;
285pub const FS_CLOSED_RESOURCE_LIMIT: u8 = 4;
286
287// Record kinds inside FS_UPDATE.
288pub const FS_RECORD_UPSERT: u8 = 0x01;
289pub const FS_RECORD_DELETE: u8 = 0x02;
290pub const FS_RECORD_MOVE: u8 = 0x03;
291
292// UPSERT entry_flags: bits 0-1 node type, higher bits flags.
293pub const FS_ENTRY_TYPE_MASK: u8 = 0b11;
294pub const FS_ENTRY_FILE: u8 = 0;
295pub const FS_ENTRY_DIR: u8 = 1;
296pub const FS_ENTRY_SYMLINK: u8 = 2;
297pub const FS_ENTRY_OTHER: u8 = 3;
298/// Entry exists but its content could not be read.
299pub const FS_ENTRY_UNREADABLE: u8 = 1 << 2;
300/// Content omitted: over `inline_max` or the sync did not request content.
301pub const FS_ENTRY_NO_CONTENT: u8 = 1 << 3;
302/// File changed repeatedly while being read; content omitted, another
303/// upsert follows once it settles.
304pub const FS_ENTRY_UNSTABLE: u8 = 1 << 4;
305/// Set on an `FS_ENTRY_SYMLINK` whose target is a directory, which the sync
306/// enumerates like any other. Clients need it to know the entry is expandable:
307/// the type alone cannot distinguish a link to a directory from one to a file,
308/// and a non-recursive sync has no children listed yet to infer it from.
309pub const FS_ENTRY_LINK_DIR: u8 = 1 << 5;
310/// Set on a directory whose enumeration skipped at least one child the
311/// sync's exclusion rules cover (docs/design/fs-watch.md "Ignoring").
312/// Excluded paths are absent rather than marked, so without this a client
313/// cannot tell an empty directory from a filtered one — a file browser
314/// needs it to say "some items hidden" instead of showing a folder that
315/// looks wrong.
316///
317/// Prompt when it goes up, lazy when it comes down: the first excluded
318/// child costs one re-listing of its directory, while the *last* one
319/// disappearing clears the flag only at that directory's next enumeration.
320/// Chasing the clear would mean re-listing on every excluded-file event,
321/// which is the cost the exclusion exists to avoid — so a client may
322/// briefly see "hidden items" on a directory that no longer has any.
323pub const FS_ENTRY_FILTERED: u8 = 1 << 6;
324
325// UPSERT content kinds.
326pub const FS_CONTENT_NONE: u8 = 0;
327pub const FS_CONTENT_FULL: u8 = 1;
328pub const FS_CONTENT_DELTA: u8 = 2;
329
330/// One decoded record from an `FS_UPDATE` payload.
331#[derive(Clone, Debug, PartialEq, Eq)]
332pub enum FsRecord<'a> {
333    Upsert {
334        path: &'a str,
335        entry_flags: u8,
336        size: u64,
337        mtime_ns: u64,
338        mode: u32,
339        /// BLAKE3 truncated to 128 bits; zero for non-files or unknown.
340        hash: u128,
341        content: FsContent<'a>,
342    },
343    /// Remove `path` and every path under it.
344    Delete { path: &'a str },
345    /// Rename the `from` subtree to `to`.
346    Move { from: &'a str, to: &'a str },
347}
348
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub enum FsContent<'a> {
351    None,
352    Full(&'a [u8]),
353    /// LEB128 instruction stream against the last content this client
354    /// acked for this path: 0x01 COPY [offset][len], 0x02 INSERT [len][bytes].
355    Delta(&'a [u8]),
356}
357
358/// Append one record to an uncompressed `FS_UPDATE` records buffer.
359pub fn append_fs_record(buf: &mut Vec<u8>, record: &FsRecord<'_>) {
360    let start = buf.len();
361    buf.extend_from_slice(&0u32.to_le_bytes()); // record_len placeholder
362    match record {
363        FsRecord::Upsert {
364            path,
365            entry_flags,
366            size,
367            mtime_ns,
368            mode,
369            hash,
370            content,
371        } => {
372            buf.push(FS_RECORD_UPSERT);
373            buf.push(*entry_flags);
374            let pb = path.as_bytes();
375            buf.extend_from_slice(&(pb.len() as u16).to_le_bytes());
376            buf.extend_from_slice(pb);
377            buf.extend_from_slice(&size.to_le_bytes());
378            buf.extend_from_slice(&mtime_ns.to_le_bytes());
379            buf.extend_from_slice(&mode.to_le_bytes());
380            buf.extend_from_slice(&hash.to_le_bytes());
381            match content {
382                FsContent::None => buf.push(FS_CONTENT_NONE),
383                FsContent::Full(data) => {
384                    buf.push(FS_CONTENT_FULL);
385                    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
386                    buf.extend_from_slice(data);
387                }
388                FsContent::Delta(ops) => {
389                    buf.push(FS_CONTENT_DELTA);
390                    buf.extend_from_slice(&(ops.len() as u32).to_le_bytes());
391                    buf.extend_from_slice(ops);
392                }
393            }
394        }
395        FsRecord::Delete { path } => {
396            buf.push(FS_RECORD_DELETE);
397            let pb = path.as_bytes();
398            buf.extend_from_slice(&(pb.len() as u16).to_le_bytes());
399            buf.extend_from_slice(pb);
400        }
401        FsRecord::Move { from, to } => {
402            buf.push(FS_RECORD_MOVE);
403            let fb = from.as_bytes();
404            buf.extend_from_slice(&(fb.len() as u16).to_le_bytes());
405            buf.extend_from_slice(fb);
406            let tb = to.as_bytes();
407            buf.extend_from_slice(&(tb.len() as u16).to_le_bytes());
408            buf.extend_from_slice(tb);
409        }
410    }
411    let len = (buf.len() - start - 4) as u32;
412    buf[start..start + 4].copy_from_slice(&len.to_le_bytes());
413}
414
415/// Iterate records in an uncompressed `FS_UPDATE` payload.
416/// Unknown kinds are skipped via `record_len`; a malformed record ends
417/// iteration (the update is applied up to that point and the rest dropped —
418/// forward-compatible with future record extensions).
419pub struct FsRecordIter<'a> {
420    data: &'a [u8],
421}
422
423pub fn fs_records(data: &[u8]) -> FsRecordIter<'_> {
424    FsRecordIter { data }
425}
426
427fn take_path<'a>(body: &mut &'a [u8]) -> Option<&'a str> {
428    if body.len() < 2 {
429        return None;
430    }
431    let len = u16::from_le_bytes([body[0], body[1]]) as usize;
432    if body.len() < 2 + len {
433        return None;
434    }
435    let s = std::str::from_utf8(&body[2..2 + len]).ok()?;
436    *body = &body[2 + len..];
437    Some(s)
438}
439
440impl<'a> Iterator for FsRecordIter<'a> {
441    type Item = FsRecord<'a>;
442
443    fn next(&mut self) -> Option<FsRecord<'a>> {
444        loop {
445            if self.data.len() < 4 {
446                return None;
447            }
448            let rec_len =
449                u32::from_le_bytes([self.data[0], self.data[1], self.data[2], self.data[3]])
450                    as usize;
451            if self.data.len() < 4 + rec_len || rec_len == 0 {
452                return None;
453            }
454            let mut body = &self.data[4..4 + rec_len];
455            self.data = &self.data[4 + rec_len..];
456            let kind = body[0];
457            body = &body[1..];
458            match kind {
459                FS_RECORD_UPSERT => {
460                    if body.is_empty() {
461                        return None;
462                    }
463                    let entry_flags = body[0];
464                    body = &body[1..];
465                    let path = take_path(&mut body)?;
466                    if body.len() < 8 + 8 + 4 + 16 + 1 {
467                        return None;
468                    }
469                    let size = u64::from_le_bytes(body[0..8].try_into().unwrap());
470                    let mtime_ns = u64::from_le_bytes(body[8..16].try_into().unwrap());
471                    let mode = u32::from_le_bytes(body[16..20].try_into().unwrap());
472                    let hash = u128::from_le_bytes(body[20..36].try_into().unwrap());
473                    let content_kind = body[36];
474                    body = &body[37..];
475                    let content = match content_kind {
476                        FS_CONTENT_NONE => FsContent::None,
477                        FS_CONTENT_FULL | FS_CONTENT_DELTA => {
478                            if body.len() < 4 {
479                                return None;
480                            }
481                            let len = u32::from_le_bytes(body[0..4].try_into().unwrap()) as usize;
482                            if body.len() < 4 + len {
483                                return None;
484                            }
485                            let data = &body[4..4 + len];
486                            if content_kind == FS_CONTENT_FULL {
487                                FsContent::Full(data)
488                            } else {
489                                FsContent::Delta(data)
490                            }
491                        }
492                        _ => return None,
493                    };
494                    return Some(FsRecord::Upsert {
495                        path,
496                        entry_flags,
497                        size,
498                        mtime_ns,
499                        mode,
500                        hash,
501                        content,
502                    });
503                }
504                FS_RECORD_DELETE => {
505                    let path = take_path(&mut body)?;
506                    return Some(FsRecord::Delete { path });
507                }
508                FS_RECORD_MOVE => {
509                    let from = take_path(&mut body)?;
510                    let to = take_path(&mut body)?;
511                    return Some(FsRecord::Move { from, to });
512                }
513                _ => continue, // unknown kind: skip via record_len
514            }
515        }
516    }
517}
518
519// ---------------------------------------------------------------------------
520// Message builders
521// ---------------------------------------------------------------------------
522
523pub fn msg_fs_sync(
524    nonce: u16,
525    flags: u16,
526    latency_ms: u16,
527    inline_max: u32,
528    path: &str,
529) -> Vec<u8> {
530    msg_fs_sync_full(nonce, flags, latency_ms, inline_max, path, "", None)
531}
532
533/// Build a `C2S_FS_SYNC` carrying client exclude patterns: gitignore
534/// syntax, one per line, anchored at the sync root
535/// (docs/design/fs-watch.md "Ignoring"). Sets `FS_SYNC_EXCLUDE`; an empty
536/// `exclude` builds the plain form instead, so a caller need not special-case
537/// "no patterns".
538pub fn msg_fs_sync_excluding(
539    nonce: u16,
540    flags: u16,
541    latency_ms: u16,
542    inline_max: u32,
543    path: &str,
544    exclude: &str,
545) -> Vec<u8> {
546    msg_fs_sync_full(nonce, flags, latency_ms, inline_max, path, exclude, None)
547}
548
549/// Build a `C2S_FS_SYNC` whose base directory the server resolves from a pty's
550/// live cwd: sets `FS_SYNC_FROM_PTY` and appends `[src_pty_id:2]` last
551/// (docs/ide.md Decision 3). `path` is joined onto the resolved cwd
552/// server-side (empty = the cwd itself).
553pub fn msg_fs_sync_from_pty(
554    nonce: u16,
555    flags: u16,
556    latency_ms: u16,
557    inline_max: u32,
558    path: &str,
559    src_pty_id: u16,
560) -> Vec<u8> {
561    msg_fs_sync_full(
562        nonce,
563        flags,
564        latency_ms,
565        inline_max,
566        path,
567        "",
568        Some(src_pty_id),
569    )
570}
571
572/// Every `C2S_FS_SYNC` variant, in field order. The optional trailers are
573/// self-describing through their flags — `EXCLUDE` first, `FROM_PTY` last —
574/// which is what lets a parser skip one to reach the other.
575pub fn msg_fs_sync_full(
576    nonce: u16,
577    flags: u16,
578    latency_ms: u16,
579    inline_max: u32,
580    path: &str,
581    exclude: &str,
582    src_pty_id: Option<u16>,
583) -> Vec<u8> {
584    let pb = path.as_bytes();
585    let eb = exclude.as_bytes();
586    let mut flags = flags;
587    if eb.is_empty() {
588        flags &= !FS_SYNC_EXCLUDE;
589    } else {
590        flags |= FS_SYNC_EXCLUDE;
591    }
592    if src_pty_id.is_some() {
593        flags |= FS_SYNC_FROM_PTY;
594    }
595    let mut msg = Vec::with_capacity(FS_SYNC_HEADER + pb.len() + eb.len() + 4);
596    msg.push(C2S_FS_SYNC);
597    msg.extend_from_slice(&nonce.to_le_bytes());
598    msg.extend_from_slice(&flags.to_le_bytes());
599    msg.extend_from_slice(&latency_ms.to_le_bytes());
600    msg.extend_from_slice(&inline_max.to_le_bytes());
601    msg.extend_from_slice(&(pb.len() as u16).to_le_bytes());
602    msg.extend_from_slice(pb);
603    if !eb.is_empty() {
604        msg.extend_from_slice(&(eb.len() as u16).to_le_bytes());
605        msg.extend_from_slice(eb);
606    }
607    if let Some(src) = src_pty_id {
608        msg.extend_from_slice(&src.to_le_bytes());
609    }
610    msg
611}
612
613/// Fixed part of `C2S_FS_SYNC`, up to and including `path_len`.
614pub const FS_SYNC_HEADER: usize = 13;
615
616/// The `flags` field of a `C2S_FS_SYNC`, or `None` if it is truncated.
617pub fn fs_sync_flags(msg: &[u8]) -> Option<u16> {
618    if msg.first().copied() != Some(C2S_FS_SYNC) || msg.len() < FS_SYNC_HEADER {
619        return None;
620    }
621    Some(u16::from_le_bytes([msg[3], msg[4]]))
622}
623
624/// End of the `path` field, i.e. the offset of the first trailer.
625fn fs_sync_trailer_start(msg: &[u8]) -> Option<usize> {
626    if msg.first().copied() != Some(C2S_FS_SYNC) || msg.len() < FS_SYNC_HEADER {
627        return None;
628    }
629    let path_len = u16::from_le_bytes([msg[11], msg[12]]) as usize;
630    let end = FS_SYNC_HEADER.checked_add(path_len)?;
631    (end <= msg.len()).then_some(end)
632}
633
634/// Byte range of the `exclude` payload in an `EXCLUDE` `C2S_FS_SYNC`, and
635/// the offset just past it. `None` when the flag is unset or the field is
636/// truncated — the caller refuses the request rather than guessing.
637fn fs_sync_exclude_span(msg: &[u8]) -> Option<(std::ops::Range<usize>, usize)> {
638    let off = fs_sync_trailer_start(msg)?;
639    if fs_sync_flags(msg)? & FS_SYNC_EXCLUDE == 0 {
640        return Some((off..off, off));
641    }
642    let len_bytes = msg.get(off..off + 2)?;
643    let len = u16::from_le_bytes([len_bytes[0], len_bytes[1]]) as usize;
644    let start = off + 2;
645    let end = start.checked_add(len)?;
646    (end <= msg.len()).then_some((start..end, end))
647}
648
649/// Client exclude patterns from a `C2S_FS_SYNC` — `""` when `EXCLUDE` is
650/// unset. `None` means malformed: a truncated field or non-UTF-8 patterns.
651pub fn fs_sync_exclude(msg: &[u8]) -> Option<&str> {
652    let (span, _) = fs_sync_exclude_span(msg)?;
653    std::str::from_utf8(&msg[span]).ok()
654}
655
656/// Extract the trailing `src_pty_id` from a `FROM_PTY` `C2S_FS_SYNC`; `None`
657/// when the flag is unset or the field is missing.
658pub fn fs_sync_src_pty(msg: &[u8]) -> Option<u16> {
659    if fs_sync_flags(msg)? & FS_SYNC_FROM_PTY == 0 {
660        return None;
661    }
662    let (_, off) = fs_sync_exclude_span(msg)?;
663    let b = msg.get(off..off + 2)?;
664    Some(u16::from_le_bytes([b[0], b[1]]))
665}
666
667/// Rebase a `FROM_PTY` `C2S_FS_SYNC` onto a resolved `cwd`: join `cwd`/`path`
668/// and clear `FROM_PTY`, producing a plain path-based sync the handler
669/// consumes unchanged. `cwd` `None` (source pty gone) keeps `path` verbatim.
670/// Any exclude field rides along — the filter is the client's, not the
671/// pty's, and dropping it here would silently widen the sync.
672pub fn fs_sync_rebase(msg: &[u8], cwd: Option<&str>) -> Option<Vec<u8>> {
673    fs_sync_src_pty(msg)?;
674    let nonce = u16::from_le_bytes([msg[1], msg[2]]);
675    let flags = fs_sync_flags(msg)? & !FS_SYNC_FROM_PTY;
676    let latency_ms = u16::from_le_bytes([msg[5], msg[6]]);
677    let inline_max = u32::from_le_bytes([msg[7], msg[8], msg[9], msg[10]]);
678    let path_len = u16::from_le_bytes([msg[11], msg[12]]) as usize;
679    let path = std::str::from_utf8(msg.get(FS_SYNC_HEADER..FS_SYNC_HEADER + path_len)?).ok()?;
680    let exclude = fs_sync_exclude(msg)?;
681    let joined = cwd.map(|dir| {
682        std::path::Path::new(dir)
683            .join(path)
684            .to_string_lossy()
685            .into_owned()
686    });
687    let eff = joined.as_deref().unwrap_or(path);
688    Some(msg_fs_sync_full(
689        nonce, flags, latency_ms, inline_max, eff, exclude, None,
690    ))
691}
692
693pub fn msg_fs_stop(sync_id: u16) -> Vec<u8> {
694    let mut msg = Vec::with_capacity(3);
695    msg.push(C2S_FS_STOP);
696    msg.extend_from_slice(&sync_id.to_le_bytes());
697    msg
698}
699
700pub fn msg_fs_ack(sync_id: u16, update_id: u32) -> Vec<u8> {
701    let mut msg = Vec::with_capacity(7);
702    msg.push(C2S_FS_ACK);
703    msg.extend_from_slice(&sync_id.to_le_bytes());
704    msg.extend_from_slice(&update_id.to_le_bytes());
705    msg
706}
707
708pub fn msg_fs_fetch(nonce: u16, sync_id: u16, path: &str) -> Vec<u8> {
709    let pb = path.as_bytes();
710    let mut msg = Vec::with_capacity(7 + pb.len());
711    msg.push(C2S_FS_FETCH);
712    msg.extend_from_slice(&nonce.to_le_bytes());
713    msg.extend_from_slice(&sync_id.to_le_bytes());
714    msg.extend_from_slice(&(pb.len() as u16).to_le_bytes());
715    msg.extend_from_slice(pb);
716    msg
717}
718
719/// Build a `C2S_FS_SEARCH`.
720pub fn msg_fs_search(nonce: u16, limit: u16, root: &str, query: &str) -> Vec<u8> {
721    let rb = root.as_bytes();
722    let qb = query.as_bytes();
723    let mut m = Vec::with_capacity(9 + rb.len() + qb.len());
724    m.push(C2S_FS_SEARCH);
725    m.extend_from_slice(&nonce.to_le_bytes());
726    m.extend_from_slice(&limit.to_le_bytes());
727    m.extend_from_slice(&(rb.len() as u16).to_le_bytes());
728    m.extend_from_slice(rb);
729    m.extend_from_slice(&(qb.len() as u16).to_le_bytes());
730    m.extend_from_slice(qb);
731    m
732}
733
734/// Parse a `C2S_FS_SEARCH` → `(nonce, limit, root, query)`.
735pub fn parse_fs_search(data: &[u8]) -> Option<(u16, u16, String, String)> {
736    if data.first().copied() != Some(C2S_FS_SEARCH) || data.len() < 9 {
737        return None;
738    }
739    let nonce = u16::from_le_bytes([data[1], data[2]]);
740    let limit = u16::from_le_bytes([data[3], data[4]]);
741    let rl = u16::from_le_bytes([data[5], data[6]]) as usize;
742    let ro = 7;
743    if data.len() < ro + rl + 2 {
744        return None;
745    }
746    let root = String::from_utf8_lossy(&data[ro..ro + rl]).into_owned();
747    let qo = ro + rl;
748    let ql = u16::from_le_bytes([data[qo], data[qo + 1]]) as usize;
749    let qs = qo + 2;
750    if data.len() < qs + ql {
751        return None;
752    }
753    let query = String::from_utf8_lossy(&data[qs..qs + ql]).into_owned();
754    Some((nonce, limit, root, query))
755}
756
757/// Build an `S2C_FS_SEARCH` result.
758pub fn msg_fs_search_result(nonce: u16, status: u8, paths: &[String]) -> Vec<u8> {
759    let mut m = Vec::with_capacity(6 + paths.iter().map(|p| 2 + p.len()).sum::<usize>());
760    m.push(S2C_FS_SEARCH);
761    m.extend_from_slice(&nonce.to_le_bytes());
762    m.push(status);
763    m.extend_from_slice(&(paths.len() as u16).to_le_bytes());
764    for p in paths {
765        let pb = p.as_bytes();
766        m.extend_from_slice(&(pb.len() as u16).to_le_bytes());
767        m.extend_from_slice(pb);
768    }
769    m
770}
771
772/// Parse an `S2C_FS_SEARCH` → `(nonce, status, paths)`.
773pub fn parse_fs_search_result(data: &[u8]) -> Option<(u16, u8, Vec<String>)> {
774    if data.first().copied() != Some(S2C_FS_SEARCH) || data.len() < 6 {
775        return None;
776    }
777    let nonce = u16::from_le_bytes([data[1], data[2]]);
778    let status = data[3];
779    let count = u16::from_le_bytes([data[4], data[5]]) as usize;
780    let mut paths = Vec::with_capacity(count);
781    let mut off = 6;
782    for _ in 0..count {
783        if off + 2 > data.len() {
784            return None;
785        }
786        let pl = u16::from_le_bytes([data[off], data[off + 1]]) as usize;
787        off += 2;
788        if off + pl > data.len() {
789            return None;
790        }
791        paths.push(String::from_utf8_lossy(&data[off..off + pl]).into_owned());
792        off += pl;
793    }
794    Some((nonce, status, paths))
795}
796
797/// Build a `C2S_FS_INDEX`.
798pub fn msg_fs_index(nonce: u16, root: &str) -> Vec<u8> {
799    let rb = root.as_bytes();
800    let mut m = Vec::with_capacity(6 + rb.len());
801    m.push(C2S_FS_INDEX);
802    m.extend_from_slice(&nonce.to_le_bytes());
803    m.push(0); // flags, reserved
804    m.extend_from_slice(&(rb.len() as u16).to_le_bytes());
805    m.extend_from_slice(rb);
806    m
807}
808
809/// Parse a `C2S_FS_INDEX` → `(nonce, flags, root)`.
810pub fn parse_fs_index(data: &[u8]) -> Option<(u16, u8, String)> {
811    // [0x47][nonce:2][flags:1][root_len:2][root:N]
812    if data.first().copied() != Some(C2S_FS_INDEX) || data.len() < 6 {
813        return None;
814    }
815    let nonce = u16::from_le_bytes([data[1], data[2]]);
816    let flags = data[3];
817    let rl = u16::from_le_bytes([data[4], data[5]]) as usize;
818    if data.len() < 6 + rl {
819        return None;
820    }
821    let root = String::from_utf8_lossy(&data[6..6 + rl]).into_owned();
822    Some((nonce, flags, root))
823}
824
825/// Build an `S2C_FS_INDEX` result. `paths` should be root-relative and
826/// sorted — sorted lists share prefixes, which is what makes the LZ4
827/// payload small.
828pub fn msg_fs_index_result(nonce: u16, status: u8, flags: u8, paths: &[String]) -> Vec<u8> {
829    let mut raw = Vec::with_capacity(paths.iter().map(|p| 2 + p.len()).sum::<usize>());
830    for p in paths {
831        let pb = p.as_bytes();
832        raw.extend_from_slice(&(pb.len() as u16).to_le_bytes());
833        raw.extend_from_slice(pb);
834    }
835    let compressed = lz4_flex::compress_prepend_size(&raw);
836    let mut m = Vec::with_capacity(9 + compressed.len());
837    m.push(S2C_FS_INDEX);
838    m.extend_from_slice(&nonce.to_le_bytes());
839    m.push(status);
840    m.push(flags);
841    m.extend_from_slice(&(paths.len() as u32).to_le_bytes());
842    m.extend_from_slice(&compressed);
843    m
844}
845
846/// Parse an `S2C_FS_INDEX` → `(nonce, status, flags, paths)`. Applies the
847/// standard decompression guard; `None` = malformed, over-sized, or a
848/// payload that disagrees with `count`.
849pub fn parse_fs_index_result(data: &[u8]) -> Option<(u16, u8, u8, Vec<String>)> {
850    // [0x46][nonce:2][status:1][flags:1][count:4][paths:LZ4]
851    if data.first().copied() != Some(S2C_FS_INDEX) || data.len() < 9 {
852        return None;
853    }
854    let nonce = u16::from_le_bytes([data[1], data[2]]);
855    let status = data[3];
856    let flags = data[4];
857    let count = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
858    if count > FS_INDEX_MAX_COUNT {
859        return None;
860    }
861    let raw = decompress_guarded(&data[9..])?;
862    // Each record is at least 2 bytes, so `count` bounds the preallocation.
863    if count > raw.len() / 2 + 1 {
864        return None;
865    }
866    let mut paths = Vec::with_capacity(count);
867    let mut off = 0;
868    while off < raw.len() {
869        if off + 2 > raw.len() {
870            return None;
871        }
872        let pl = u16::from_le_bytes([raw[off], raw[off + 1]]) as usize;
873        off += 2;
874        if off + pl > raw.len() {
875            return None;
876        }
877        paths.push(String::from_utf8_lossy(&raw[off..off + pl]).into_owned());
878        off += pl;
879    }
880    if paths.len() != count {
881        return None;
882    }
883    Some((nonce, status, flags, paths))
884}
885
886pub fn msg_fs_synced(nonce: u16, sync_id: u16, status: u8, detail: &str) -> Vec<u8> {
887    let db = detail.as_bytes();
888    let mut msg = Vec::with_capacity(8 + db.len());
889    msg.push(S2C_FS_SYNCED);
890    msg.extend_from_slice(&nonce.to_le_bytes());
891    msg.extend_from_slice(&sync_id.to_le_bytes());
892    msg.push(status);
893    msg.extend_from_slice(&(db.len() as u16).to_le_bytes());
894    msg.extend_from_slice(db);
895    msg
896}
897
898/// Build an `FS_UPDATE` from an uncompressed records buffer.
899pub fn msg_fs_update(sync_id: u16, update_id: u32, flags: u8, records: &[u8]) -> Vec<u8> {
900    let compressed = lz4_flex::compress_prepend_size(records);
901    let mut msg = Vec::with_capacity(8 + compressed.len());
902    msg.push(S2C_FS_UPDATE);
903    msg.extend_from_slice(&sync_id.to_le_bytes());
904    msg.extend_from_slice(&update_id.to_le_bytes());
905    msg.push(flags);
906    msg.extend_from_slice(&compressed);
907    msg
908}
909
910pub fn msg_fs_file(nonce: u16, status: u8, data: &[u8]) -> Vec<u8> {
911    let compressed = lz4_flex::compress_prepend_size(data);
912    let mut msg = Vec::with_capacity(4 + compressed.len());
913    msg.push(S2C_FS_FILE);
914    msg.extend_from_slice(&nonce.to_le_bytes());
915    msg.push(status);
916    msg.extend_from_slice(&compressed);
917    msg
918}
919
920pub fn msg_fs_closed(sync_id: u16, reason: u8) -> Vec<u8> {
921    let mut msg = Vec::with_capacity(4);
922    msg.push(S2C_FS_CLOSED);
923    msg.extend_from_slice(&sync_id.to_le_bytes());
924    msg.push(reason);
925    msg
926}
927
928// ---------------------------------------------------------------------------
929// Client-side reducer
930// ---------------------------------------------------------------------------
931
932/// One node in a mirrored tree.
933#[derive(Clone, Debug, PartialEq, Eq)]
934pub struct FsNode {
935    pub entry_flags: u8,
936    pub size: u64,
937    pub mtime_ns: u64,
938    pub mode: u32,
939    pub hash: u128,
940    /// Present when the sync requested content and the file fits the
941    /// inline limit. `None` does not mean empty — check `entry_flags`.
942    pub content: Option<Vec<u8>>,
943}
944
945/// Cap on any single LZ4-decompressed fs payload — the protocol-wide
946/// [`crate::MAX_DECOMPRESSED`] guard (docs/protocol.md). Checked against
947/// the prepended size *before* allocating, so a hostile or corrupt length
948/// cannot force a giant allocation (the terminal path has the same guard).
949/// Large trees arrive as many bounded updates, never one huge one; content
950/// records are bounded by the sync's `inline_max` (16 MiB default).
951pub const FS_MAX_DECOMPRESSED: usize = crate::MAX_DECOMPRESSED;
952
953/// Decompress a `compress_prepend_size` payload, refusing declared sizes
954/// over [`FS_MAX_DECOMPRESSED`].
955fn decompress_guarded(data: &[u8]) -> Option<Vec<u8>> {
956    if data.len() < 4 {
957        return None;
958    }
959    let declared = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
960    if declared > FS_MAX_DECOMPRESSED {
961        return None;
962    }
963    lz4_flex::decompress_size_prepended(data).ok()
964}
965
966/// Decompress an `FS_UPDATE`'s records buffer (for consumers that want the
967/// records themselves, e.g. event display), with the standard guard.
968pub fn fs_update_records(msg: &[u8]) -> Option<Vec<u8>> {
969    if msg.len() < 8 || msg[0] != S2C_FS_UPDATE {
970        return None;
971    }
972    decompress_guarded(&msg[8..])
973}
974
975/// Parse an `S2C_FS_FILE` message (starting at the opcode byte) into
976/// `(nonce, status, data)`. Applies the same decompression guard as
977/// [`FsMirror::apply_update`]; `None` = malformed or over-sized.
978pub fn parse_fs_file(msg: &[u8]) -> Option<(u16, u8, Vec<u8>)> {
979    if msg.len() < 4 || msg[0] != S2C_FS_FILE {
980        return None;
981    }
982    let nonce = u16::from_le_bytes([msg[1], msg[2]]);
983    let status = msg[3];
984    let data = decompress_guarded(&msg[4..])?;
985    Some((nonce, status, data))
986}
987
988// ---------------------------------------------------------------------------
989// Write family (docs/design/fs-write.md): nonce request/response side-band
990// operations against disk. The write itself echoes nothing — the existing
991// per-client differ re-emits UPSERT/MOVE/DELETE once the reconciler
992// re-indexes the landed change.
993// ---------------------------------------------------------------------------
994
995/// A content write (`C2S_FS_WRITE`). `base` is the CAS precondition: the
996/// current on-disk content hash to match (non-zero), zero for
997/// create-exclusive, ignored under `FS_WRITE_NO_CAS`.
998#[derive(Clone, Debug, PartialEq, Eq)]
999pub struct FsWrite {
1000    pub nonce: u16,
1001    pub sync_id: u16,
1002    pub flags: u8,
1003    pub base: u128,
1004    pub mode: u32,
1005    pub content_kind: u8,
1006    pub path: String,
1007    pub content: Vec<u8>,
1008}
1009
1010pub fn msg_fs_write(w: &FsWrite) -> Vec<u8> {
1011    let pb = w.path.as_bytes();
1012    let compressed = lz4_flex::compress_prepend_size(&w.content);
1013    let mut msg = Vec::with_capacity(29 + pb.len() + compressed.len());
1014    msg.push(C2S_FS_WRITE);
1015    msg.extend_from_slice(&w.nonce.to_le_bytes());
1016    msg.extend_from_slice(&w.sync_id.to_le_bytes());
1017    msg.push(w.flags);
1018    msg.extend_from_slice(&w.base.to_le_bytes());
1019    msg.extend_from_slice(&w.mode.to_le_bytes());
1020    msg.push(w.content_kind);
1021    msg.extend_from_slice(&(pb.len() as u16).to_le_bytes());
1022    msg.extend_from_slice(pb);
1023    msg.extend_from_slice(&compressed);
1024    msg
1025}
1026
1027/// Parse a `C2S_FS_WRITE`. `None` = malformed, non-UTF-8 path, or content
1028/// whose declared decompressed size exceeds the protocol cap.
1029pub fn parse_fs_write(msg: &[u8]) -> Option<FsWrite> {
1030    // [nonce:2][sync_id:2][flags:1][base:16][mode:4][content_kind:1][path_len:2][path:N][content:LZ4]
1031    if msg.len() < 29 || msg[0] != C2S_FS_WRITE {
1032        return None;
1033    }
1034    let nonce = u16::from_le_bytes([msg[1], msg[2]]);
1035    let sync_id = u16::from_le_bytes([msg[3], msg[4]]);
1036    let flags = msg[5];
1037    let base = u128::from_le_bytes(msg[6..22].try_into().unwrap());
1038    let mode = u32::from_le_bytes(msg[22..26].try_into().unwrap());
1039    let content_kind = msg[26];
1040    let path_len = u16::from_le_bytes([msg[27], msg[28]]) as usize;
1041    let path = std::str::from_utf8(msg.get(29..29 + path_len)?)
1042        .ok()?
1043        .to_string();
1044    let content = decompress_guarded(&msg[29 + path_len..])?;
1045    Some(FsWrite {
1046        nonce,
1047        sync_id,
1048        flags,
1049        base,
1050        mode,
1051        content_kind,
1052        path,
1053        content,
1054    })
1055}
1056
1057/// A metadata op (`C2S_FS_OP`): `op` selects mkdir/remove/rename; `a` is
1058/// the primary path, `b` the rename destination. `base`/`mode` are used
1059/// by only some ops (like `LSP_QUERY`'s `line`/`col`).
1060#[derive(Clone, Debug, PartialEq, Eq)]
1061pub struct FsOp {
1062    pub nonce: u16,
1063    pub sync_id: u16,
1064    pub op: u8,
1065    pub flags: u8,
1066    pub base: u128,
1067    pub mode: u32,
1068    pub a: String,
1069    pub b: String,
1070}
1071
1072pub fn msg_fs_op(o: &FsOp) -> Vec<u8> {
1073    let ab = o.a.as_bytes();
1074    let bb = o.b.as_bytes();
1075    let mut msg = Vec::with_capacity(29 + ab.len() + bb.len());
1076    msg.push(C2S_FS_OP);
1077    msg.extend_from_slice(&o.nonce.to_le_bytes());
1078    msg.extend_from_slice(&o.sync_id.to_le_bytes());
1079    msg.push(o.op);
1080    msg.push(o.flags);
1081    msg.extend_from_slice(&o.base.to_le_bytes());
1082    msg.extend_from_slice(&o.mode.to_le_bytes());
1083    msg.extend_from_slice(&(ab.len() as u16).to_le_bytes());
1084    msg.extend_from_slice(ab);
1085    msg.extend_from_slice(&(bb.len() as u16).to_le_bytes());
1086    msg.extend_from_slice(bb);
1087    msg
1088}
1089
1090/// Parse a `C2S_FS_OP`. `None` = malformed or a non-UTF-8 path.
1091pub fn parse_fs_op(msg: &[u8]) -> Option<FsOp> {
1092    // [nonce:2][sync_id:2][op:1][flags:1][base:16][mode:4][a_len:2][a:N][b_len:2][b:N]
1093    if msg.len() < 29 || msg[0] != C2S_FS_OP {
1094        return None;
1095    }
1096    let nonce = u16::from_le_bytes([msg[1], msg[2]]);
1097    let sync_id = u16::from_le_bytes([msg[3], msg[4]]);
1098    let op = msg[5];
1099    let flags = msg[6];
1100    let base = u128::from_le_bytes(msg[7..23].try_into().unwrap());
1101    let mode = u32::from_le_bytes(msg[23..27].try_into().unwrap());
1102    let a_len = u16::from_le_bytes([msg[27], msg[28]]) as usize;
1103    let a = std::str::from_utf8(msg.get(29..29 + a_len)?)
1104        .ok()?
1105        .to_string();
1106    let b_off = 29 + a_len;
1107    let b_len = u16::from_le_bytes([*msg.get(b_off)?, *msg.get(b_off + 1)?]) as usize;
1108    let b = std::str::from_utf8(msg.get(b_off + 2..b_off + 2 + b_len)?)
1109        .ok()?
1110        .to_string();
1111    Some(FsOp {
1112        nonce,
1113        sync_id,
1114        op,
1115        flags,
1116        base,
1117        mode,
1118        a,
1119        b,
1120    })
1121}
1122
1123/// Build an `S2C_FS_DONE`. On success `hash`/`mtime_ns` are the post-op
1124/// stat; on `CONFLICT`, `hash` is the current on-disk hash.
1125pub fn msg_fs_done(nonce: u16, status: u8, hash: u128, mtime_ns: u64) -> Vec<u8> {
1126    let mut msg = Vec::with_capacity(28);
1127    msg.push(S2C_FS_DONE);
1128    msg.extend_from_slice(&nonce.to_le_bytes());
1129    msg.push(status);
1130    msg.extend_from_slice(&hash.to_le_bytes());
1131    msg.extend_from_slice(&mtime_ns.to_le_bytes());
1132    msg
1133}
1134
1135/// Parse an `S2C_FS_DONE` into `(nonce, status, hash, mtime_ns)`.
1136pub fn parse_fs_done(msg: &[u8]) -> Option<(u16, u8, u128, u64)> {
1137    // [nonce:2][status:1][hash:16][mtime_ns:8]
1138    if msg.len() < 28 || msg[0] != S2C_FS_DONE {
1139        return None;
1140    }
1141    let nonce = u16::from_le_bytes([msg[1], msg[2]]);
1142    let status = msg[3];
1143    let hash = u128::from_le_bytes(msg[4..20].try_into().unwrap());
1144    let mtime_ns = u64::from_le_bytes(msg[20..28].try_into().unwrap());
1145    Some((nonce, status, hash, mtime_ns))
1146}
1147
1148/// The complete client obligation: apply updates, read `live`.
1149///
1150/// Paths are relative to the sync root, `/`-separated, "" = the root itself.
1151#[derive(Debug, Default)]
1152pub struct FsMirror {
1153    pub live: BTreeMap<String, FsNode>,
1154    staging: Option<BTreeMap<String, FsNode>>,
1155}
1156
1157impl FsMirror {
1158    pub fn new() -> Self {
1159        Self::default()
1160    }
1161
1162    /// Apply one `FS_UPDATE` message (starting at the opcode byte).
1163    /// Returns `Some(update_id)` to acknowledge, `None` if malformed.
1164    pub fn apply_update(&mut self, msg: &[u8]) -> Option<u32> {
1165        if msg.len() < 8 || msg[0] != S2C_FS_UPDATE {
1166            return None;
1167        }
1168        let update_id = u32::from_le_bytes([msg[3], msg[4], msg[5], msg[6]]);
1169        let flags = msg[7];
1170        let records = decompress_guarded(&msg[8..])?;
1171        if flags & FS_UPDATE_RESET != 0 {
1172            self.staging = Some(BTreeMap::new());
1173        }
1174        let map = self.staging.as_mut().unwrap_or(&mut self.live);
1175        for record in fs_records(&records) {
1176            match record {
1177                FsRecord::Upsert {
1178                    path,
1179                    entry_flags,
1180                    size,
1181                    mtime_ns,
1182                    mode,
1183                    hash,
1184                    content,
1185                } => {
1186                    let content = match content {
1187                        FsContent::None => {
1188                            let entry_type = entry_flags & FS_ENTRY_TYPE_MASK;
1189                            let content_bearing =
1190                                entry_type == FS_ENTRY_FILE || entry_type == FS_ENTRY_SYMLINK;
1191                            if !content_bearing
1192                                || entry_flags
1193                                    & (FS_ENTRY_NO_CONTENT
1194                                        | FS_ENTRY_UNREADABLE
1195                                        | FS_ENTRY_UNSTABLE)
1196                                    != 0
1197                            {
1198                                None
1199                            } else {
1200                                // Metadata-only upsert keeps previous content only
1201                                // when the entry stays the same content-bearing
1202                                // type. The node is replaced either way, so move
1203                                // the bytes out instead of cloning them.
1204                                map.remove(path)
1205                                    .filter(|n| n.entry_flags & FS_ENTRY_TYPE_MASK == entry_type)
1206                                    .and_then(|n| n.content)
1207                            }
1208                        }
1209                        FsContent::Full(data) => Some(data.to_vec()),
1210                        FsContent::Delta(ops) => {
1211                            let base = map
1212                                .get(path)
1213                                .and_then(|n| n.content.as_deref())
1214                                .unwrap_or(&[]);
1215                            Some(apply_fs_delta(base, ops)?)
1216                        }
1217                    };
1218                    map.insert(
1219                        path.to_string(),
1220                        FsNode {
1221                            entry_flags,
1222                            size,
1223                            mtime_ns,
1224                            mode,
1225                            hash,
1226                            content,
1227                        },
1228                    );
1229                }
1230                FsRecord::Delete { path } => {
1231                    remove_subtree(map, path);
1232                }
1233                FsRecord::Move { from, to } => {
1234                    let moved = take_subtree(map, from);
1235                    for (suffix, node) in moved {
1236                        let new_path = join_moved(to, &suffix);
1237                        map.insert(new_path, node);
1238                    }
1239                }
1240            }
1241        }
1242        if flags & FS_UPDATE_SYNC != 0
1243            && let Some(staged) = self.staging.take()
1244        {
1245            self.live = staged;
1246        }
1247        Some(update_id)
1248    }
1249}
1250
1251/// Keys at or under `root` in a sorted map: the entry itself plus the
1252/// contiguous `root/`-prefixed range — O(log n + subtree), never a scan of
1253/// the whole map.
1254fn subtree_keys(map: &BTreeMap<String, FsNode>, root: &str) -> Vec<String> {
1255    if root.is_empty() {
1256        return map.keys().cloned().collect();
1257    }
1258    let mut keys: Vec<String> = Vec::new();
1259    if map.contains_key(root) {
1260        keys.push(root.to_string());
1261    }
1262    let prefix = format!("{root}/");
1263    keys.extend(
1264        map.range(prefix.clone()..)
1265            .take_while(|(k, _)| k.starts_with(&prefix))
1266            .map(|(k, _)| k.clone()),
1267    );
1268    keys
1269}
1270
1271fn remove_subtree(map: &mut BTreeMap<String, FsNode>, root: &str) {
1272    for key in subtree_keys(map, root) {
1273        map.remove(&key);
1274    }
1275}
1276
1277/// Remove and return `(suffix, node)` pairs for `root` and everything under
1278/// it. The suffix is "" for the root itself.
1279fn take_subtree(map: &mut BTreeMap<String, FsNode>, root: &str) -> Vec<(String, FsNode)> {
1280    subtree_keys(map, root)
1281        .into_iter()
1282        .map(|key| {
1283            let node = map.remove(&key).unwrap();
1284            let suffix = if key.len() > root.len() {
1285                key[root.len() + if root.is_empty() { 0 } else { 1 }..].to_string()
1286            } else {
1287                String::new()
1288            };
1289            (suffix, node)
1290        })
1291        .collect()
1292}
1293
1294fn join_moved(to: &str, suffix: &str) -> String {
1295    if suffix.is_empty() {
1296        to.to_string()
1297    } else if to.is_empty() {
1298        suffix.to_string()
1299    } else {
1300        format!("{to}/{suffix}")
1301    }
1302}
1303
1304/// Apply a content delta (LEB128 COPY/INSERT instruction stream) to a base.
1305pub fn apply_fs_delta(base: &[u8], mut ops: &[u8]) -> Option<Vec<u8>> {
1306    fn leb128(data: &mut &[u8]) -> Option<u64> {
1307        let mut value = 0u64;
1308        let mut shift = 0u32;
1309        loop {
1310            let (&byte, rest) = data.split_first()?;
1311            *data = rest;
1312            if shift >= 64 {
1313                return None;
1314            }
1315            value |= u64::from(byte & 0x7F) << shift;
1316            if byte & 0x80 == 0 {
1317                return Some(value);
1318            }
1319            shift += 7;
1320        }
1321    }
1322    let mut out = Vec::new();
1323    while let Some((&op, rest)) = ops.split_first() {
1324        ops = rest;
1325        match op {
1326            0x01 => {
1327                let offset = leb128(&mut ops)? as usize;
1328                let len = leb128(&mut ops)? as usize;
1329                if out.len().checked_add(len)? > FS_MAX_DECOMPRESSED {
1330                    return None;
1331                }
1332                out.extend_from_slice(base.get(offset..offset.checked_add(len)?)?);
1333            }
1334            0x02 => {
1335                let len = leb128(&mut ops)? as usize;
1336                if ops.len() < len {
1337                    return None;
1338                }
1339                if out.len().checked_add(len)? > FS_MAX_DECOMPRESSED {
1340                    return None;
1341                }
1342                out.extend_from_slice(&ops[..len]);
1343                ops = &ops[len..];
1344            }
1345            _ => return None,
1346        }
1347    }
1348    Some(out)
1349}
1350
1351// ── FS_GREP (docs/design/fs-grep.md) ───────────────────────────────────────
1352
1353/// One record of an `FS_GREP` response.
1354#[derive(Debug, Clone, PartialEq, Eq)]
1355pub enum FsGrepRecord {
1356    /// FILE 0x01: `[kind:1][flags:1][n:2][path_len:2][path:N]` — the next `n`
1357    /// `Match` records belong to this file. `flags` is `FS_GREP_FILE_IGNORED`.
1358    File {
1359        flags: u8,
1360        n: u16,
1361        /// Root-relative, lossy UTF-8 of the on-disk name.
1362        path: String,
1363    },
1364    /// MATCH 0x02: `[kind:1][line:4][col:4][end_line:4][end_col:4][text_len:4][text:N]`.
1365    /// 0-based lines, UTF-8 byte columns — the same shape as an LSP range.
1366    /// `end_line` differs from `line` when the pattern matched across a
1367    /// newline; `text` then carries every line the match spans, joined by
1368    /// `\n`, so a client can show the whole thing rather than a fragment.
1369    Match {
1370        line: u32,
1371        col: u32,
1372        end_line: u32,
1373        end_col: u32,
1374        /// The matched line(s) without a trailing terminator, capped.
1375        text: String,
1376    },
1377}
1378
1379/// Build a `C2S_FS_GREP`.
1380pub fn msg_fs_grep(
1381    nonce: u16,
1382    flags: u8,
1383    max_matches: u16,
1384    max_per_file: u16,
1385    root: &str,
1386    query: &str,
1387) -> Vec<u8> {
1388    let rb = root.as_bytes();
1389    let qb = query.as_bytes();
1390    let mut m = Vec::with_capacity(12 + rb.len() + qb.len());
1391    m.push(C2S_FS_GREP);
1392    m.extend_from_slice(&nonce.to_le_bytes());
1393    m.push(flags);
1394    m.extend_from_slice(&max_matches.to_le_bytes());
1395    m.extend_from_slice(&max_per_file.to_le_bytes());
1396    m.extend_from_slice(&(rb.len() as u16).to_le_bytes());
1397    m.extend_from_slice(rb);
1398    m.extend_from_slice(&(qb.len() as u16).to_le_bytes());
1399    m.extend_from_slice(qb);
1400    m
1401}
1402
1403/// Parse a `C2S_FS_GREP` → `(nonce, flags, max_matches, max_per_file, root, query)`.
1404pub fn parse_fs_grep(data: &[u8]) -> Option<(u16, u8, u16, u16, String, String)> {
1405    if data.first().copied() != Some(C2S_FS_GREP) || data.len() < 12 {
1406        return None;
1407    }
1408    let nonce = u16::from_le_bytes([data[1], data[2]]);
1409    let flags = data[3];
1410    let max_matches = u16::from_le_bytes([data[4], data[5]]);
1411    let max_per_file = u16::from_le_bytes([data[6], data[7]]);
1412    let rl = u16::from_le_bytes([data[8], data[9]]) as usize;
1413    let ro = 10;
1414    if data.len() < ro + rl + 2 {
1415        return None;
1416    }
1417    let root = String::from_utf8_lossy(&data[ro..ro + rl]).into_owned();
1418    let qo = ro + rl;
1419    let ql = u16::from_le_bytes([data[qo], data[qo + 1]]) as usize;
1420    let qs = qo + 2;
1421    if data.len() < qs + ql {
1422        return None;
1423    }
1424    let query = String::from_utf8_lossy(&data[qs..qs + ql]).into_owned();
1425    Some((nonce, flags, max_matches, max_per_file, root, query))
1426}
1427
1428/// Append one record to an uncompressed `FS_GREP` records buffer.
1429pub fn append_fs_grep_record(buf: &mut Vec<u8>, record: &FsGrepRecord) {
1430    let start = buf.len();
1431    buf.extend_from_slice(&0u32.to_le_bytes()); // record_len placeholder
1432    match record {
1433        FsGrepRecord::File { flags, n, path } => {
1434            buf.push(FS_GREP_RECORD_FILE);
1435            buf.push(*flags);
1436            buf.extend_from_slice(&n.to_le_bytes());
1437            let pb = path.as_bytes();
1438            buf.extend_from_slice(&(pb.len() as u16).to_le_bytes());
1439            buf.extend_from_slice(pb);
1440        }
1441        FsGrepRecord::Match {
1442            line,
1443            col,
1444            end_line,
1445            end_col,
1446            text,
1447        } => {
1448            buf.push(FS_GREP_RECORD_MATCH);
1449            buf.extend_from_slice(&line.to_le_bytes());
1450            buf.extend_from_slice(&col.to_le_bytes());
1451            buf.extend_from_slice(&end_line.to_le_bytes());
1452            buf.extend_from_slice(&end_col.to_le_bytes());
1453            let tb = text.as_bytes();
1454            buf.extend_from_slice(&(tb.len() as u32).to_le_bytes());
1455            buf.extend_from_slice(tb);
1456        }
1457    }
1458    let len = (buf.len() - start - 4) as u32;
1459    buf[start..start + 4].copy_from_slice(&len.to_le_bytes());
1460}
1461
1462/// Decode an uncompressed `FS_GREP` records buffer. Unknown kinds are skipped
1463/// via `record_len`; a record whose body overruns ends the stream, matching
1464/// the TypeScript mirror.
1465pub fn fs_grep_records(data: &[u8]) -> Vec<FsGrepRecord> {
1466    let mut out = Vec::new();
1467    let mut off = 0usize;
1468    while off + 4 <= data.len() {
1469        let len = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize;
1470        if len == 0 || off + 4 + len > data.len() {
1471            return out;
1472        }
1473        let body = &data[off + 4..off + 4 + len];
1474        off += 4 + len;
1475        match body[0] {
1476            FS_GREP_RECORD_FILE => {
1477                if body.len() < 6 {
1478                    return out;
1479                }
1480                let flags = body[1];
1481                let n = u16::from_le_bytes([body[2], body[3]]);
1482                let pl = u16::from_le_bytes([body[4], body[5]]) as usize;
1483                if body.len() < 6 + pl {
1484                    return out;
1485                }
1486                out.push(FsGrepRecord::File {
1487                    flags,
1488                    n,
1489                    path: String::from_utf8_lossy(&body[6..6 + pl]).into_owned(),
1490                });
1491            }
1492            FS_GREP_RECORD_MATCH => {
1493                if body.len() < 21 {
1494                    return out;
1495                }
1496                let line = u32::from_le_bytes(body[1..5].try_into().unwrap());
1497                let col = u32::from_le_bytes(body[5..9].try_into().unwrap());
1498                let end_line = u32::from_le_bytes(body[9..13].try_into().unwrap());
1499                let end_col = u32::from_le_bytes(body[13..17].try_into().unwrap());
1500                let tl = u32::from_le_bytes(body[17..21].try_into().unwrap()) as usize;
1501                if body.len() < 21 + tl {
1502                    return out;
1503                }
1504                out.push(FsGrepRecord::Match {
1505                    line,
1506                    col,
1507                    end_line,
1508                    end_col,
1509                    text: String::from_utf8_lossy(&body[21..21 + tl]).into_owned(),
1510                });
1511            }
1512            // Unknown kind: skipped via record_len, as the family requires.
1513            _ => {}
1514        }
1515    }
1516    out
1517}
1518
1519/// Build an `S2C_FS_GREP` from an uncompressed records buffer.
1520pub fn msg_fs_grep_result(
1521    nonce: u16,
1522    status: u8,
1523    flags: u8,
1524    detail: &str,
1525    records: &[u8],
1526) -> Vec<u8> {
1527    let db = detail.as_bytes();
1528    let compressed = lz4_flex::compress_prepend_size(records);
1529    let mut m = Vec::with_capacity(7 + db.len() + compressed.len());
1530    m.push(S2C_FS_GREP);
1531    m.extend_from_slice(&nonce.to_le_bytes());
1532    m.push(status);
1533    m.push(flags);
1534    m.extend_from_slice(&(db.len() as u16).to_le_bytes());
1535    m.extend_from_slice(db);
1536    m.extend_from_slice(&compressed);
1537    m
1538}
1539
1540/// Parse an `S2C_FS_GREP` → `(nonce, status, flags, detail, records)` with the
1541/// records decompressed under the standard guard.
1542pub fn parse_fs_grep_result(data: &[u8]) -> Option<(u16, u8, u8, String, Vec<u8>)> {
1543    if data.first().copied() != Some(S2C_FS_GREP) || data.len() < 7 {
1544        return None;
1545    }
1546    let nonce = u16::from_le_bytes([data[1], data[2]]);
1547    let status = data[3];
1548    let flags = data[4];
1549    let dl = u16::from_le_bytes([data[5], data[6]]) as usize;
1550    let ds = 7;
1551    if data.len() < ds + dl {
1552        return None;
1553    }
1554    let detail = String::from_utf8_lossy(&data[ds..ds + dl]).into_owned();
1555    let records = decompress_guarded(&data[ds + dl..])?;
1556    Some((nonce, status, flags, detail, records))
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561    #[test]
1562    fn fs_grep_request_roundtrip() {
1563        // Pinned bytes; the TypeScript mirror asserts the same hex.
1564        let m = msg_fs_grep(
1565            0x0102,
1566            FS_GREP_CASE_SENSITIVE | FS_GREP_REGEX,
1567            500,
1568            50,
1569            "/tmp/root",
1570            "fn \\w+",
1571        );
1572        assert_eq!(
1573            m.iter().map(|b| format!("{b:02x}")).collect::<String>(),
1574            // Cross-pinned with js/core/src/__tests__/fs.test.ts (the request
1575            // is uncompressed, so both sides can pin exact bytes).
1576            "48020103f401320009002f746d702f726f6f740600666e205c772b"
1577        );
1578        assert_eq!(
1579            parse_fs_grep(&m),
1580            Some((
1581                0x0102,
1582                FS_GREP_CASE_SENSITIVE | FS_GREP_REGEX,
1583                500,
1584                50,
1585                "/tmp/root".to_string(),
1586                "fn \\w+".to_string()
1587            ))
1588        );
1589        // Truncated frames are malformed, never partially accepted.
1590        for cut in 0..m.len() {
1591            assert_eq!(parse_fs_grep(&m[..cut]), None, "cut at {cut}");
1592        }
1593    }
1594
1595    #[test]
1596    fn fs_grep_result_roundtrip() {
1597        let recs = vec![
1598            FsGrepRecord::File {
1599                flags: 0,
1600                n: 2,
1601                path: "src/main.rs".to_string(),
1602            },
1603            FsGrepRecord::Match {
1604                line: 41,
1605                col: 4,
1606                end_line: 41,
1607                end_col: 6,
1608                text: "    fn main() {".to_string(),
1609            },
1610            FsGrepRecord::Match {
1611                line: 99,
1612                col: 0,
1613                end_line: 99,
1614                end_col: 2,
1615                text: "fn helper()".to_string(),
1616            },
1617            // An ignored file sorts last and carries the flag.
1618            FsGrepRecord::File {
1619                flags: FS_GREP_FILE_IGNORED,
1620                n: 1,
1621                path: "target/debug/build.rs".to_string(),
1622            },
1623            FsGrepRecord::Match {
1624                line: 0,
1625                col: 0,
1626                end_line: 0,
1627                end_col: 2,
1628                text: String::new(),
1629            },
1630        ];
1631        let mut buf = Vec::new();
1632        for r in &recs {
1633            append_fs_grep_record(&mut buf, r);
1634        }
1635        assert_eq!(fs_grep_records(&buf), recs);
1636
1637        let msg = msg_fs_grep_result(7, FS_DONE_OK, FS_GREP_TRUNCATED, "", &buf);
1638        let (nonce, status, flags, detail, records) = parse_fs_grep_result(&msg).unwrap();
1639        assert_eq!(
1640            (nonce, status, flags, detail.as_str()),
1641            (7, FS_DONE_OK, FS_GREP_TRUNCATED, "")
1642        );
1643        assert_eq!(fs_grep_records(&records), recs);
1644
1645        // `detail` carries the regex error on INVALID, with no records.
1646        let bad = msg_fs_grep_result(8, FS_DONE_INVALID, 0, "unclosed character class", &[]);
1647        let (_, st, _, d, r) = parse_fs_grep_result(&bad).unwrap();
1648        assert_eq!(st, FS_DONE_INVALID);
1649        assert_eq!(d, "unclosed character class");
1650        assert!(fs_grep_records(&r).is_empty());
1651    }
1652
1653    #[test]
1654    fn fs_grep_records_skip_unknown_kinds() {
1655        let mut buf = Vec::new();
1656        // An unknown kind between two known records is stepped over via
1657        // record_len rather than ending the stream.
1658        append_fs_grep_record(
1659            &mut buf,
1660            &FsGrepRecord::File {
1661                flags: 0,
1662                n: 0,
1663                path: "a".to_string(),
1664            },
1665        );
1666        let start = buf.len();
1667        buf.extend_from_slice(&0u32.to_le_bytes());
1668        buf.push(0x7f); // unknown kind
1669        buf.extend_from_slice(b"whatever");
1670        let len = (buf.len() - start - 4) as u32;
1671        buf[start..start + 4].copy_from_slice(&len.to_le_bytes());
1672        append_fs_grep_record(
1673            &mut buf,
1674            &FsGrepRecord::File {
1675                flags: 0,
1676                n: 0,
1677                path: "b".to_string(),
1678            },
1679        );
1680        let got = fs_grep_records(&buf);
1681        assert_eq!(got.len(), 2, "unknown kind must not end the stream");
1682
1683        // A record whose body overruns its own length ends the stream.
1684        let mut trunc = buf.clone();
1685        trunc.truncate(trunc.len() - 1);
1686        assert!(fs_grep_records(&trunc).len() <= 2);
1687    }
1688
1689    use super::*;
1690
1691    #[test]
1692    fn fs_search_request_roundtrip() {
1693        let m = msg_fs_search(9, 50, "/a/b:c", "eng.rs");
1694        let (nonce, limit, root, query) = parse_fs_search(&m).unwrap();
1695        assert_eq!(nonce, 9);
1696        assert_eq!(limit, 50);
1697        assert_eq!(root, "/a/b:c");
1698        assert_eq!(query, "eng.rs");
1699    }
1700
1701    #[test]
1702    fn fs_search_result_roundtrip() {
1703        let paths = vec!["src/main.rs".to_string(), "a/b:c/engine.rs".to_string()];
1704        let m = msg_fs_search_result(3, FS_STATUS_OK, &paths);
1705        let (nonce, status, out) = parse_fs_search_result(&m).unwrap();
1706        assert_eq!(nonce, 3);
1707        assert_eq!(status, FS_STATUS_OK);
1708        assert_eq!(out, paths);
1709    }
1710
1711    #[test]
1712    fn fs_index_request_roundtrip() {
1713        let m = msg_fs_index(0x0102, "/tmp/watch me");
1714        // Cross-pinned with js/core/src/__tests__/fs.test.ts (uncompressed,
1715        // so both sides can pin exact bytes).
1716        assert_eq!(
1717            m.iter().map(|x| format!("{x:02x}")).collect::<String>(),
1718            "470201000d002f746d702f7761746368206d65"
1719        );
1720        let (nonce, flags, root) = parse_fs_index(&m).unwrap();
1721        assert_eq!(nonce, 0x0102);
1722        assert_eq!(flags, 0);
1723        assert_eq!(root, "/tmp/watch me");
1724        assert_eq!(parse_fs_index(&m[..5]), None);
1725        assert_eq!(parse_fs_index(&msg_fs_stop(1)), None);
1726    }
1727
1728    #[test]
1729    fn fs_index_result_roundtrip() {
1730        let paths = vec![
1731            "a/b:c/engine.rs".to_string(),
1732            "src/main.rs".to_string(),
1733            String::new(),
1734        ];
1735        let m = msg_fs_index_result(3, FS_DONE_OK, FS_INDEX_TRUNCATED, &paths);
1736        let (nonce, status, flags, out) = parse_fs_index_result(&m).unwrap();
1737        assert_eq!(nonce, 3);
1738        assert_eq!(status, FS_DONE_OK);
1739        assert_eq!(flags, FS_INDEX_TRUNCATED);
1740        assert_eq!(out, paths);
1741
1742        // Empty list (the error-status shape) still carries a valid LZ4 blob.
1743        let empty = msg_fs_index_result(4, FS_DONE_NOT_FOUND, 0, &[]);
1744        let (_, status, _, out) = parse_fs_index_result(&empty).unwrap();
1745        assert_eq!(status, FS_DONE_NOT_FOUND);
1746        assert!(out.is_empty());
1747
1748        // A count that disagrees with the payload is malformed.
1749        let mut lying = msg_fs_index_result(5, FS_DONE_OK, 0, &paths);
1750        lying[5..9].copy_from_slice(&2u32.to_le_bytes());
1751        assert_eq!(parse_fs_index_result(&lying), None);
1752
1753        // A count over the protocol cap is rejected before decompression —
1754        // a hostile 33M-record claim must not reach the preallocation.
1755        let mut huge = msg_fs_index_result(6, FS_DONE_OK, 0, &[]);
1756        huge[5..9].copy_from_slice(&((FS_INDEX_MAX_COUNT as u32) + 1).to_le_bytes());
1757        assert_eq!(parse_fs_index_result(&huge), None);
1758    }
1759
1760    #[test]
1761    fn single_and_recursive_are_mutually_exclusive() {
1762        assert!(fs_sync_flags_valid(FS_SYNC_SINGLE));
1763        assert!(fs_sync_flags_valid(FS_SYNC_SINGLE | FS_SYNC_CONTENT));
1764        assert!(fs_sync_flags_valid(FS_SYNC_RECURSIVE | FS_SYNC_CONTENT));
1765        assert!(!fs_sync_flags_valid(FS_SYNC_SINGLE | FS_SYNC_RECURSIVE));
1766        assert!(!fs_sync_flags_valid(
1767            FS_SYNC_SINGLE | FS_SYNC_RECURSIVE | FS_SYNC_CONTENT
1768        ));
1769    }
1770
1771    #[test]
1772    fn fs_sync_from_pty_roundtrip_and_rebase() {
1773        let m = msg_fs_sync_from_pty(7, FS_SYNC_RECURSIVE, 0, 0, "sub", 42);
1774        assert_eq!(
1775            m,
1776            vec![
1777                0x40, 0x07, 0x00, 0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x73,
1778                0x75, 0x62, 0x2a, 0x00
1779            ]
1780        );
1781        assert_eq!(fs_sync_src_pty(&m), Some(42));
1782        assert_eq!(
1783            fs_sync_src_pty(&msg_fs_sync(7, FS_SYNC_RECURSIVE, 0, 0, "sub")),
1784            None
1785        );
1786        // Rebase joins cwd + path and clears FROM_PTY.
1787        let reb = fs_sync_rebase(&m, Some("/home/u")).unwrap();
1788        assert_eq!(fs_sync_flags(&reb).unwrap() & FS_SYNC_FROM_PTY, 0);
1789        let plen = u16::from_le_bytes([reb[11], reb[12]]) as usize;
1790        assert_eq!(
1791            std::str::from_utf8(&reb[13..13 + plen]).unwrap(),
1792            "/home/u/sub"
1793        );
1794        // No cwd (source pty gone) keeps path verbatim.
1795        let reb0 = fs_sync_rebase(&m, None).unwrap();
1796        let plen0 = u16::from_le_bytes([reb0[11], reb0[12]]) as usize;
1797        assert_eq!(std::str::from_utf8(&reb0[13..13 + plen0]).unwrap(), "sub");
1798    }
1799
1800    /// The two optional trailers coexist in one message, and each is
1801    /// reachable past the other — the property that makes the field order
1802    /// (`EXCLUDE` then `FROM_PTY`) load-bearing.
1803    #[test]
1804    fn fs_sync_exclude_field_roundtrips_alongside_from_pty() {
1805        let plain = msg_fs_sync(1, FS_SYNC_RECURSIVE, 0, 0, "sub");
1806        assert_eq!(fs_sync_flags(&plain).unwrap() & FS_SYNC_EXCLUDE, 0);
1807        assert_eq!(fs_sync_exclude(&plain), Some(""));
1808
1809        // Empty patterns build the plain form: no field, no flag.
1810        assert_eq!(
1811            msg_fs_sync_excluding(1, FS_SYNC_RECURSIVE, 0, 0, "sub", ""),
1812            plain
1813        );
1814
1815        let ex = msg_fs_sync_excluding(1, FS_SYNC_RECURSIVE, 0, 0, "sub", "target\n!keep");
1816        assert_ne!(fs_sync_flags(&ex).unwrap() & FS_SYNC_EXCLUDE, 0);
1817        assert_eq!(fs_sync_exclude(&ex), Some("target\n!keep"));
1818        assert_eq!(fs_sync_src_pty(&ex), None);
1819
1820        let both = msg_fs_sync_full(1, FS_SYNC_RECURSIVE, 0, 0, "sub", "target", Some(42));
1821        assert_eq!(fs_sync_exclude(&both), Some("target"));
1822        assert_eq!(fs_sync_src_pty(&both), Some(42), "reached past the field");
1823        let reb = fs_sync_rebase(&both, Some("/home/u")).unwrap();
1824        assert_eq!(fs_sync_flags(&reb).unwrap() & FS_SYNC_FROM_PTY, 0);
1825        assert_ne!(fs_sync_flags(&reb).unwrap() & FS_SYNC_EXCLUDE, 0);
1826        assert_eq!(fs_sync_exclude(&reb), Some("target"), "filter survives");
1827        let plen = u16::from_le_bytes([reb[11], reb[12]]) as usize;
1828        assert_eq!(
1829            std::str::from_utf8(&reb[13..13 + plen]).unwrap(),
1830            "/home/u/sub"
1831        );
1832
1833        // A truncated field is malformed, not an empty pattern list.
1834        assert_eq!(fs_sync_exclude(&ex[..ex.len() - 1]), None);
1835        let mut headerless = ex.clone();
1836        headerless.truncate(17);
1837        assert_eq!(fs_sync_exclude(&headerless), None);
1838    }
1839
1840    /// Exclusion narrows enumeration, and a `SINGLE` sync enumerates
1841    /// nothing: the combination is a client misunderstanding, refused
1842    /// rather than silently ignored.
1843    #[test]
1844    fn exclusion_flags_are_rejected_with_single() {
1845        for flag in [FS_SYNC_EXCLUDE_GIT, FS_SYNC_GITIGNORE, FS_SYNC_EXCLUDE] {
1846            assert!(fs_sync_flags_valid(flag));
1847            assert!(fs_sync_flags_valid(flag | FS_SYNC_RECURSIVE));
1848            assert!(!fs_sync_flags_valid(flag | FS_SYNC_SINGLE));
1849        }
1850    }
1851
1852    fn upsert(path: &str, content: &[u8]) -> Vec<u8> {
1853        let mut buf = Vec::new();
1854        append_fs_record(
1855            &mut buf,
1856            &FsRecord::Upsert {
1857                path,
1858                entry_flags: FS_ENTRY_FILE,
1859                size: content.len() as u64,
1860                mtime_ns: 42,
1861                mode: 0o644,
1862                hash: 7,
1863                content: FsContent::Full(content),
1864            },
1865        );
1866        buf
1867    }
1868
1869    #[test]
1870    fn record_roundtrip() {
1871        let mut buf = Vec::new();
1872        append_fs_record(
1873            &mut buf,
1874            &FsRecord::Upsert {
1875                path: "a/b.txt",
1876                entry_flags: FS_ENTRY_FILE | FS_ENTRY_NO_CONTENT,
1877                size: 10,
1878                mtime_ns: 1_700_000_000_000_000_000,
1879                mode: 0o755,
1880                hash: 0xDEAD_BEEF_DEAD_BEEF_DEAD_BEEF,
1881                content: FsContent::None,
1882            },
1883        );
1884        append_fs_record(&mut buf, &FsRecord::Delete { path: "old" });
1885        append_fs_record(
1886            &mut buf,
1887            &FsRecord::Move {
1888                from: "src",
1889                to: "dst",
1890            },
1891        );
1892        let records: Vec<_> = fs_records(&buf).collect();
1893        assert_eq!(records.len(), 3);
1894        match &records[0] {
1895            FsRecord::Upsert {
1896                path,
1897                entry_flags,
1898                size,
1899                mtime_ns,
1900                mode,
1901                hash,
1902                content,
1903            } => {
1904                assert_eq!(*path, "a/b.txt");
1905                assert_eq!(*entry_flags, FS_ENTRY_FILE | FS_ENTRY_NO_CONTENT);
1906                assert_eq!(*size, 10);
1907                assert_eq!(*mtime_ns, 1_700_000_000_000_000_000);
1908                assert_eq!(*mode, 0o755);
1909                assert_eq!(*hash, 0xDEAD_BEEF_DEAD_BEEF_DEAD_BEEF);
1910                assert_eq!(*content, FsContent::None);
1911            }
1912            other => panic!("unexpected {other:?}"),
1913        }
1914        assert_eq!(records[1], FsRecord::Delete { path: "old" });
1915        assert_eq!(
1916            records[2],
1917            FsRecord::Move {
1918                from: "src",
1919                to: "dst"
1920            }
1921        );
1922    }
1923
1924    /// Byte fixtures shared with the TypeScript codecs
1925    /// (`js/core/src/__tests__/fs.test.ts` pins the same hex), so codec
1926    /// drift fails on one side or the other. The compressed `FS_UPDATE`
1927    /// variant is pinned only in TS — LZ4 output may legitimately change
1928    /// across `lz4_flex` versions, while these buffers never can.
1929    #[test]
1930    fn wire_fixtures() {
1931        fn hex(b: &[u8]) -> String {
1932            b.iter().map(|x| format!("{x:02x}")).collect()
1933        }
1934
1935        assert_eq!(
1936            hex(&msg_fs_sync(
1937                0x0102,
1938                FS_SYNC_RECURSIVE | FS_SYNC_CONTENT,
1939                25,
1940                65536,
1941                "/tmp/watch me"
1942            )),
1943            "40020103001900000001000d002f746d702f7761746368206d65"
1944        );
1945        // Both optional trailers, in field order: EXCLUDE then FROM_PTY.
1946        assert_eq!(
1947            hex(&msg_fs_sync_full(
1948                7,
1949                FS_SYNC_RECURSIVE,
1950                0,
1951                0,
1952                "sub",
1953                "target",
1954                Some(42)
1955            )),
1956            "4007009100000000000000030073756206007461726765742a00"
1957        );
1958        assert_eq!(hex(&msg_fs_stop(0x0102)), "410201");
1959        assert_eq!(hex(&msg_fs_ack(0x0102, 0x01020304)), "42020104030201");
1960        assert_eq!(
1961            hex(&msg_fs_fetch(3, 0x0102, "sub/%FF.bin")),
1962            "43030002010b007375622f2546462e62696e"
1963        );
1964        assert_eq!(
1965            hex(&msg_fs_synced(0x0102, 3, 0, "/w")),
1966            "40020103000002002f77"
1967        );
1968
1969        let mut records = Vec::new();
1970        append_fs_record(
1971            &mut records,
1972            &FsRecord::Upsert {
1973                path: "a.txt",
1974                entry_flags: FS_ENTRY_FILE,
1975                size: 5,
1976                mtime_ns: 1_700_000_000_123_456_789,
1977                mode: 0o100644,
1978                hash: 0x0123_4567_89ab_cdef_1122_3344_5566_7788,
1979                content: FsContent::Full(b"hello"),
1980            },
1981        );
1982        append_fs_record(
1983            &mut records,
1984            &FsRecord::Upsert {
1985                path: "sub",
1986                entry_flags: FS_ENTRY_DIR,
1987                size: 0,
1988                mtime_ns: 0,
1989                mode: 0o40755,
1990                hash: 0,
1991                content: FsContent::None,
1992            },
1993        );
1994        append_fs_record(
1995            &mut records,
1996            &FsRecord::Upsert {
1997                path: "sub/%FF.bin", // server-escaped non-UTF-8 name
1998                entry_flags: FS_ENTRY_FILE | FS_ENTRY_NO_CONTENT,
1999                size: 1 << 20,
2000                mtime_ns: 1,
2001                mode: 0o100600,
2002                hash: 0xff,
2003                content: FsContent::None,
2004            },
2005        );
2006        append_fs_record(&mut records, &FsRecord::Delete { path: "old" });
2007        append_fs_record(
2008            &mut records,
2009            &FsRecord::Move {
2010                from: "src",
2011                to: "dst",
2012            },
2013        );
2014        assert_eq!(
2015            hex(&records),
2016            "3700000001000500612e747874050000000000000015cd853dfe9c9717a48100008877665544332211efcdab8967452301010500000068656c6c6f2c0000000101030073756200000000000000000000000000000000ed41000000000000000000000000000000000000003400000001080b007375622f2546462e62696e0000100000000000010000000000000080810000ff00000000000000000000000000000000060000000203006f6c640b0000000303007372630300647374"
2017        );
2018
2019        // Decode direction: the pinned bytes parse back to the same records.
2020        let decoded: Vec<_> = fs_records(&records).collect();
2021        assert_eq!(decoded.len(), 5);
2022        assert!(matches!(
2023            &decoded[0],
2024            FsRecord::Upsert {
2025                path: "a.txt",
2026                size: 5,
2027                mtime_ns: 1_700_000_000_123_456_789,
2028                hash: 0x0123_4567_89ab_cdef_1122_3344_5566_7788,
2029                content: FsContent::Full(b"hello"),
2030                ..
2031            }
2032        ));
2033        assert_eq!(decoded[3], FsRecord::Delete { path: "old" });
2034        assert_eq!(
2035            decoded[4],
2036            FsRecord::Move {
2037                from: "src",
2038                to: "dst"
2039            }
2040        );
2041    }
2042
2043    #[test]
2044    fn oversized_declared_length_is_rejected_before_allocation() {
2045        // A hand-forged FS_UPDATE whose LZ4 size prefix declares 1 GiB.
2046        let mut msg = vec![S2C_FS_UPDATE];
2047        msg.extend_from_slice(&1u16.to_le_bytes()); // sync_id
2048        msg.extend_from_slice(&1u32.to_le_bytes()); // update_id
2049        msg.push(0); // flags
2050        msg.extend_from_slice(&(1u32 << 30).to_le_bytes()); // declared size
2051        msg.extend_from_slice(&[0u8; 16]); // bogus compressed bytes
2052        let mut mirror = FsMirror::new();
2053        assert_eq!(mirror.apply_update(&msg), None);
2054
2055        let mut file = vec![S2C_FS_FILE];
2056        file.extend_from_slice(&7u16.to_le_bytes()); // nonce
2057        file.push(FS_FILE_OK);
2058        file.extend_from_slice(&(1u32 << 30).to_le_bytes());
2059        file.extend_from_slice(&[0u8; 16]);
2060        assert_eq!(parse_fs_file(&file), None);
2061    }
2062
2063    #[test]
2064    fn fs_file_roundtrip() {
2065        let msg = msg_fs_file(9, FS_FILE_OK, b"contents");
2066        assert_eq!(
2067            parse_fs_file(&msg),
2068            Some((9, FS_FILE_OK, b"contents".to_vec()))
2069        );
2070    }
2071
2072    #[test]
2073    fn fs_write_roundtrip() {
2074        let w = FsWrite {
2075            nonce: 7,
2076            sync_id: 3,
2077            flags: FS_WRITE_MKPARENTS | FS_WRITE_DURABLE,
2078            base: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
2079            mode: 0o644,
2080            content_kind: FS_WRITE_CONTENT_FULL,
2081            path: "dir/50%25.txt".to_string(),
2082            content: b"hello world".to_vec(),
2083        };
2084        assert_eq!(parse_fs_write(&msg_fs_write(&w)), Some(w));
2085        // Empty content (create-empty) and zero base (create-exclusive).
2086        let w0 = FsWrite {
2087            nonce: 1,
2088            sync_id: 1,
2089            flags: 0,
2090            base: 0,
2091            mode: 0,
2092            content_kind: FS_WRITE_CONTENT_FULL,
2093            path: "new.txt".to_string(),
2094            content: Vec::new(),
2095        };
2096        assert_eq!(parse_fs_write(&msg_fs_write(&w0)), Some(w0));
2097        // Truncated header and wrong opcode are rejected.
2098        assert_eq!(parse_fs_write(&[C2S_FS_WRITE, 0, 0]), None);
2099        assert_eq!(parse_fs_write(&msg_fs_file(1, 0, b"x")), None);
2100    }
2101
2102    #[test]
2103    fn fs_op_roundtrip() {
2104        let rename = FsOp {
2105            nonce: 42,
2106            sync_id: 9,
2107            op: FS_OP_RENAME,
2108            flags: FS_OP_MKPARENTS,
2109            base: 0,
2110            mode: 0,
2111            a: "old/name".to_string(),
2112            b: "new/name".to_string(),
2113        };
2114        assert_eq!(parse_fs_op(&msg_fs_op(&rename)), Some(rename));
2115        let mkdir = FsOp {
2116            nonce: 2,
2117            sync_id: 1,
2118            op: FS_OP_MKDIR,
2119            flags: 0,
2120            base: 0,
2121            mode: 0o700,
2122            a: "sub".to_string(),
2123            b: String::new(),
2124        };
2125        assert_eq!(parse_fs_op(&msg_fs_op(&mkdir)), Some(mkdir));
2126        assert_eq!(parse_fs_op(&[C2S_FS_OP, 0],), None);
2127    }
2128
2129    #[test]
2130    fn fs_write_family_byte_fixtures() {
2131        // Pinned bytes, cross-checked with js/core/src/__tests__/fs.test.ts.
2132        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
2133        let w = FsWrite {
2134            nonce: 0x0102,
2135            sync_id: 0x0304,
2136            flags: FS_WRITE_MKPARENTS,
2137            base: 0x0f0e_0d0c_0b0a_0908_0706_0504_0302_0100,
2138            mode: 0o644,
2139            content_kind: FS_WRITE_CONTENT_FULL,
2140            path: "a/b.txt".into(),
2141            content: b"hi".to_vec(),
2142        };
2143        assert_eq!(
2144            hex(&msg_fs_write(&w)),
2145            "440201040302000102030405060708090a0b0c0d0e0fa4010000010700612f622e74787402000000206869"
2146        );
2147        let o = FsOp {
2148            nonce: 0x0102,
2149            sync_id: 0x0304,
2150            op: FS_OP_RENAME,
2151            flags: FS_OP_MKPARENTS,
2152            base: 0,
2153            mode: 0,
2154            a: "x".into(),
2155            b: "y".into(),
2156        };
2157        assert_eq!(
2158            hex(&msg_fs_op(&o)),
2159            "450201040303020000000000000000000000000000000000000000010078010079"
2160        );
2161        // A symlink target is a verbatim string, never a wire path — "../t"
2162        // rides the `a` field unescaped and unvalidated.
2163        let ln = FsOp {
2164            nonce: 0x0102,
2165            sync_id: 0x0304,
2166            op: FS_OP_SYMLINK,
2167            flags: FS_OP_NO_CAS,
2168            base: 0,
2169            mode: 0,
2170            a: "../t".into(),
2171            b: "l".into(),
2172        };
2173        assert_eq!(
2174            hex(&msg_fs_op(&ln)),
2175            "45020104030401000000000000000000000000000000000000000004002e2e2f7401006c"
2176        );
2177        assert_eq!(
2178            hex(&msg_fs_done(
2179                0x0102,
2180                FS_DONE_CONFLICT,
2181                0x0f0e_0d0c_0b0a_0908_0706_0504_0302_0100,
2182                0x1122_3344_5566_7788
2183            )),
2184            "4402010b000102030405060708090a0b0c0d0e0f8877665544332211"
2185        );
2186    }
2187
2188    #[test]
2189    fn fs_done_roundtrip() {
2190        let hash = 0xdead_beef_dead_beef_dead_beef_dead_beefu128;
2191        let msg = msg_fs_done(5, FS_DONE_OK, hash, 1_700_000_000_000_000_000);
2192        assert_eq!(
2193            parse_fs_done(&msg),
2194            Some((5, FS_DONE_OK, hash, 1_700_000_000_000_000_000))
2195        );
2196        // CONFLICT carries the current disk hash.
2197        let c = msg_fs_done(6, FS_DONE_CONFLICT, hash, 0);
2198        assert_eq!(parse_fs_done(&c), Some((6, FS_DONE_CONFLICT, hash, 0)));
2199    }
2200
2201    #[test]
2202    fn unknown_record_kind_is_skipped() {
2203        let mut buf = Vec::new();
2204        // A future record kind 0x7F with 3 payload bytes.
2205        buf.extend_from_slice(&4u32.to_le_bytes());
2206        buf.push(0x7F);
2207        buf.extend_from_slice(&[1, 2, 3]);
2208        append_fs_record(&mut buf, &FsRecord::Delete { path: "x" });
2209        let records: Vec<_> = fs_records(&buf).collect();
2210        assert_eq!(records, vec![FsRecord::Delete { path: "x" }]);
2211    }
2212
2213    #[test]
2214    fn mirror_staged_snapshot_and_live() {
2215        let mut mirror = FsMirror::new();
2216        // Snapshot: RESET+SYNC with two files.
2217        let mut records = upsert("a.txt", b"alpha");
2218        records.extend_from_slice(&upsert("d/b.txt", b"beta"));
2219        let msg = msg_fs_update(1, 1, FS_UPDATE_RESET | FS_UPDATE_SYNC, &records);
2220        assert_eq!(mirror.apply_update(&msg), Some(1));
2221        assert_eq!(mirror.live.len(), 2);
2222        assert_eq!(mirror.live["a.txt"].content.as_deref(), Some(&b"alpha"[..]));
2223
2224        // Live delete + move.
2225        let mut records = Vec::new();
2226        append_fs_record(&mut records, &FsRecord::Delete { path: "a.txt" });
2227        append_fs_record(&mut records, &FsRecord::Move { from: "d", to: "e" });
2228        let msg = msg_fs_update(1, 2, 0, &records);
2229        assert_eq!(mirror.apply_update(&msg), Some(2));
2230        assert_eq!(mirror.live.len(), 1);
2231        assert_eq!(
2232            mirror.live["e/b.txt"].content.as_deref(),
2233            Some(&b"beta"[..])
2234        );
2235
2236        // Mid-stream RESET without SYNC leaves live untouched…
2237        let msg = msg_fs_update(1, 3, FS_UPDATE_RESET, &upsert("n.txt", b"new"));
2238        assert_eq!(mirror.apply_update(&msg), Some(3));
2239        assert_eq!(mirror.live.len(), 1);
2240        // …until SYNC swaps atomically.
2241        let msg = msg_fs_update(1, 4, FS_UPDATE_SYNC, &[]);
2242        assert_eq!(mirror.apply_update(&msg), Some(4));
2243        assert_eq!(mirror.live.len(), 1);
2244        assert!(mirror.live.contains_key("n.txt"));
2245    }
2246
2247    #[test]
2248    fn delta_content() {
2249        let mut mirror = FsMirror::new();
2250        let msg = msg_fs_update(
2251            1,
2252            1,
2253            FS_UPDATE_RESET | FS_UPDATE_SYNC,
2254            &upsert("f", b"hello world"),
2255        );
2256        mirror.apply_update(&msg).unwrap();
2257
2258        // COPY(0,6) + INSERT("blit") == "hello blit"
2259        let ops: Vec<u8> = vec![0x01, 0, 6, 0x02, 4, b'b', b'l', b'i', b't'];
2260        let mut records = Vec::new();
2261        append_fs_record(
2262            &mut records,
2263            &FsRecord::Upsert {
2264                path: "f",
2265                entry_flags: FS_ENTRY_FILE,
2266                size: 10,
2267                mtime_ns: 43,
2268                mode: 0o644,
2269                hash: 8,
2270                content: FsContent::Delta(&ops),
2271            },
2272        );
2273        let msg = msg_fs_update(1, 2, 0, &records);
2274        mirror.apply_update(&msg).unwrap();
2275        assert_eq!(
2276            mirror.live["f"].content.as_deref(),
2277            Some(&b"hello blit"[..])
2278        );
2279    }
2280
2281    #[test]
2282    fn subtree_semantics() {
2283        let mut map = BTreeMap::new();
2284        for p in ["a", "a/b", "a/b/c", "ab", "z"] {
2285            map.insert(
2286                p.to_string(),
2287                FsNode {
2288                    entry_flags: FS_ENTRY_FILE,
2289                    size: 0,
2290                    mtime_ns: 0,
2291                    mode: 0,
2292                    hash: 0,
2293                    content: None,
2294                },
2295            );
2296        }
2297        // "ab" must not match subtree "a" — and neither may a taken (moved)
2298        // subtree, even though "ab" sorts between "a" and "a/b".
2299        let taken = take_subtree(&mut map.clone(), "a");
2300        let suffixes: Vec<_> = taken.iter().map(|(s, _)| s.clone()).collect();
2301        assert_eq!(
2302            suffixes,
2303            vec![String::new(), "b".to_string(), "b/c".to_string()]
2304        );
2305        remove_subtree(&mut map, "a");
2306        let left: Vec<_> = map.keys().cloned().collect();
2307        assert_eq!(left, vec!["ab".to_string(), "z".to_string()]);
2308    }
2309}