1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
23use std::sync::{Arc, Mutex, OnceLock};
24use std::time::{Duration, Instant, SystemTime};
25use std::{fs, io};
26
27use blit_remote::fs::{
28 FS_CLOSED_CLIENT_REQUEST, FS_CLOSED_RESOURCE_LIMIT, FS_CLOSED_ROOT_GONE, FS_DONE_CONFLICT,
29 FS_DONE_INVALID, FS_DONE_NOT_FOUND, FS_DONE_OK, FS_DONE_OTHER, FS_DONE_PERMISSION,
30 FS_DONE_TOO_LARGE, FS_DONE_WRONG_TYPE, FS_ENTRY_DIR, FS_ENTRY_FILE, FS_ENTRY_FILTERED,
31 FS_ENTRY_LINK_DIR, FS_ENTRY_NO_CONTENT, FS_ENTRY_OTHER, FS_ENTRY_SYMLINK, FS_ENTRY_TYPE_MASK,
32 FS_ENTRY_UNREADABLE, FS_ENTRY_UNSTABLE, FS_FILE_NOT_FOUND, FS_FILE_OK, FS_FILE_UNREADABLE,
33 FS_OP_HARDLINK, FS_OP_MKDIR, FS_OP_MKPARENTS, FS_OP_NO_CAS, FS_OP_REMOVE, FS_OP_RENAME,
34 FS_OP_SYMLINK, FS_UPDATE_RESET, FS_UPDATE_SYNC, FS_WRITE_DURABLE, FS_WRITE_FOLLOW_SYMLINK,
35 FS_WRITE_MKPARENTS, FS_WRITE_NO_CAS, FsContent, FsRecord, append_fs_record, msg_fs_closed,
36 msg_fs_done, msg_fs_file, msg_fs_update,
37};
38
39pub mod backend;
40pub mod ignores;
41
42pub use ignores::{IgnoreSpec, MAX_PATTERNS as MAX_IGNORE_PATTERNS};
43
44#[derive(Clone, Debug)]
49pub struct SyncOptions {
50 pub recursive: bool,
51 pub content: bool,
52 pub cross_filesystem: bool,
53 pub latency: Duration,
55 pub inline_max: u64,
57 pub window_bytes: usize,
59 pub batch_target: usize,
61 pub max_entries: usize,
63}
64
65impl Default for SyncOptions {
66 fn default() -> Self {
67 Self {
68 recursive: true,
69 content: false,
70 cross_filesystem: false,
71 latency: env_ms("BLIT_FS_LATENCY_MS", 20),
72 inline_max: env_u64("BLIT_FS_INLINE_MAX", 16 * 1024 * 1024),
73 window_bytes: env_u64("BLIT_FS_WINDOW", 1024 * 1024) as usize,
74 batch_target: 64 * 1024,
75 max_entries: env_u64("BLIT_FS_MAX_ENTRIES", 1_000_000) as usize,
76 }
77 }
78}
79
80fn env_ms(name: &str, default: u64) -> Duration {
81 Duration::from_millis(env_u64(name, default).clamp(1, 1000))
82}
83
84fn env_u64(name: &str, default: u64) -> u64 {
85 std::env::var(name)
86 .ok()
87 .and_then(|v| v.parse().ok())
88 .unwrap_or(default)
89}
90
91#[derive(Clone, Debug)]
94pub enum Hint {
95 Dirty(PathBuf),
97 Rescan,
99}
100
101#[derive(Debug)]
108pub struct InflightGuard {
109 set: Arc<Mutex<std::collections::HashSet<u16>>>,
110 nonce: u16,
111}
112
113impl InflightGuard {
114 pub fn new(set: Arc<Mutex<std::collections::HashSet<u16>>>, nonce: u16) -> Self {
115 InflightGuard { set, nonce }
116 }
117}
118
119impl Drop for InflightGuard {
120 fn drop(&mut self) {
121 if let Ok(mut set) = self.set.lock() {
122 set.remove(&self.nonce);
123 }
124 }
125}
126
127#[derive(Clone, Debug)]
130pub struct WriteReq {
131 pub nonce: u16,
132 pub path: String,
133 pub base: u128,
134 pub mode: u32,
135 pub flags: u8,
136 pub content_kind: u8,
137 pub content: Vec<u8>,
138 pub inflight: Option<Arc<InflightGuard>>,
141}
142
143#[derive(Clone, Debug)]
146pub struct OpReq {
147 pub nonce: u16,
148 pub op: u8,
149 pub a: String,
150 pub b: String,
151 pub base: u128,
152 pub mode: u32,
153 pub flags: u8,
154 pub inflight: Option<Arc<InflightGuard>>,
155}
156
157#[derive(Clone, Debug)]
159pub enum Command {
160 Ack(u32),
161 Fetch { nonce: u16, path: String },
162 Write(WriteReq),
163 Op(OpReq),
164 Stop,
165}
166
167pub trait BackendHandle: Send {
173 fn add_dir(&self, _dir: &Path) -> bool {
177 true
178 }
179 fn watch_outside(&self, _dir: &Path) {}
184 fn remove_dir(&self, _dir: &Path) {}
187 fn retain_dirs(&self, _keep: &dyn Fn(&Path) -> bool) {}
190}
191
192pub struct NoopBackend;
193impl BackendHandle for NoopBackend {}
194
195#[derive(Clone, Debug, PartialEq, Eq, Hash)]
205pub struct RootKey {
206 pub path: PathBuf,
208 pub recursive: bool,
209 pub cross_filesystem: bool,
210 pub ignores: IgnoreSpec,
214}
215
216enum RootMsg {
218 Hint(Hint),
219 Subscribe {
220 id: u64,
221 tx: Sender<SyncMsg>,
222 latency: Duration,
223 },
224 Unsubscribe {
225 id: u64,
226 },
227 HashLearned {
231 path: String,
232 meta: NodeMeta,
233 },
234}
235
236enum RootUpdate {
238 Snapshot {
243 index: Arc<Index>,
244 settled: Option<Instant>,
245 changed: Option<Arc<std::collections::BTreeSet<String>>>,
249 recheck: Arc<std::collections::BTreeSet<String>>,
253 },
254 Closed(u8),
256}
257
258enum SyncMsg {
260 Cmd(Command),
261 Root(RootUpdate),
262}
263
264pub struct SharedRootHandle {
268 key: RootKey,
269 single: bool,
274 tx: Sender<RootMsg>,
275 closed: Arc<OnceLock<u8>>,
279 learned: Mutex<std::collections::HashMap<String, NodeMeta>>,
287 _backend: Mutex<Option<backend::WatchBackend>>,
289}
290
291impl SharedRootHandle {
292 pub fn key(&self) -> &RootKey {
293 &self.key
294 }
295
296 pub fn is_single(&self) -> bool {
298 self.single
299 }
300
301 pub fn hint_sender(&self) -> HintSender {
303 HintSender {
304 tx: self.tx.clone(),
305 }
306 }
307
308 fn is_closed(&self) -> bool {
309 self.closed.get().is_some()
310 }
311}
312
313#[derive(Clone, Debug, PartialEq, Eq, Hash)]
318struct RegKey {
319 root: RootKey,
320 single: bool,
321}
322
323type Registry = std::collections::HashMap<RegKey, std::sync::Weak<SharedRootHandle>>;
324
325fn registry() -> &'static Mutex<Registry> {
326 static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
327 REGISTRY.get_or_init(Default::default)
328}
329
330pub fn open_root(key: RootKey) -> Result<Arc<SharedRootHandle>, (u8, String)> {
335 open_root_inner(key, false, true)
336}
337
338pub fn open_root_unwatched(key: RootKey) -> Arc<SharedRootHandle> {
341 open_root_inner(key, false, false).expect("unwatched open cannot fail")
342}
343
344pub fn open_single_root(path: PathBuf) -> Result<Arc<SharedRootHandle>, (u8, String)> {
353 open_root_inner(single_root_key(path), true, true)
354}
355
356pub fn open_single_root_unwatched(path: PathBuf) -> Arc<SharedRootHandle> {
359 open_root_inner(single_root_key(path), true, false).expect("unwatched open cannot fail")
360}
361
362fn single_root_key(path: PathBuf) -> RootKey {
363 RootKey {
364 path,
365 recursive: false,
366 cross_filesystem: false,
367 ignores: IgnoreSpec::default(),
368 }
369}
370
371fn watch_error_status(err: ¬ify::Error) -> u8 {
373 use blit_remote::fs::{
374 FS_STATUS_NOT_FOUND, FS_STATUS_OTHER, FS_STATUS_PERMISSION_DENIED, FS_STATUS_RESOURCE_LIMIT,
375 };
376 match &err.kind {
377 notify::ErrorKind::MaxFilesWatch => FS_STATUS_RESOURCE_LIMIT,
378 notify::ErrorKind::PathNotFound => FS_STATUS_NOT_FOUND,
379 notify::ErrorKind::Io(e) => match e.raw_os_error() {
380 Some(23) | Some(24) | Some(28) => FS_STATUS_RESOURCE_LIMIT,
382 _ => match e.kind() {
383 io::ErrorKind::PermissionDenied => FS_STATUS_PERMISSION_DENIED,
384 io::ErrorKind::NotFound => FS_STATUS_NOT_FOUND,
385 _ => FS_STATUS_OTHER,
386 },
387 },
388 _ => FS_STATUS_OTHER,
389 }
390}
391
392fn open_root_inner(
393 key: RootKey,
394 single: bool,
395 watched: bool,
396) -> Result<Arc<SharedRootHandle>, (u8, String)> {
397 let reg_key = RegKey {
398 root: key.clone(),
399 single,
400 };
401 {
403 let mut map = registry().lock().unwrap();
404 map.retain(|_, weak| weak.strong_count() > 0);
405 if let Some(existing) = map
406 .get(®_key)
407 .and_then(std::sync::Weak::upgrade)
408 .filter(|h| !h.is_closed())
409 {
410 return Ok(existing);
411 }
412 }
413 let (tx, rx) = mpsc::channel();
418 let backend = if watched {
419 let hints = HintSender { tx: tx.clone() };
420 let (watch_path, recursive) = if single {
426 let parent = key
427 .path
428 .parent()
429 .ok_or_else(|| {
430 use blit_remote::fs::FS_STATUS_OTHER;
431 (FS_STATUS_OTHER, "single root has no parent".to_string())
432 })?
433 .to_path_buf();
434 (parent, false)
435 } else {
436 (key.path.clone(), key.recursive)
437 };
438 let per_dir =
442 backend::per_dir_watching_pays(key.recursive, single, !key.ignores.is_empty());
443 Some(
444 backend::watch(&watch_path, recursive, per_dir, hints)
445 .map_err(|e| (watch_error_status(&e), e.to_string()))?,
446 )
447 } else {
448 None
449 };
450 let registrar: Box<dyn BackendHandle> = match &backend {
453 Some(backend) => Box::new(backend.watches.clone()),
454 None => Box::new(NoopBackend),
455 };
456 let mut map = registry().lock().unwrap();
457 map.retain(|_, weak| weak.strong_count() > 0);
458 if let Some(existing) = map
461 .get(®_key)
462 .and_then(std::sync::Weak::upgrade)
463 .filter(|h| !h.is_closed())
464 {
465 return Ok(existing);
466 }
467 let closed: Arc<OnceLock<u8>> = Arc::new(OnceLock::new());
468 let handle = Arc::new(SharedRootHandle {
469 key: key.clone(),
470 single,
471 tx,
472 closed: closed.clone(),
473 learned: Mutex::new(Default::default()),
474 _backend: Mutex::new(backend),
475 });
476 std::thread::Builder::new()
477 .name("blit-fsroot".into())
478 .spawn(move || Reconciler::new(key, single, rx, registrar, closed).run())
479 .expect("spawn fssync reconciler");
480 map.insert(reg_key, Arc::downgrade(&handle));
481 Ok(handle)
482}
483
484pub struct SyncHandle {
487 tx: Sender<SyncMsg>,
488 done: Arc<std::sync::atomic::AtomicBool>,
492}
493
494impl SyncHandle {
495 pub fn command(&self, cmd: Command) -> bool {
496 self.tx.send(SyncMsg::Cmd(cmd)).is_ok()
497 }
498
499 pub fn is_done(&self) -> bool {
503 self.done.load(std::sync::atomic::Ordering::Acquire)
504 }
505}
506
507impl Drop for SyncHandle {
508 fn drop(&mut self) {
509 let _ = self.tx.send(SyncMsg::Cmd(Command::Stop));
510 }
511}
512
513#[derive(Clone)]
515pub struct HintSender {
516 tx: Sender<RootMsg>,
517}
518
519impl HintSender {
520 pub fn send(&self, hint: Hint) -> bool {
521 self.tx.send(RootMsg::Hint(hint)).is_ok()
522 }
523}
524
525pub type Outbox = Box<dyn FnMut(Vec<u8>) -> bool + Send>;
528
529pub fn validate_root(path: &str) -> Result<PathBuf, (u8, String)> {
532 use blit_remote::fs::{FS_STATUS_NOT_FOUND, FS_STATUS_OTHER, FS_STATUS_PERMISSION_DENIED};
533 if path.is_empty() || path.contains('\0') {
534 return Err((FS_STATUS_OTHER, "invalid path".into()));
535 }
536 let err = match fs::canonicalize(path) {
537 Ok(p) => return Ok(p),
538 Err(e) => e,
539 };
540 if err.kind() == io::ErrorKind::NotFound
554 && path.contains('%')
555 && let Some(decoded) = wire_to_os(path)
556 && let Ok(p) = fs::canonicalize(&decoded)
557 {
558 return Ok(p);
559 }
560 let status = match err.kind() {
561 io::ErrorKind::NotFound => FS_STATUS_NOT_FOUND,
562 io::ErrorKind::PermissionDenied => FS_STATUS_PERMISSION_DENIED,
563 _ => FS_STATUS_OTHER,
564 };
565 Err((status, err.to_string()))
566}
567
568pub fn validate_single_root(path: &str) -> Result<PathBuf, (u8, String)> {
574 use blit_remote::fs::FS_STATUS_OTHER;
575 let canon = validate_root(path)?;
576 match fs::symlink_metadata(&canon) {
577 Ok(md) if md.is_dir() => Err((
578 FS_STATUS_OTHER,
579 "single sync root is a directory".to_string(),
580 )),
581 _ => Ok(canon),
582 }
583}
584
585pub fn start_sync(
589 shared: &Arc<SharedRootHandle>,
590 sync_id: u16,
591 opts: SyncOptions,
592 outbox: Outbox,
593) -> SyncHandle {
594 static SUB_IDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
595 let sub_id = SUB_IDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
596 let (tx, rx) = mpsc::channel();
597 let _ = shared.tx.send(RootMsg::Subscribe {
598 id: sub_id,
599 tx: tx.clone(),
600 latency: opts.latency,
601 });
602 let engine = SyncEngine::new(sync_id, shared.clone(), sub_id, opts, rx, outbox);
603 let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
604 let done_thread = done.clone();
605 std::thread::Builder::new()
606 .name(format!("blit-fssync-{sync_id}"))
607 .spawn(move || {
608 engine.run();
609 done_thread.store(true, std::sync::atomic::Ordering::Release);
611 })
612 .expect("spawn fssync engine");
613 SyncHandle { tx, done }
614}
615
616pub fn escape_bytes(bytes: &[u8]) -> String {
622 let mut out = String::with_capacity(bytes.len());
623 let mut rest = bytes;
624 loop {
625 match std::str::from_utf8(rest) {
626 Ok(s) => {
627 push_escaping_percent(&mut out, s);
628 return out;
629 }
630 Err(e) => {
631 let (valid, after) = rest.split_at(e.valid_up_to());
632 push_escaping_percent(&mut out, unsafe { std::str::from_utf8_unchecked(valid) });
633 let bad = e.error_len().unwrap_or(after.len());
634 for &b in &after[..bad] {
635 out.push_str(&format!("%{b:02X}"));
636 }
637 rest = &after[bad..];
638 }
639 }
640 }
641}
642
643fn push_escaping_percent(out: &mut String, s: &str) {
644 for ch in s.chars() {
645 if ch == '%' {
646 out.push_str("%25");
647 } else {
648 out.push(ch);
649 }
650 }
651}
652
653pub fn unescape_to_bytes(s: &str) -> Option<Vec<u8>> {
655 let mut out = Vec::with_capacity(s.len());
656 let bytes = s.as_bytes();
657 let mut i = 0;
658 while i < bytes.len() {
659 if bytes[i] == b'%' {
660 let hex = bytes.get(i + 1..i + 3)?;
661 let hi = (hex[0] as char).to_digit(16)?;
662 let lo = (hex[1] as char).to_digit(16)?;
663 out.push((hi * 16 + lo) as u8);
664 i += 3;
665 } else {
666 out.push(bytes[i]);
667 i += 1;
668 }
669 }
670 Some(out)
671}
672
673pub fn escape_wide(units: &[u16]) -> String {
678 let mut out = String::with_capacity(units.len());
679 for decoded in char::decode_utf16(units.iter().copied()) {
680 match decoded {
681 Ok('%') => out.push_str("%25"),
682 Ok(c) => out.push(c),
683 Err(e) => {
684 out.push_str(&format!("%u{:04X}", e.unpaired_surrogate()));
685 }
686 }
687 }
688 out
689}
690
691pub fn unescape_to_wide(s: &str) -> Option<Vec<u16>> {
694 let mut out = Vec::with_capacity(s.len());
695 let bytes = s.as_bytes();
696 let mut i = 0;
697 while i < bytes.len() {
698 if bytes[i] == b'%' {
699 if bytes.get(i + 1) == Some(&b'u') {
700 out.push(u16::from_str_radix(s.get(i + 2..i + 6)?, 16).ok()?);
701 i += 6;
702 } else {
703 out.push(u16::from(
704 u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?,
705 ));
706 i += 3;
707 }
708 } else {
709 let c = s[i..].chars().next()?;
710 let mut buf = [0u16; 2];
711 out.extend_from_slice(c.encode_utf16(&mut buf));
712 i += c.len_utf8();
713 }
714 }
715 Some(out)
716}
717
718#[cfg(unix)]
721pub fn escape_path(path: &Path) -> String {
722 use std::os::unix::ffi::OsStrExt;
723 escape_bytes(path.as_os_str().as_bytes())
724}
725
726#[cfg(windows)]
727pub fn escape_path(path: &Path) -> String {
728 use std::os::windows::ffi::OsStrExt;
729 escape_wide(&path.as_os_str().encode_wide().collect::<Vec<_>>())
730}
731
732#[cfg(all(not(unix), not(windows)))]
733pub fn escape_path(path: &Path) -> String {
734 escape_bytes(path.to_string_lossy().as_bytes())
735}
736
737#[cfg(unix)]
738fn os_to_wire(name: &std::ffi::OsStr) -> String {
739 use std::os::unix::ffi::OsStrExt;
740 escape_bytes(name.as_bytes())
741}
742
743#[cfg(windows)]
744fn os_to_wire(name: &std::ffi::OsStr) -> String {
745 use std::os::windows::ffi::OsStrExt;
746 escape_wide(&name.encode_wide().collect::<Vec<_>>())
747}
748
749#[cfg(all(not(unix), not(windows)))]
750fn os_to_wire(name: &std::ffi::OsStr) -> String {
751 escape_bytes(name.to_string_lossy().as_bytes())
752}
753
754#[cfg(unix)]
755fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
756 use std::os::unix::ffi::OsStringExt;
757 Some(std::ffi::OsString::from_vec(unescape_to_bytes(component)?))
758}
759
760#[cfg(windows)]
761fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
762 use std::os::windows::ffi::OsStringExt;
763 Some(std::ffi::OsString::from_wide(&unescape_to_wide(component)?))
764}
765
766#[cfg(all(not(unix), not(windows)))]
767fn wire_to_os(component: &str) -> Option<std::ffi::OsString> {
768 Some(
769 String::from_utf8(unescape_to_bytes(component)?)
770 .ok()?
771 .into(),
772 )
773}
774
775pub fn resolve_wire_path(root: &Path, wire: &str) -> Option<PathBuf> {
778 use std::path::Component;
779 let mut abs = root.to_path_buf();
780 if wire.is_empty() {
781 return Some(abs);
782 }
783 for component in wire.split('/') {
784 let os = wire_to_os(component)?;
791 let mut parts = Path::new(&os).components();
792 match (parts.next(), parts.next()) {
793 (Some(Component::Normal(part)), None) if part == os.as_os_str() => abs.push(part),
794 _ => return None,
795 }
796 }
797 Some(abs)
798}
799
800fn join_wire(parent: &str, child: &str) -> String {
801 if parent.is_empty() {
802 child.to_string()
803 } else {
804 format!("{parent}/{child}")
805 }
806}
807
808#[derive(Clone, Debug, PartialEq, Eq)]
813pub struct NodeMeta {
814 pub node_type: u8,
816 pub size: u64,
817 pub mtime_ns: u64,
818 pub mode: u32,
819 pub hash: u128,
821 pub dev_ino: (u64, u64),
823 pub link_dir: bool,
827 pub filtered: bool,
833}
834
835impl NodeMeta {
836 fn enumerable_dir(&self) -> bool {
842 self.node_type == FS_ENTRY_DIR || (self.node_type == FS_ENTRY_SYMLINK && self.link_dir)
843 }
844
845 fn same_identity(&self, other: &NodeMeta) -> bool {
846 self.node_type == other.node_type && self.dev_ino != (0, 0) && self.dev_ino == other.dev_ino
847 }
848
849 fn content_changed(&self, prev: &NodeMeta) -> bool {
850 self.node_type != prev.node_type
851 || self.size != prev.size
852 || self.mtime_ns != prev.mtime_ns
853 || self.dev_ino != prev.dev_ino
854 }
855
856 fn visible_eq(&self, other: &NodeMeta) -> bool {
874 self.node_type == other.node_type
875 && self.size == other.size
876 && self.mtime_ns == other.mtime_ns
877 && self.mode == other.mode
878 && self.dev_ino == other.dev_ino
879 && self.filtered == other.filtered
880 && self.link_dir == other.link_dir
881 }
882}
883
884fn target_identity(md: &fs::Metadata) -> (u64, u64) {
887 #[cfg(unix)]
888 {
889 use std::os::unix::fs::MetadataExt;
890 (md.dev(), md.ino())
891 }
892 #[cfg(not(unix))]
893 {
894 let _ = md;
895 (0, 0)
896 }
897}
898
899fn stat_meta(path: &Path) -> io::Result<NodeMeta> {
900 let md = fs::symlink_metadata(path)?;
901 let ft = md.file_type();
902 let node_type = if ft.is_file() {
903 FS_ENTRY_FILE
904 } else if ft.is_dir() {
905 FS_ENTRY_DIR
906 } else if ft.is_symlink() {
907 FS_ENTRY_SYMLINK
908 } else {
909 FS_ENTRY_OTHER
910 };
911 let mtime_ns = md
912 .modified()
913 .ok()
914 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
915 .map(|d| d.as_nanos() as u64)
916 .unwrap_or(0);
917 #[cfg(unix)]
918 let (mode, dev_ino) = {
919 use std::os::unix::fs::MetadataExt;
920 (md.mode(), (md.dev(), md.ino()))
921 };
922 #[cfg(not(unix))]
923 let (mode, dev_ino) = (0u32, (0u64, 0u64));
924 Ok(NodeMeta {
925 node_type,
926 link_dir: ft.is_symlink() && fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false),
929 filtered: false,
932 size: if ft.is_file() || ft.is_symlink() {
935 md.len()
936 } else {
937 0
938 },
939 mtime_ns,
940 mode,
941 hash: 0,
942 dev_ino,
943 })
944}
945
946type Index = BTreeMap<String, NodeMeta>;
947
948fn is_under(path: &str, root: &str) -> bool {
949 root.is_empty()
950 || path == root
951 || (path.len() > root.len()
952 && path.starts_with(root)
953 && path.as_bytes()[root.len()] == b'/')
954}
955
956fn subtree_keys<V>(map: &BTreeMap<String, V>, root: &str) -> Vec<String> {
960 if root.is_empty() {
961 return map.keys().cloned().collect();
962 }
963 let mut keys: Vec<String> = Vec::new();
964 if map.contains_key(root) {
965 keys.push(root.to_string());
966 }
967 let prefix = format!("{root}/");
968 keys.extend(
969 map.range(prefix.clone()..)
970 .take_while(|(k, _)| k.starts_with(&prefix))
971 .map(|(k, _)| k.clone()),
972 );
973 keys
974}
975
976fn subtree_entries<'a, V>(
978 map: &'a BTreeMap<String, V>,
979 root: &str,
980) -> impl Iterator<Item = (&'a String, &'a V)> {
981 let own = if root.is_empty() {
982 None
983 } else {
984 map.get_key_value(root)
985 };
986 let prefix = if root.is_empty() {
987 String::new()
988 } else {
989 format!("{root}/")
990 };
991 own.into_iter().chain(
992 map.range(prefix.clone()..)
993 .take_while(move |(k, _)| k.starts_with(&prefix)),
994 )
995}
996
997fn parent_wire(rel: &str) -> Option<&str> {
1000 if rel.is_empty() {
1001 None
1002 } else {
1003 Some(match rel.rfind('/') {
1004 Some(i) => &rel[..i],
1005 None => "",
1006 })
1007 }
1008}
1009
1010fn rebase_subtree_path(path: &str, from: &str, to: &str) -> String {
1014 let suffix = if path.len() > from.len() {
1015 &path[from.len() + usize::from(!from.is_empty())..]
1016 } else {
1017 ""
1018 };
1019 if suffix.is_empty() {
1020 to.to_string()
1021 } else if to.is_empty() {
1022 suffix.to_string()
1023 } else {
1024 format!("{to}/{suffix}")
1025 }
1026}
1027
1028#[derive(Clone, Debug, PartialEq, Eq)]
1033pub enum DiffOp {
1034 Upsert {
1036 path: String,
1037 content_changed: bool,
1038 },
1039 Delete {
1040 path: String,
1041 },
1042 Move {
1043 from: String,
1044 to: String,
1045 },
1046}
1047
1048pub fn diff(prev: &Index, curr: &Index) -> Vec<DiffOp> {
1056 let mut removed: Vec<&String> = Vec::new();
1057 let mut added: Vec<&String> = Vec::new();
1058 let mut changed: Vec<(&String, bool)> = Vec::new();
1059
1060 let mut pi = prev.iter().peekable();
1061 let mut ci = curr.iter().peekable();
1062 loop {
1063 match (pi.peek(), ci.peek()) {
1064 (Some((pk, pv)), Some((ck, cv))) => {
1065 if pk == ck {
1066 if !cv.visible_eq(pv) {
1067 changed.push((ck, cv.content_changed(pv)));
1068 }
1069 pi.next();
1070 ci.next();
1071 } else if pk < ck {
1072 removed.push(pk);
1073 pi.next();
1074 } else {
1075 added.push(ck);
1076 ci.next();
1077 }
1078 }
1079 (Some((pk, _)), None) => {
1080 removed.push(pk);
1081 pi.next();
1082 }
1083 (None, Some((ck, _))) => {
1084 added.push(ck);
1085 ci.next();
1086 }
1087 (None, None) => break,
1088 }
1089 }
1090 diff_classified(prev, curr, removed, added, changed)
1091}
1092
1093fn diff_changed(
1099 prev: &Index,
1100 curr: &Index,
1101 changed_keys: &std::collections::BTreeSet<String>,
1102) -> Vec<DiffOp> {
1103 let mut removed: Vec<&String> = Vec::new();
1104 let mut added: Vec<&String> = Vec::new();
1105 let mut changed: Vec<(&String, bool)> = Vec::new();
1106 for key in changed_keys {
1108 match (prev.get_key_value(key), curr.get_key_value(key)) {
1109 (Some((pk, pv)), Some((_, cv))) => {
1110 if !cv.visible_eq(pv) {
1111 changed.push((pk, cv.content_changed(pv)));
1112 }
1113 }
1114 (Some((pk, _)), None) => removed.push(pk),
1115 (None, Some((ck, _))) => added.push(ck),
1116 (None, None) => {}
1117 }
1118 }
1119 diff_classified(prev, curr, removed, added, changed)
1120}
1121
1122fn cover_sorted(paths: &[&String], covered: &mut [bool], root: &str) {
1126 if let Ok(i) = paths.binary_search_by(|p| p.as_str().cmp(root)) {
1127 covered[i] = true;
1128 }
1129 let prefix = format!("{root}/");
1130 let start = paths.partition_point(|p| p.as_str() < prefix.as_str());
1131 for i in start..paths.len() {
1132 if !paths[i].starts_with(&prefix) {
1133 break;
1134 }
1135 covered[i] = true;
1136 }
1137}
1138
1139fn diff_classified(
1143 prev: &Index,
1144 curr: &Index,
1145 removed: Vec<&String>,
1146 added: Vec<&String>,
1147 changed: Vec<(&String, bool)>,
1148) -> Vec<DiffOp> {
1149 let mut moves: Vec<(String, String)> = Vec::new();
1152 let mut removed_covered = vec![false; removed.len()];
1153 let mut added_covered = vec![false; added.len()];
1154 let mut by_identity: std::collections::HashMap<(u64, u64), usize> =
1155 std::collections::HashMap::new();
1156 for (idx, path) in removed.iter().enumerate() {
1157 let meta = &prev[*path];
1158 if meta.dev_ino != (0, 0) {
1159 by_identity.insert(meta.dev_ino, idx);
1160 }
1161 }
1162 let mut add_order: Vec<usize> = (0..added.len()).collect();
1163 add_order.sort_by_key(|&i| added[i].len());
1164 for ai in add_order {
1165 if added_covered[ai] {
1166 continue;
1167 }
1168 let to = added[ai];
1169 let cmeta = &curr[to];
1170 let Some(&ri) = by_identity.get(&cmeta.dev_ino) else {
1171 continue;
1172 };
1173 if removed_covered[ri] || !prev[removed[ri]].same_identity(cmeta) {
1174 continue;
1175 }
1176 let from = removed[ri];
1177 cover_sorted(&removed, &mut removed_covered, from);
1179 cover_sorted(&added, &mut added_covered, to);
1180 moves.push((from.clone(), to.clone()));
1181 }
1182
1183 let mut ops = Vec::new();
1184 for (from, to) in &moves {
1187 ops.push(DiffOp::Move {
1188 from: from.clone(),
1189 to: to.clone(),
1190 });
1191 }
1192 let mut emitted: std::collections::HashSet<&str> = std::collections::HashSet::new();
1197 for (i, path) in removed.iter().enumerate() {
1198 if removed_covered[i] {
1199 continue;
1200 }
1201 let mut ancestor_deleted = false;
1202 let mut cursor: &str = path;
1203 while let Some(parent) = parent_wire(cursor) {
1204 if emitted.contains(parent) {
1205 ancestor_deleted = true;
1206 break;
1207 }
1208 cursor = parent;
1209 }
1210 if !ancestor_deleted {
1211 emitted.insert(path.as_str());
1212 ops.push(DiffOp::Delete {
1213 path: (*path).clone(),
1214 });
1215 }
1216 }
1217 for (i, path) in added.iter().enumerate() {
1218 if !added_covered[i] {
1219 ops.push(DiffOp::Upsert {
1220 path: (*path).clone(),
1221 content_changed: true,
1222 });
1223 }
1224 }
1225 for (from, to) in &moves {
1231 for (path, _) in subtree_entries(prev, from) {
1232 let new_path = rebase_subtree_path(path, from, to);
1233 if !curr.contains_key(&new_path) {
1234 ops.push(DiffOp::Delete { path: new_path });
1235 }
1236 }
1237 for (path, new) in subtree_entries(curr, to) {
1238 let old_path = rebase_subtree_path(path, to, from);
1239 match prev.get(&old_path) {
1240 Some(old) if new.visible_eq(old) => {}
1241 Some(old) => ops.push(DiffOp::Upsert {
1242 path: path.clone(),
1243 content_changed: new.content_changed(old),
1244 }),
1245 None => ops.push(DiffOp::Upsert {
1246 path: path.clone(),
1247 content_changed: true,
1248 }),
1249 }
1250 }
1251 }
1252 for (path, content_changed) in changed {
1253 ops.push(DiffOp::Upsert {
1254 path: path.clone(),
1255 content_changed,
1256 });
1257 }
1258 ops
1259}
1260
1261pub enum ReadOutcome {
1266 Stable(Vec<u8>),
1267 Unstable,
1268 Unreadable,
1269}
1270
1271enum ReadMetaOutcome {
1272 Stable(Vec<u8>, NodeMeta),
1274 Unstable,
1275 Unreadable,
1276}
1277
1278fn read_verified_meta(path: &Path) -> ReadMetaOutcome {
1282 for _ in 0..2 {
1283 let Ok(before) = stat_meta(path) else {
1284 return ReadMetaOutcome::Unreadable;
1285 };
1286 let read = if before.node_type == FS_ENTRY_SYMLINK {
1287 link_target_bytes(path)
1288 } else {
1289 fs::read(path)
1290 };
1291 let Ok(data) = read else {
1292 return ReadMetaOutcome::Unreadable;
1293 };
1294 match stat_meta(path) {
1295 Ok(after)
1296 if after.dev_ino == before.dev_ino
1297 && after.size == before.size
1298 && after.mtime_ns == before.mtime_ns =>
1299 {
1300 return ReadMetaOutcome::Stable(data, after);
1301 }
1302 Ok(_) => continue,
1303 Err(_) => return ReadMetaOutcome::Unreadable,
1304 }
1305 }
1306 ReadMetaOutcome::Unstable
1307}
1308
1309pub fn read_verified(path: &Path) -> ReadOutcome {
1311 match read_verified_meta(path) {
1312 ReadMetaOutcome::Stable(data, _) => ReadOutcome::Stable(data),
1313 ReadMetaOutcome::Unstable => ReadOutcome::Unstable,
1314 ReadMetaOutcome::Unreadable => ReadOutcome::Unreadable,
1315 }
1316}
1317
1318const RACY_WINDOW_NS: u64 = 2_000_000_000;
1324
1325fn racily_clean(mtime_ns: u64) -> bool {
1326 let now_ns = SystemTime::now()
1327 .duration_since(SystemTime::UNIX_EPOCH)
1328 .map(|d| d.as_nanos() as u64)
1329 .unwrap_or(0);
1330 now_ns.saturating_sub(mtime_ns) < RACY_WINDOW_NS
1331}
1332
1333pub fn blake3_128(data: &[u8]) -> u128 {
1337 let hash = blake3::hash(data);
1338 u128::from_le_bytes(hash.as_bytes()[..16].try_into().unwrap())
1339}
1340
1341fn fs_write_max() -> u64 {
1352 std::env::var("BLIT_FS_WRITE_MAX")
1353 .ok()
1354 .and_then(|v| v.parse().ok())
1355 .unwrap_or(16 * 1024 * 1024)
1356}
1357
1358fn write_io_status(e: &io::Error) -> u8 {
1359 match e.kind() {
1360 io::ErrorKind::NotFound => FS_DONE_NOT_FOUND,
1361 io::ErrorKind::PermissionDenied => FS_DONE_PERMISSION,
1362 io::ErrorKind::AlreadyExists => FS_DONE_CONFLICT,
1363 _ => FS_DONE_OTHER,
1364 }
1365}
1366
1367enum SymlinkPolicy {
1369 Refuse,
1371 Follow,
1373 Operate,
1376}
1377
1378enum ConfineError {
1380 Invalid,
1382 Io(io::Error),
1384 Escapes,
1386}
1387
1388fn confine_target(root: &Path, wire: &str) -> Result<PathBuf, ConfineError> {
1396 let abs = resolve_wire_path(root, wire).ok_or(ConfineError::Invalid)?;
1397 let (Some(parent), Some(name)) = (abs.parent(), abs.file_name()) else {
1398 return Err(ConfineError::Invalid);
1399 };
1400 let canon_parent = fs::canonicalize(parent).map_err(ConfineError::Io)?;
1401 if !canon_parent.starts_with(root) {
1402 return Err(ConfineError::Escapes);
1403 }
1404 Ok(canon_parent.join(name))
1405}
1406
1407fn resolve_write_target(root: &Path, wire: &str, policy: SymlinkPolicy) -> Result<PathBuf, u8> {
1411 let target = match confine_target(root, wire) {
1412 Ok(t) => t,
1413 Err(ConfineError::Invalid) => return Err(FS_DONE_INVALID),
1414 Err(ConfineError::Io(e)) => return Err(write_io_status(&e)),
1415 Err(ConfineError::Escapes) => return Err(FS_DONE_PERMISSION),
1416 };
1417 match fs::symlink_metadata(&target) {
1418 Ok(md) if md.file_type().is_symlink() => match policy {
1419 SymlinkPolicy::Refuse => Err(FS_DONE_PERMISSION),
1420 SymlinkPolicy::Operate => Ok(target),
1421 SymlinkPolicy::Follow => {
1422 let resolved = fs::canonicalize(&target).map_err(|e| write_io_status(&e))?;
1423 if resolved.starts_with(root) {
1424 Ok(resolved)
1425 } else {
1426 Err(FS_DONE_PERMISSION)
1427 }
1428 }
1429 },
1430 _ => Ok(target),
1431 }
1432}
1433
1434fn temp_sibling(target: &Path) -> PathBuf {
1437 static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1438 let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1439 let dir = target.parent().unwrap_or_else(|| Path::new("."));
1440 dir.join(format!(".blit-tmp-{}-{n}", std::process::id()))
1441}
1442
1443#[cfg(unix)]
1446fn apply_mode(f: &fs::File, at: &Path, mode: u32) {
1447 if mode == 0
1448 && let Ok(md) = fs::metadata(at)
1449 {
1450 let _ = f.set_permissions(md.permissions());
1451 }
1452}
1453#[cfg(not(unix))]
1454fn apply_mode(_f: &fs::File, _at: &Path, _mode: u32) {}
1455
1456fn fsync_durable(f: &fs::File, target: &Path) -> io::Result<()> {
1459 f.sync_all()?;
1460 #[cfg(unix)]
1461 if let Some(dir) = target.parent()
1462 && let Ok(d) = fs::File::open(dir)
1463 {
1464 let _ = d.sync_all();
1465 }
1466 let _ = target;
1467 Ok(())
1468}
1469
1470fn write_atomic(target: &Path, bytes: &[u8], mode: u32, durable: bool) -> io::Result<()> {
1474 use std::io::Write as _;
1475 let tmp = temp_sibling(target);
1476 let mut opts = fs::OpenOptions::new();
1477 opts.write(true).create_new(true);
1478 #[cfg(unix)]
1479 if mode != 0 {
1480 use std::os::unix::fs::OpenOptionsExt;
1481 opts.mode(mode);
1482 }
1483 let mut f = opts.open(&tmp)?;
1484 let staged = (|| {
1485 f.write_all(bytes)?;
1486 apply_mode(&f, target, mode);
1487 if durable {
1488 f.sync_all()?;
1489 }
1490 Ok(())
1491 })();
1492 drop(f);
1493 if let Err(e) = staged {
1494 let _ = fs::remove_file(&tmp);
1495 return Err(e);
1496 }
1497 if let Err(e) = fs::rename(&tmp, target) {
1498 let _ = fs::remove_file(&tmp);
1499 return Err(e);
1500 }
1501 #[cfg(unix)]
1502 if durable && let Ok(d) = fs::File::open(target.parent().unwrap_or_else(|| Path::new("."))) {
1503 let _ = d.sync_all();
1504 }
1505 Ok(())
1506}
1507
1508fn create_exclusive(target: &Path, bytes: &[u8], mode: u32, durable: bool) -> io::Result<()> {
1512 use std::io::Write as _;
1513 let mut opts = fs::OpenOptions::new();
1514 opts.write(true).create_new(true);
1515 #[cfg(unix)]
1516 if mode != 0 {
1517 use std::os::unix::fs::OpenOptionsExt;
1518 opts.mode(mode);
1519 }
1520 let mut f = opts.open(target)?;
1523 let staged = (|| {
1524 f.write_all(bytes)?;
1525 if durable {
1526 fsync_durable(&f, target)?;
1527 }
1528 Ok(())
1529 })();
1530 drop(f);
1531 if let Err(e) = staged {
1532 let _ = fs::remove_file(target);
1536 return Err(e);
1537 }
1538 Ok(())
1539}
1540
1541fn current_hash(path: &Path) -> u128 {
1547 match fs::symlink_metadata(path) {
1548 Ok(md) if md.file_type().is_symlink() => match link_target_bytes(path) {
1549 Ok(bytes) => blake3_128(&bytes),
1550 Err(_) => 0,
1551 },
1552 _ => hash_file_streamed(path).unwrap_or(0),
1558 }
1559}
1560
1561fn hash_file_streamed(path: &Path) -> io::Result<u128> {
1565 use std::io::Read as _;
1566 let mut f = fs::File::open(path)?;
1567 let mut hasher = blake3::Hasher::new();
1568 let mut buf = [0u8; 64 * 1024];
1569 loop {
1570 let n = f.read(&mut buf)?;
1571 if n == 0 {
1572 break;
1573 }
1574 hasher.update(&buf[..n]);
1575 }
1576 Ok(u128::from_le_bytes(
1577 hasher.finalize().as_bytes()[..16].try_into().unwrap(),
1578 ))
1579}
1580
1581fn link_target_bytes(path: &Path) -> io::Result<Vec<u8>> {
1584 let target = fs::read_link(path)?;
1585 #[cfg(unix)]
1586 {
1587 use std::os::unix::ffi::OsStrExt;
1588 Ok(target.as_os_str().as_bytes().to_vec())
1589 }
1590 #[cfg(not(unix))]
1591 Ok(target.to_string_lossy().into_owned().into_bytes())
1592}
1593
1594#[cfg(unix)]
1596fn symlink_at(target: &str, at: &Path) -> io::Result<()> {
1597 std::os::unix::fs::symlink(target, at)
1598}
1599#[cfg(windows)]
1600fn symlink_at(target: &str, at: &Path) -> io::Result<()> {
1601 let resolved = at.parent().unwrap_or_else(|| Path::new(".")).join(target);
1605 if resolved.is_dir() {
1606 std::os::windows::fs::symlink_dir(target, at)
1607 } else {
1608 std::os::windows::fs::symlink_file(target, at)
1609 }
1610}
1611#[cfg(not(any(unix, windows)))]
1612fn symlink_at(_target: &str, _at: &Path) -> io::Result<()> {
1613 Err(io::Error::from(io::ErrorKind::Unsupported))
1614}
1615
1616fn wire_key_for(root: &Path, abs: &Path) -> Option<String> {
1621 let rel = abs.strip_prefix(root).ok()?;
1622 let mut wire = String::new();
1623 for comp in rel.components() {
1624 wire = join_wire(&wire, &os_to_wire(comp.as_os_str()));
1625 }
1626 Some(wire)
1627}
1628
1629fn path_write_lock(path: &Path) -> Arc<Mutex<()>> {
1638 static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, std::sync::Weak<Mutex<()>>>>> =
1639 OnceLock::new();
1640 let mut map = LOCKS.get_or_init(Default::default).lock().unwrap();
1641 if let Some(existing) = map.get(path).and_then(std::sync::Weak::upgrade) {
1642 return existing;
1643 }
1644 map.retain(|_, w| w.strong_count() > 0);
1645 let lock = Arc::new(Mutex::new(()));
1646 map.insert(path.to_path_buf(), Arc::downgrade(&lock));
1647 lock
1648}
1649
1650fn create_parents_confined(root: &Path, target_parent: &Path) -> Result<(), u8> {
1656 let mut existing = target_parent.to_path_buf();
1657 let mut tail: Vec<std::ffi::OsString> = Vec::new();
1658 while !existing.exists() {
1659 let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
1660 return Err(FS_DONE_INVALID);
1661 };
1662 tail.push(name);
1663 existing = existing.parent().map(Path::to_path_buf).unwrap_or_default();
1664 if existing.as_os_str().is_empty() {
1665 return Err(FS_DONE_INVALID);
1666 }
1667 }
1668 let mut cur = fs::canonicalize(&existing).map_err(|e| write_io_status(&e))?;
1669 if !cur.starts_with(root) {
1670 return Err(FS_DONE_PERMISSION);
1671 }
1672 for name in tail.iter().rev() {
1673 cur.push(name);
1674 if let Err(e) = fs::create_dir(&cur) {
1675 let real_dir = fs::symlink_metadata(&cur)
1680 .map(|m| m.file_type().is_dir())
1681 .unwrap_or(false);
1682 if !real_dir {
1683 return Err(write_io_status(&e));
1684 }
1685 }
1686 match fs::canonicalize(&cur) {
1690 Ok(c) if c.starts_with(root) => cur = c,
1691 Ok(_) => return Err(FS_DONE_PERMISSION),
1692 Err(e) => return Err(write_io_status(&e)),
1693 }
1694 }
1695 Ok(())
1696}
1697
1698pub struct BlobStore {
1708 budget: usize,
1709 total: usize,
1710 seq: u64,
1711 by_hash: std::collections::HashMap<u128, (Arc<Vec<u8>>, u64)>,
1712 by_age: BTreeMap<u64, u128>,
1713}
1714
1715impl BlobStore {
1716 pub fn new(budget: usize) -> Self {
1717 BlobStore {
1718 budget,
1719 total: 0,
1720 seq: 0,
1721 by_hash: Default::default(),
1722 by_age: Default::default(),
1723 }
1724 }
1725
1726 pub fn get(&mut self, hash: u128) -> Option<Arc<Vec<u8>>> {
1728 let (data, seq) = self.by_hash.get(&hash)?.clone();
1729 self.by_age.remove(&seq);
1730 self.seq += 1;
1731 self.by_age.insert(self.seq, hash);
1732 self.by_hash.insert(hash, (data.clone(), self.seq));
1733 Some(data)
1734 }
1735
1736 pub fn put(&mut self, hash: u128, data: Arc<Vec<u8>>) {
1739 if data.len() > self.budget {
1740 return;
1741 }
1742 if self.by_hash.contains_key(&hash) {
1743 self.get(hash);
1744 return;
1745 }
1746 self.seq += 1;
1747 self.total += data.len();
1748 self.by_age.insert(self.seq, hash);
1749 self.by_hash.insert(hash, (data, self.seq));
1750 while self.total > self.budget {
1751 let (&seq, &oldest) = self
1752 .by_age
1753 .iter()
1754 .next()
1755 .expect("total > 0 implies entries");
1756 self.by_age.remove(&seq);
1757 if let Some((old, _)) = self.by_hash.remove(&oldest) {
1758 self.total -= old.len();
1759 }
1760 }
1761 }
1762}
1763
1764pub fn blob_store() -> &'static Mutex<BlobStore> {
1766 static STORE: OnceLock<Mutex<BlobStore>> = OnceLock::new();
1767 STORE.get_or_init(|| {
1768 Mutex::new(BlobStore::new(
1769 env_u64("BLIT_FS_BLOB_MAX", 256 * 1024 * 1024) as usize,
1770 ))
1771 })
1772}
1773
1774fn push_leb128(out: &mut Vec<u8>, mut value: u64) {
1775 loop {
1776 let byte = (value & 0x7F) as u8;
1777 value >>= 7;
1778 if value == 0 {
1779 out.push(byte);
1780 return;
1781 }
1782 out.push(byte | 0x80);
1783 }
1784}
1785
1786pub fn encode_delta(base: &[u8], new: &[u8]) -> Vec<u8> {
1792 let bound = base.len().min(new.len());
1793 let mut prefix = 0;
1794 while prefix < bound && base[prefix] == new[prefix] {
1795 prefix += 1;
1796 }
1797 let mut suffix = 0;
1798 let bound = bound - prefix;
1799 while suffix < bound && base[base.len() - 1 - suffix] == new[new.len() - 1 - suffix] {
1800 suffix += 1;
1801 }
1802 let mut ops = Vec::new();
1803 if prefix > 0 {
1804 ops.push(0x01);
1805 push_leb128(&mut ops, 0);
1806 push_leb128(&mut ops, prefix as u64);
1807 }
1808 let middle = &new[prefix..new.len() - suffix];
1809 if !middle.is_empty() {
1810 ops.push(0x02);
1811 push_leb128(&mut ops, middle.len() as u64);
1812 ops.extend_from_slice(middle);
1813 }
1814 if suffix > 0 {
1815 ops.push(0x01);
1816 push_leb128(&mut ops, (base.len() - suffix) as u64);
1817 push_leb128(&mut ops, suffix as u64);
1818 }
1819 ops
1820}
1821
1822struct Reconciler {
1830 root: PathBuf,
1831 single: bool,
1836 opts: SyncOptions,
1839 ignores: Option<ignores::Ignores>,
1843 rx: Receiver<RootMsg>,
1844 backend: Box<dyn BackendHandle>,
1845 canonical: Index,
1846 snapshot: Arc<Index>,
1848 subs: std::collections::HashMap<u64, (Sender<SyncMsg>, Duration)>,
1849 latency: Duration,
1851 dirty: std::collections::BTreeSet<String>,
1852 changed: std::collections::BTreeSet<String>,
1856 recheck: std::collections::BTreeSet<String>,
1862 full_rescan: bool,
1863 pending_since: Option<Instant>,
1864 hash_dirty_since: Option<Instant>,
1870 closed: Option<u8>,
1872 closed_flag: Arc<OnceLock<u8>>,
1874}
1875
1876const HASH_PUBLISH_INTERVAL: Duration = Duration::from_millis(500);
1878
1879fn record_merge_changed(
1882 old: &Index,
1883 new: &Index,
1884 changed: &mut std::collections::BTreeSet<String>,
1885) {
1886 let mut oi = old.iter().peekable();
1887 let mut ni = new.iter().peekable();
1888 loop {
1889 match (oi.peek(), ni.peek()) {
1890 (Some((ok, ov)), Some((nk, nv))) => {
1891 if ok == nk {
1892 if ov != nv {
1893 changed.insert((*ok).clone());
1894 }
1895 oi.next();
1896 ni.next();
1897 } else if ok < nk {
1898 changed.insert((*ok).clone());
1899 oi.next();
1900 } else {
1901 changed.insert((*nk).clone());
1902 ni.next();
1903 }
1904 }
1905 (Some((ok, _)), None) => {
1906 changed.insert((*ok).clone());
1907 oi.next();
1908 }
1909 (None, Some((nk, _))) => {
1910 changed.insert((*nk).clone());
1911 ni.next();
1912 }
1913 (None, None) => break,
1914 }
1915 }
1916}
1917
1918impl Reconciler {
1919 fn new(
1920 key: RootKey,
1921 single: bool,
1922 rx: Receiver<RootMsg>,
1923 backend: Box<dyn BackendHandle>,
1924 closed_flag: Arc<OnceLock<u8>>,
1925 ) -> Self {
1926 let opts = SyncOptions {
1927 recursive: key.recursive,
1928 cross_filesystem: key.cross_filesystem,
1929 ..Default::default()
1930 };
1931 let ignores = (!single && !key.ignores.is_empty())
1935 .then(|| ignores::Ignores::new(&key.path, &key.ignores));
1936 Reconciler {
1937 root: key.path,
1938 single,
1939 latency: opts.latency,
1940 opts,
1941 ignores,
1942 rx,
1943 backend,
1944 canonical: Index::new(),
1945 snapshot: Arc::new(Index::new()),
1946 subs: Default::default(),
1947 dirty: Default::default(),
1948 changed: Default::default(),
1949 recheck: Default::default(),
1950 full_rescan: false,
1951 pending_since: None,
1952 hash_dirty_since: None,
1953 closed: None,
1954 closed_flag,
1955 }
1956 }
1957
1958 fn run(mut self) {
1959 if let Some(ignores) = &self.ignores {
1966 for dir in ignores.external_watch_dirs() {
1967 self.backend.watch_outside(&dir);
1968 }
1969 }
1970 match self.scan_all() {
1973 Ok(index) => {
1974 self.canonical = index;
1975 self.snapshot = Arc::new(self.canonical.clone());
1976 }
1977 Err(reason) => self.close(reason),
1978 }
1979 loop {
1980 let deadline = |since: Option<Instant>, window: Duration| {
1981 since.map(|s| (s + window).saturating_duration_since(Instant::now()))
1982 };
1983 let timeout = if self.closed.is_some() {
1984 Duration::from_secs(3600)
1985 } else {
1986 [
1987 deadline(self.pending_since, self.latency),
1988 deadline(self.hash_dirty_since, HASH_PUBLISH_INTERVAL),
1989 ]
1990 .into_iter()
1991 .flatten()
1992 .min()
1993 .unwrap_or(Duration::from_secs(3600))
1994 };
1995 match self.rx.recv_timeout(timeout) {
1996 Ok(RootMsg::Hint(hint)) => self.note_hint(hint),
1997 Ok(RootMsg::Subscribe { id, tx, latency }) => {
1998 let update = match self.closed {
1999 Some(reason) => RootUpdate::Closed(reason),
2000 None => RootUpdate::Snapshot {
2003 index: self.snapshot.clone(),
2004 settled: None,
2005 changed: None,
2006 recheck: Default::default(),
2007 },
2008 };
2009 let _ = tx.send(SyncMsg::Root(update));
2010 self.subs.insert(id, (tx, latency));
2011 self.recompute_latency();
2012 }
2013 Ok(RootMsg::Unsubscribe { id }) => {
2014 self.subs.remove(&id);
2015 self.recompute_latency();
2016 }
2017 Ok(RootMsg::HashLearned { path, meta }) => {
2018 if let Some(existing) = self.canonical.get_mut(&path)
2019 && existing.hash != meta.hash
2020 && existing.node_type == meta.node_type
2021 && existing.dev_ino == meta.dev_ino
2022 && existing.size == meta.size
2023 && existing.mtime_ns == meta.mtime_ns
2024 {
2025 existing.hash = meta.hash;
2026 self.changed.insert(path);
2027 if self.hash_dirty_since.is_none() {
2028 self.hash_dirty_since = Some(Instant::now());
2029 }
2030 }
2031 }
2032 Err(RecvTimeoutError::Timeout) => {}
2033 Err(RecvTimeoutError::Disconnected) => return,
2034 }
2035 let elapsed = |since: Option<Instant>, window: Duration| {
2036 since.is_some_and(|s| Instant::now().saturating_duration_since(s) >= window)
2037 };
2038 if self.closed.is_none()
2039 && (elapsed(self.pending_since, self.latency)
2040 || elapsed(self.hash_dirty_since, HASH_PUBLISH_INTERVAL))
2041 {
2042 self.tick();
2043 }
2044 }
2045 }
2046
2047 fn ignored(&mut self, rel: &str, is_dir: bool) -> bool {
2052 match &mut self.ignores {
2053 Some(ignores) => ignores.matched(rel, is_dir),
2054 None => false,
2055 }
2056 }
2057
2058 fn ignore_rules_changed(&mut self, abs: &Path, rel: &str) -> bool {
2063 match &mut self.ignores {
2064 Some(ignores) => ignores.source_affects_rules(abs, rel),
2065 None => false,
2066 }
2067 }
2068
2069 fn retain_watched_dirs(&self) {
2072 let root = &self.root;
2073 let canonical = &self.canonical;
2074 self.backend.retain_dirs(&|abs| {
2075 wire_key_for(root, abs)
2076 .and_then(|key| canonical.get(&key).map(|m| m.node_type == FS_ENTRY_DIR))
2077 .unwrap_or(false)
2078 });
2079 }
2080
2081 fn recompute_latency(&mut self) {
2082 self.latency = self
2083 .subs
2084 .values()
2085 .map(|(_, latency)| *latency)
2086 .min()
2087 .unwrap_or(self.opts.latency);
2088 }
2089
2090 fn close(&mut self, reason: u8) {
2091 self.closed = Some(reason);
2092 let _ = self.closed_flag.set(reason);
2095 self.pending_since = None;
2096 for (tx, _) in self.subs.values() {
2097 let _ = tx.send(SyncMsg::Root(RootUpdate::Closed(reason)));
2098 }
2099 }
2100
2101 fn note_hint(&mut self, hint: Hint) {
2102 if self.single {
2103 let relevant = match hint {
2110 Hint::Rescan => true,
2111 Hint::Dirty(abs) => abs == self.root || Some(abs.as_path()) == self.root.parent(),
2112 };
2113 if relevant {
2114 self.dirty.insert(String::new());
2115 if self.pending_since.is_none() {
2116 self.pending_since = Some(Instant::now());
2117 }
2118 }
2119 return;
2120 }
2121 match hint {
2122 Hint::Rescan => self.full_rescan = true,
2123 Hint::Dirty(abs) => {
2124 let rel = match abs.strip_prefix(&self.root) {
2125 Ok(rel) => rel,
2126 Err(_) => {
2131 if self
2132 .ignores
2133 .as_ref()
2134 .is_some_and(|i| i.is_external_source(&abs))
2135 {
2136 if let Some(ignores) = &mut self.ignores {
2137 ignores.invalidate();
2138 }
2139 self.full_rescan = true;
2140 if self.pending_since.is_none() {
2141 self.pending_since = Some(Instant::now());
2142 }
2143 }
2144 return;
2145 }
2146 };
2147 let mut wire = String::new();
2148 let mut depth = 0usize;
2149 for comp in rel.components() {
2150 wire = join_wire(&wire, &os_to_wire(comp.as_os_str()));
2151 depth += 1;
2152 }
2153 if !self.opts.recursive && depth > 1 {
2156 return;
2157 }
2158 if self.ignore_rules_changed(&abs, &wire) {
2165 if let Some(ignores) = &mut self.ignores {
2166 ignores.invalidate();
2167 }
2168 self.full_rescan = true;
2169 } else if self.ignored(&wire, false) {
2170 if let Some(parent) = parent_wire(&wire)
2184 && self.canonical.get(parent).is_some_and(|m| !m.filtered)
2185 {
2186 self.dirty.insert(parent.to_string());
2187 if self.pending_since.is_none() {
2188 self.pending_since = Some(Instant::now());
2189 }
2190 }
2191 return;
2192 }
2193 self.dirty.insert(wire);
2194 }
2195 }
2196 if self.pending_since.is_none() {
2197 self.pending_since = Some(Instant::now());
2198 }
2199 }
2200
2201 fn tick(&mut self) {
2204 let settled = self.pending_since;
2208 self.pending_since = None;
2209 self.hash_dirty_since = None;
2210 if self.full_rescan {
2211 self.full_rescan = false;
2212 self.dirty.clear();
2213 match self.scan_all() {
2214 Ok(index) => {
2215 record_merge_changed(&self.canonical, &index, &mut self.changed);
2219 self.canonical = index;
2220 self.retain_watched_dirs();
2224 if let Some(ignores) = &self.ignores {
2229 for dir in ignores.external_watch_dirs() {
2230 self.backend.watch_outside(&dir);
2231 }
2232 }
2233 }
2234 Err(reason) => return self.close(reason),
2235 }
2236 } else {
2237 let dirty = std::mem::take(&mut self.dirty);
2238 for rel in dirty {
2239 if let Err(reason) = self.reconcile(&rel) {
2240 return self.close(reason);
2241 }
2242 }
2243 }
2244 let prev_snapshot = self.snapshot.clone();
2248 self.changed
2249 .retain(|k| self.canonical.get(k) != prev_snapshot.get(k));
2250 if !self.changed.is_empty() || !self.recheck.is_empty() {
2254 let changed = Arc::new(std::mem::take(&mut self.changed));
2255 let recheck = Arc::new(std::mem::take(&mut self.recheck));
2256 if !changed.is_empty() {
2257 self.snapshot = Arc::new(self.canonical.clone());
2258 }
2259 for (tx, _) in self.subs.values() {
2260 let _ = tx.send(SyncMsg::Root(RootUpdate::Snapshot {
2261 index: self.snapshot.clone(),
2262 settled,
2263 changed: Some(changed.clone()),
2264 recheck: recheck.clone(),
2265 }));
2266 }
2267 }
2268 }
2269
2270 fn scan_all(&mut self) -> Result<Index, u8> {
2271 if self.single {
2272 return self.scan_single();
2273 }
2274 let mut index = Index::new();
2275 let root = self.root.clone();
2276 self.scan_into(&mut index, &root, "", self.opts.recursive, None)
2277 .map_err(|e| match e.kind() {
2278 io::ErrorKind::NotFound => FS_CLOSED_ROOT_GONE,
2279 io::ErrorKind::PermissionDenied => FS_CLOSED_PERMISSION_LOST_COMPAT,
2280 _ if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => FS_CLOSED_RESOURCE_LIMIT,
2281 _ => FS_CLOSED_RESOURCE_LIMIT,
2282 })?;
2283 Ok(index)
2284 }
2285
2286 fn scan_single(&self) -> Result<Index, u8> {
2292 let mut index = Index::new();
2293 match stat_meta(&self.root) {
2294 Ok(meta) => {
2295 index.insert(String::new(), meta);
2296 }
2297 Err(e) => {
2298 if !self.root.parent().map(Path::exists).unwrap_or(false) {
2299 return Err(FS_CLOSED_ROOT_GONE);
2300 }
2301 if e.kind() == io::ErrorKind::PermissionDenied {
2302 return Err(FS_CLOSED_PERMISSION_LOST_COMPAT);
2303 }
2304 }
2307 }
2308 Ok(index)
2309 }
2310
2311 fn reconcile_single(&mut self) -> Result<(), u8> {
2315 match stat_meta(&self.root) {
2316 Ok(meta) => {
2317 let preserved = self
2318 .canonical
2319 .get("")
2320 .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2321 let mut meta = meta;
2322 if let Some(h) = preserved {
2323 meta.hash = h;
2324 self.note_racy("", &meta);
2325 }
2326 self.index_insert(String::new(), meta);
2327 }
2328 Err(e) => {
2329 if !self.root.parent().map(Path::exists).unwrap_or(false) {
2330 return Err(FS_CLOSED_ROOT_GONE);
2331 }
2332 if e.kind() == io::ErrorKind::PermissionDenied {
2333 return Err(FS_CLOSED_PERMISSION_LOST_COMPAT);
2334 }
2335 self.index_remove("");
2336 }
2337 }
2338 Ok(())
2339 }
2340
2341 fn scan_into(
2345 &mut self,
2346 index: &mut Index,
2347 abs: &Path,
2348 rel: &str,
2349 recurse: bool,
2350 root_dev: Option<u64>,
2351 ) -> io::Result<()> {
2352 let mut ancestors = Vec::new();
2353 self.scan_into_inner(index, abs, rel, recurse, root_dev, &mut ancestors)?;
2354 Ok(())
2355 }
2356
2357 fn scan_into_inner(
2363 &mut self,
2364 index: &mut Index,
2365 abs: &Path,
2366 rel: &str,
2367 recurse: bool,
2368 root_dev: Option<u64>,
2369 ancestors: &mut Vec<(u64, u64)>,
2370 ) -> io::Result<bool> {
2373 let meta = stat_meta(abs)?;
2374 if !rel.is_empty() && self.ignored(rel, meta.enumerable_dir()) {
2381 return Ok(true);
2382 }
2383 if index.len() >= self.opts.max_entries {
2384 return Err(io::Error::from_raw_os_error(RESOURCE_LIMIT_ERRNO));
2385 }
2386 let node_type = meta.node_type;
2387 let link_dir = meta.link_dir;
2388 let self_id = meta.dev_ino;
2389 let dev = meta.dev_ino.0;
2390 index.insert(rel.to_string(), meta);
2391
2392 let (descend_id, dev) = if node_type == FS_ENTRY_SYMLINK {
2398 if !link_dir {
2399 return Ok(false); }
2401 let Ok(target) = fs::metadata(abs) else {
2402 return Ok(false);
2403 };
2404 let id = target_identity(&target);
2405 if id == (0, 0) {
2406 return Ok(false);
2409 }
2410 if ancestors.contains(&id) {
2411 return Ok(false); }
2413 (id, id.0)
2414 } else if node_type == FS_ENTRY_DIR {
2415 (self_id, dev)
2416 } else {
2417 return Ok(false);
2418 };
2419
2420 ancestors.push(descend_id);
2421 let real_dir = node_type == FS_ENTRY_DIR;
2422 let result =
2423 self.scan_children(index, abs, rel, recurse, root_dev, dev, real_dir, ancestors);
2424 ancestors.pop();
2425 result.map(|()| false)
2426 }
2427
2428 #[allow(clippy::too_many_arguments)]
2429 fn scan_children(
2430 &mut self,
2431 index: &mut Index,
2432 abs: &Path,
2433 rel: &str,
2434 recurse: bool,
2435 root_dev: Option<u64>,
2436 dev: u64,
2437 real_dir: bool,
2443 ancestors: &mut Vec<(u64, u64)>,
2444 ) -> io::Result<()> {
2445 let root_dev = root_dev.or(Some(dev));
2446 if !self.opts.cross_filesystem && Some(dev) != root_dev {
2447 return Ok(()); }
2449 if real_dir && !self.backend.add_dir(abs) {
2454 return Err(io::Error::from_raw_os_error(RESOURCE_LIMIT_ERRNO));
2455 }
2456 if !recurse && !rel.is_empty() {
2457 return Ok(());
2458 }
2459 let entries = match fs::read_dir(abs) {
2460 Ok(e) => e,
2461 Err(_) => return Ok(()), };
2463 let mut filtered = false;
2464 for entry in entries.flatten() {
2465 let name = os_to_wire(&entry.file_name());
2466 let child_rel = join_wire(rel, &name);
2467 let child_abs = entry.path();
2468 let child_recurse = self.opts.recursive;
2470 match self.scan_into_inner(
2471 index,
2472 &child_abs,
2473 &child_rel,
2474 child_recurse,
2475 root_dev,
2476 ancestors,
2477 ) {
2478 Ok(excluded) => filtered |= excluded,
2479 Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => return Err(e),
2480 Err(_) => {}
2481 }
2482 }
2484 if filtered && let Some(dir) = index.get_mut(rel) {
2488 dir.filtered = true;
2489 }
2490 Ok(())
2491 }
2492
2493 fn note_racy(&mut self, key: &str, meta: &NodeMeta) {
2501 if matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) && racily_clean(meta.mtime_ns)
2502 {
2503 self.recheck.insert(key.to_string());
2504 }
2505 }
2506
2507 fn index_insert(&mut self, key: String, meta: NodeMeta) {
2510 if self.canonical.get(&key) != Some(&meta) {
2511 self.changed.insert(key.clone());
2512 self.canonical.insert(key, meta);
2513 }
2514 }
2515
2516 fn index_remove(&mut self, key: &str) {
2517 let Some(meta) = self.canonical.remove(key) else {
2518 return;
2519 };
2520 self.changed.insert(key.to_string());
2521 if meta.node_type == FS_ENTRY_DIR
2525 && let Some(abs) = resolve_wire_path(&self.root, key)
2526 {
2527 self.backend.remove_dir(&abs);
2528 }
2529 }
2530
2531 fn remove_index_subtree(&mut self, rel: &str, keep_root: bool) {
2534 for key in subtree_keys(&self.canonical, rel) {
2535 if keep_root && key == rel {
2536 continue;
2537 }
2538 self.index_remove(&key);
2539 }
2540 }
2541
2542 fn root_device(&self) -> Option<u64> {
2552 self.canonical.get("").map(|m| m.dev_ino.0)
2553 }
2554
2555 fn reconcile(&mut self, rel: &str) -> Result<(), u8> {
2557 if self.single {
2558 return self.reconcile_single();
2560 }
2561 let Some(abs) = resolve_wire_path(&self.root, rel) else {
2562 return Ok(());
2563 };
2564 match stat_meta(&abs) {
2565 Err(_) => {
2566 if rel.is_empty() {
2567 return Err(FS_CLOSED_ROOT_GONE);
2568 }
2569 self.remove_index_subtree(rel, false);
2570 }
2571 Ok(meta) => {
2572 if self.ignored(rel, meta.enumerable_dir()) {
2577 self.remove_index_subtree(rel, false);
2578 if let Some(parent) = parent_wire(rel)
2584 && let Some(meta) = self.canonical.get(parent)
2585 && !meta.filtered
2586 {
2587 let mut meta = meta.clone();
2588 meta.filtered = true;
2589 self.index_insert(parent.to_string(), meta);
2590 }
2591 return Ok(());
2592 }
2593 if !self.opts.cross_filesystem
2602 && !rel.is_empty()
2603 && let Some(root_dev) = self.canonical.get("").map(|m| m.dev_ino.0)
2604 && meta.dev_ino.0 != root_dev
2605 {
2606 let parent_on_root = parent_wire(rel)
2607 .and_then(|p| self.canonical.get(p))
2608 .is_some_and(|m| m.dev_ino.0 == root_dev);
2609 if parent_on_root {
2610 self.index_insert(rel.to_string(), meta);
2611 self.check_budget()?;
2612 } else {
2613 self.remove_index_subtree(rel, false);
2614 }
2615 return Ok(());
2616 }
2617 let known = self.canonical.contains_key(rel);
2618 let was_dir = self
2619 .canonical
2620 .get(rel)
2621 .map(|m| m.enumerable_dir())
2622 .unwrap_or(false);
2623 let is_dir = meta.enumerable_dir();
2624 let preserved_hash = self
2625 .canonical
2626 .get(rel)
2627 .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2628 let was_filtered = self.canonical.get(rel).is_some_and(|m| m.filtered);
2633 let mut meta = meta;
2634 meta.filtered = was_filtered;
2635 if let Some(h) = preserved_hash {
2636 meta.hash = h;
2637 self.note_racy(rel, &meta);
2638 }
2639 self.index_insert(rel.to_string(), meta);
2640 self.check_budget()?;
2641 if is_dir && (!known || !was_dir) {
2642 let mut sub = Index::new();
2647 let bound = self.root_device();
2648 match self.scan_into(&mut sub, &abs, rel, self.opts.recursive, bound) {
2649 Ok(()) => {}
2650 Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => {
2651 return Err(FS_CLOSED_RESOURCE_LIMIT);
2652 }
2653 Err(_) => {}
2655 }
2656 for (k, v) in sub {
2657 self.index_insert(k, v);
2658 }
2659 self.check_budget()?;
2660 } else if is_dir && self.opts.recursive {
2661 self.reconcile_children(&abs, rel)?;
2665 }
2666 if was_dir && !is_dir {
2667 self.remove_index_subtree(rel, true);
2668 }
2669 }
2670 }
2671 Ok(())
2672 }
2673
2674 fn check_budget(&self) -> Result<(), u8> {
2679 if self.canonical.len() > self.opts.max_entries {
2680 Err(FS_CLOSED_RESOURCE_LIMIT)
2681 } else {
2682 Ok(())
2683 }
2684 }
2685
2686 fn reconcile_children(&mut self, abs: &Path, rel: &str) -> Result<(), u8> {
2687 let Ok(entries) = fs::read_dir(abs) else {
2688 return Ok(());
2689 };
2690 let mut seen: std::collections::HashSet<String> = Default::default();
2691 let mut new_dirs: Vec<(PathBuf, String)> = Vec::new();
2692 let mut filtered = false;
2693 for entry in entries.flatten() {
2694 let name = os_to_wire(&entry.file_name());
2695 let child_rel = join_wire(rel, &name);
2696 if let Ok(meta) = stat_meta(&entry.path()) {
2697 if self.ignored(&child_rel, meta.enumerable_dir()) {
2702 filtered = true;
2703 continue;
2704 }
2705 let newly_dir = meta.enumerable_dir()
2706 && self
2707 .canonical
2708 .get(&child_rel)
2709 .map(|m| !m.enumerable_dir())
2710 .unwrap_or(true);
2711 let preserved = self
2712 .canonical
2713 .get(&child_rel)
2714 .and_then(|m| (!m.content_changed(&meta)).then_some(m.hash));
2715 let mut meta = meta;
2716 meta.filtered = self.canonical.get(&child_rel).is_some_and(|m| m.filtered);
2719 if let Some(h) = preserved {
2720 meta.hash = h;
2721 self.note_racy(&child_rel, &meta);
2722 }
2723 if newly_dir {
2724 new_dirs.push((entry.path(), child_rel.clone()));
2725 }
2726 self.index_insert(child_rel.clone(), meta);
2727 self.check_budget()?;
2728 }
2729 seen.insert(child_rel);
2730 }
2731 if let Some(dir) = self.canonical.get(rel)
2735 && dir.filtered != filtered
2736 {
2737 let mut meta = dir.clone();
2738 meta.filtered = filtered;
2739 self.index_insert(rel.to_string(), meta);
2740 }
2741 let prefix = if rel.is_empty() {
2745 String::new()
2746 } else {
2747 format!("{rel}/")
2748 };
2749 let gone: Vec<String> = self
2750 .canonical
2751 .range(prefix.clone()..)
2752 .take_while(|(k, _)| k.starts_with(&prefix))
2753 .filter(|(k, _)| {
2754 k.as_str() != rel && {
2755 let rest = &k[prefix.len()..];
2756 let child_end = prefix.len() + rest.find('/').unwrap_or(rest.len());
2757 !seen.contains(&k[..child_end])
2758 }
2759 })
2760 .map(|(k, _)| k.clone())
2761 .collect();
2762 for k in gone {
2763 self.index_remove(&k);
2764 }
2765 let bound = self.root_device();
2766 for (abs, rel) in new_dirs {
2767 let mut sub = Index::new();
2768 match self.scan_into(&mut sub, &abs, &rel, self.opts.recursive, bound) {
2769 Ok(()) => {}
2770 Err(e) if e.raw_os_error() == Some(RESOURCE_LIMIT_ERRNO) => {
2771 return Err(FS_CLOSED_RESOURCE_LIMIT);
2772 }
2773 Err(_) => {}
2774 }
2775 for (k, v) in sub {
2776 self.index_insert(k, v);
2777 }
2778 self.check_budget()?;
2779 }
2780 Ok(())
2781 }
2782}
2783
2784enum Exit {
2785 ClientGone,
2786 Closed(u8),
2787 Stopped,
2788}
2789
2790enum ContentRead {
2791 Stable { hash: u128, data: Arc<Vec<u8>> },
2792 Unstable,
2793 Unreadable,
2794}
2795
2796struct RetryEntry {
2798 failures: u32,
2800 due: Instant,
2802}
2803
2804fn retry_backoff(failures: u32, latency: Duration) -> Duration {
2810 const RETRY_BACKOFF_CAP: Duration = Duration::from_secs(2);
2811 latency
2812 .saturating_mul(
2813 1u32.checked_shl(failures.saturating_sub(1))
2814 .unwrap_or(u32::MAX),
2815 )
2816 .min(RETRY_BACKOFF_CAP)
2817}
2818
2819struct SyncEngine {
2822 sync_id: u16,
2823 root: PathBuf,
2824 single: bool,
2827 opts: SyncOptions,
2828 rx: Receiver<SyncMsg>,
2829 outbox: Outbox,
2830 shared: Arc<SharedRootHandle>,
2831 sub_id: u64,
2832 latest: Arc<Index>,
2834 snapshot_dirty: bool,
2836 shadow: Arc<Index>,
2840 pending_since: Option<Instant>,
2841 next_update_id: u32,
2842 highest_sent: u32,
2844 unacked: std::collections::VecDeque<(u32, usize)>,
2846 unacked_bytes: usize,
2847 initial_sent: bool,
2848 held: std::collections::HashMap<String, u128>,
2852 retry: BTreeMap<String, RetryEntry>,
2855 pending_changed: std::collections::BTreeSet<String>,
2858 pending_recheck: std::collections::BTreeSet<String>,
2862 full_diff: bool,
2865}
2866
2867impl SyncEngine {
2868 fn new(
2869 sync_id: u16,
2870 shared: Arc<SharedRootHandle>,
2871 sub_id: u64,
2872 opts: SyncOptions,
2873 rx: Receiver<SyncMsg>,
2874 outbox: Outbox,
2875 ) -> Self {
2876 SyncEngine {
2877 sync_id,
2878 root: shared.key.path.clone(),
2879 single: shared.single,
2880 opts,
2881 rx,
2882 outbox,
2883 shared,
2884 sub_id,
2885 latest: Arc::new(Index::new()),
2886 snapshot_dirty: false,
2887 shadow: Arc::new(Index::new()),
2888 pending_since: None,
2889 next_update_id: 1,
2890 highest_sent: 0,
2891 unacked: Default::default(),
2892 unacked_bytes: 0,
2893 initial_sent: false,
2894 held: Default::default(),
2895 retry: Default::default(),
2896 pending_changed: Default::default(),
2897 pending_recheck: Default::default(),
2898 full_diff: false,
2899 }
2900 }
2901
2902 fn run(mut self) {
2903 let exit = self.event_loop();
2904 let _ = self
2905 .shared
2906 .tx
2907 .send(RootMsg::Unsubscribe { id: self.sub_id });
2908 match exit {
2909 Exit::ClientGone => {}
2910 Exit::Stopped => {
2911 self.drain_pending_commands();
2912 let _ = (self.outbox)(msg_fs_closed(self.sync_id, FS_CLOSED_CLIENT_REQUEST));
2913 }
2914 Exit::Closed(reason) => {
2915 self.drain_pending_commands();
2916 let _ = (self.outbox)(msg_fs_closed(self.sync_id, reason));
2917 }
2918 }
2919 }
2920
2921 fn drain_pending_commands(&mut self) {
2929 while let Ok(msg) = self.rx.try_recv() {
2930 match msg {
2931 SyncMsg::Cmd(Command::Write(w)) => {
2932 let _ = (self.outbox)(msg_fs_done(w.nonce, FS_DONE_OTHER, 0, 0));
2933 }
2934 SyncMsg::Cmd(Command::Op(o)) => {
2935 let _ = (self.outbox)(msg_fs_done(o.nonce, FS_DONE_OTHER, 0, 0));
2936 }
2937 SyncMsg::Cmd(Command::Fetch { nonce, .. }) => {
2938 let _ = (self.outbox)(msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]));
2939 }
2940 SyncMsg::Cmd(Command::Ack(_) | Command::Stop) | SyncMsg::Root(_) => {}
2941 }
2942 }
2943 }
2944
2945 fn event_loop(&mut self) -> Exit {
2946 loop {
2947 let timeout = match self.pending_since {
2951 Some(since) if self.unacked_bytes < self.opts.window_bytes => {
2952 (since + self.opts.latency).saturating_duration_since(Instant::now())
2953 }
2954 _ => Duration::from_secs(3600),
2955 };
2956 match self.rx.recv_timeout(timeout) {
2957 Ok(SyncMsg::Root(update)) => {
2958 if let Err(exit) = self.handle_root(update) {
2959 return exit;
2960 }
2961 }
2962 Ok(SyncMsg::Cmd(Command::Ack(update_id))) => {
2963 if let Err(exit) = self.handle_ack(update_id) {
2964 return exit;
2965 }
2966 }
2967 Ok(SyncMsg::Cmd(Command::Fetch { nonce, path })) => {
2968 if !self.handle_fetch(nonce, &path) {
2969 return Exit::ClientGone;
2970 }
2971 }
2972 Ok(SyncMsg::Cmd(Command::Write(w))) => {
2973 if !self.handle_write(w) {
2974 return Exit::ClientGone;
2975 }
2976 }
2977 Ok(SyncMsg::Cmd(Command::Op(o))) => {
2978 if !self.handle_op(o) {
2979 return Exit::ClientGone;
2980 }
2981 }
2982 Ok(SyncMsg::Cmd(Command::Stop)) => return Exit::Stopped,
2983 Err(RecvTimeoutError::Timeout) => {}
2984 Err(RecvTimeoutError::Disconnected) => return Exit::ClientGone,
2985 }
2986 if let Some(since) = self.pending_since
2988 && Instant::now().saturating_duration_since(since) >= self.opts.latency
2989 && self.unacked_bytes < self.opts.window_bytes
2990 && let Err(exit) = self.tick()
2991 {
2992 return exit;
2993 }
2994 }
2995 }
2996
2997 fn handle_root(&mut self, update: RootUpdate) -> Result<(), Exit> {
2998 match update {
2999 RootUpdate::Snapshot {
3000 index,
3001 settled,
3002 changed,
3003 recheck,
3004 } => {
3005 self.latest = index;
3006 self.snapshot_dirty = true;
3007 self.pending_recheck.extend(recheck.iter().cloned());
3011 match changed {
3012 Some(set) if !self.full_diff => {
3015 self.pending_changed.extend(set.iter().cloned());
3016 }
3017 Some(_) => {}
3018 None => {
3019 self.full_diff = true;
3020 self.pending_changed.clear();
3021 }
3022 }
3023 let due = settled.unwrap_or_else(|| {
3028 Instant::now()
3029 .checked_sub(self.opts.latency)
3030 .unwrap_or_else(Instant::now)
3031 });
3032 self.pending_since = Some(match self.pending_since {
3033 Some(existing) if existing <= due => existing,
3034 _ => due,
3035 });
3036 Ok(())
3037 }
3038 RootUpdate::Closed(reason) => Err(Exit::Closed(reason)),
3039 }
3040 }
3041
3042 fn handle_ack(&mut self, update_id: u32) -> Result<(), Exit> {
3048 let ahead = update_id.wrapping_sub(self.highest_sent);
3049 if ahead != 0 && ahead < 0x8000_0000 {
3050 return Err(Exit::Closed(FS_CLOSED_BACKEND_FAILED_COMPAT));
3051 }
3052 while let Some(&(id, bytes)) = self.unacked.front() {
3053 if update_id.wrapping_sub(id) < 0x8000_0000 {
3055 self.unacked.pop_front();
3056 self.unacked_bytes -= bytes;
3057 } else {
3058 break;
3059 }
3060 }
3061 Ok(())
3062 }
3063
3064 fn tick(&mut self) -> Result<(), Exit> {
3065 self.pending_since = None;
3066 if self.initial_sent && !self.snapshot_dirty && self.retry.is_empty() {
3067 return Ok(());
3068 }
3069 let canonical = self.latest.clone();
3070 let initial = !self.initial_sent;
3071 self.snapshot_dirty = false;
3072 let full = std::mem::take(&mut self.full_diff);
3073 let changed = std::mem::take(&mut self.pending_changed);
3074 let recheck = std::mem::take(&mut self.pending_recheck);
3075 self.emit_updates(&canonical, initial, full, &changed, &recheck)?;
3076 self.shadow = canonical;
3077 self.initial_sent = true;
3078 if self.snapshot_dirty {
3083 self.pending_since = Some(Instant::now());
3084 } else if let Some(due) = self.retry.values().map(|e| e.due).min() {
3085 self.pending_since = Some(
3086 due.checked_sub(self.opts.latency)
3087 .unwrap_or_else(Instant::now),
3088 );
3089 }
3090 Ok(())
3091 }
3092
3093 fn emit_updates(
3099 &mut self,
3100 canonical: &Arc<Index>,
3101 initial: bool,
3102 full: bool,
3103 changed: &std::collections::BTreeSet<String>,
3104 recheck: &std::collections::BTreeSet<String>,
3105 ) -> Result<(), Exit> {
3106 if initial {
3107 return self.emit_initial(canonical);
3110 }
3111 let mut ops = if full {
3112 diff(&self.shadow, canonical)
3113 } else {
3114 diff_changed(&self.shadow, canonical, changed)
3115 };
3116 for op in &ops {
3120 if let DiffOp::Move { from, to } = op {
3121 self.rekey_move(from, to);
3122 }
3123 }
3124 self.retry.retain(|path, _| canonical.contains_key(path));
3125 let now = Instant::now();
3130 let forced: Vec<String> = self
3131 .retry
3132 .iter()
3133 .filter(|(path, entry)| {
3134 entry.due <= now
3135 && !ops
3136 .iter()
3137 .any(|op| matches!(op, DiffOp::Upsert { path: p, .. } if p == *path))
3138 })
3139 .map(|(path, _)| path.clone())
3140 .collect();
3141 ops.extend(forced.into_iter().map(|path| DiffOp::Upsert {
3142 path,
3143 content_changed: true,
3144 }));
3145 let racy: Vec<String> = recheck
3150 .iter()
3151 .filter(|path| {
3152 !ops.iter()
3153 .any(|op| matches!(op, DiffOp::Upsert { path: p, .. } if p == *path))
3154 && self.content_diverged(path, canonical)
3155 })
3156 .cloned()
3157 .collect();
3158 ops.extend(racy.into_iter().map(|path| DiffOp::Upsert {
3159 path,
3160 content_changed: true,
3161 }));
3162 if ops.is_empty() {
3163 return Ok(());
3164 }
3165
3166 let mut buf: Vec<u8> = Vec::new();
3167 let mut reset_pending = false;
3168 for op in &ops {
3169 match op {
3170 DiffOp::Delete { path } => {
3171 self.held.retain(|held_path, _| !is_under(held_path, path));
3172 append_fs_record(&mut buf, &FsRecord::Delete { path });
3173 }
3174 DiffOp::Move { from, to } => {
3175 append_fs_record(&mut buf, &FsRecord::Move { from, to });
3177 }
3178 DiffOp::Upsert {
3179 path,
3180 content_changed,
3181 } => {
3182 if let Some(meta) = canonical.get(path) {
3183 self.append_upsert(&mut buf, path, meta, *content_changed);
3184 }
3185 }
3186 }
3187 if buf.len() >= self.opts.batch_target {
3188 self.send_update(std::mem::take(&mut buf), &mut reset_pending, false)?;
3189 }
3190 }
3191 if !buf.is_empty() {
3192 self.send_update(buf, &mut reset_pending, false)?;
3193 }
3194 Ok(())
3195 }
3196
3197 fn emit_initial(&mut self, canonical: &Arc<Index>) -> Result<(), Exit> {
3203 let mut buf: Vec<u8> = Vec::new();
3204 let mut reset_pending = true;
3205 let index: &Index = canonical;
3206 for (path, meta) in index.iter() {
3207 self.append_upsert(&mut buf, path, meta, true);
3208 if buf.len() >= self.opts.batch_target {
3209 self.send_update(std::mem::take(&mut buf), &mut reset_pending, false)?;
3210 }
3211 }
3212 self.send_update(buf, &mut reset_pending, true)?;
3213 Ok(())
3214 }
3215
3216 fn append_upsert(
3220 &mut self,
3221 buf: &mut Vec<u8>,
3222 path: &str,
3223 meta: &NodeMeta,
3224 content_changed: bool,
3225 ) {
3226 let prior_failures = self.retry.remove(path).map(|e| e.failures).unwrap_or(0);
3227 let was_retry = prior_failures > 0;
3228 let mut entry_flags = meta.node_type & FS_ENTRY_TYPE_MASK;
3229 if meta.link_dir {
3230 entry_flags |= FS_ENTRY_LINK_DIR;
3231 }
3232 if meta.filtered {
3233 entry_flags |= FS_ENTRY_FILTERED;
3234 }
3235 let mut hash = meta.hash;
3236 let mut full: Option<Arc<Vec<u8>>> = None;
3237 let mut delta: Option<Vec<u8>> = None;
3238 if matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) {
3241 let inline_cap = self
3247 .opts
3248 .inline_max
3249 .min(blit_remote::fs::FS_MAX_DECOMPRESSED as u64);
3250 if !self.opts.content || meta.size > inline_cap {
3251 entry_flags |= FS_ENTRY_NO_CONTENT;
3252 self.held.remove(path);
3253 } else if content_changed || meta.hash == 0 {
3254 match self.read_content(path, meta) {
3255 ContentRead::Stable {
3256 hash: read_hash,
3257 data,
3258 } => {
3259 hash = read_hash;
3260 if self.held.get(path) == Some(&hash) {
3261 } else {
3266 delta = self
3271 .held
3272 .get(path)
3273 .and_then(|&base_hash| blob_store().lock().unwrap().get(base_hash))
3274 .map(|base| encode_delta(&base, &data))
3275 .filter(|ops| ops.len() * 8 < data.len() * 7);
3276 if delta.is_none() {
3277 full = Some(data.clone());
3278 }
3279 self.held.insert(path.to_string(), hash);
3280 }
3281 }
3282 ContentRead::Unstable => {
3283 self.held.remove(path);
3284 self.note_retry(path, prior_failures);
3285 if was_retry {
3286 return;
3289 }
3290 entry_flags |= FS_ENTRY_UNSTABLE;
3291 }
3292 ContentRead::Unreadable => {
3293 self.held.remove(path);
3300 self.note_retry(path, prior_failures);
3301 if was_retry {
3302 return;
3303 }
3304 entry_flags |= FS_ENTRY_UNREADABLE;
3305 }
3306 }
3307 }
3308 }
3312 let content = match (&delta, &full) {
3313 (Some(ops), _) => FsContent::Delta(ops),
3314 (None, Some(data)) => FsContent::Full(data.as_slice()),
3315 (None, None) => FsContent::None,
3316 };
3317 append_fs_record(
3318 buf,
3319 &FsRecord::Upsert {
3320 path,
3321 entry_flags,
3322 size: meta.size,
3323 mtime_ns: meta.mtime_ns,
3324 mode: meta.mode,
3325 hash,
3326 content,
3327 },
3328 );
3329 }
3330
3331 fn note_retry(&mut self, path: &str, prior_failures: u32) {
3334 let failures = prior_failures + 1;
3335 self.retry.insert(
3336 path.to_string(),
3337 RetryEntry {
3338 failures,
3339 due: Instant::now() + retry_backoff(failures, self.opts.latency),
3340 },
3341 );
3342 }
3343
3344 fn content_diverged(&self, path: &str, canonical: &Index) -> bool {
3355 if !self.opts.content {
3356 return false;
3357 }
3358 let Some(&held) = self.held.get(path) else {
3359 return false;
3360 };
3361 let Some(meta) = canonical.get(path) else {
3362 return false;
3363 };
3364 if !matches!(meta.node_type, FS_ENTRY_FILE | FS_ENTRY_SYMLINK) {
3365 return false;
3366 }
3367 let Some(abs) = resolve_wire_path(&self.root, path) else {
3368 return false;
3369 };
3370 match read_verified_meta(&abs) {
3371 ReadMetaOutcome::Stable(data, _) => blake3_128(&data) != held,
3372 ReadMetaOutcome::Unstable | ReadMetaOutcome::Unreadable => false,
3375 }
3376 }
3377
3378 fn read_content(&self, path: &str, meta: &NodeMeta) -> ContentRead {
3383 if meta.hash != 0
3384 && let Some(data) = blob_store().lock().unwrap().get(meta.hash)
3385 {
3386 return ContentRead::Stable {
3387 hash: meta.hash,
3388 data,
3389 };
3390 }
3391 if meta.hash == 0
3397 && let Some(learned) = self.shared.learned.lock().unwrap().get(path).cloned()
3398 && learned.hash != 0
3399 && learned.node_type == meta.node_type
3400 && learned.dev_ino == meta.dev_ino
3401 && learned.size == meta.size
3402 && learned.mtime_ns == meta.mtime_ns
3403 && let Some(data) = blob_store().lock().unwrap().get(learned.hash)
3404 {
3405 return ContentRead::Stable {
3406 hash: learned.hash,
3407 data,
3408 };
3409 }
3410 let Some(abs) = resolve_wire_path(&self.root, path) else {
3411 return ContentRead::Unreadable;
3412 };
3413 match read_verified_meta(&abs) {
3414 ReadMetaOutcome::Stable(data, mut stat) => {
3415 let hash = blake3_128(&data);
3416 let data = Arc::new(data);
3417 blob_store().lock().unwrap().put(hash, data.clone());
3418 stat.hash = hash;
3419 if !racily_clean(stat.mtime_ns) {
3426 self.teach_hash(path, stat);
3427 }
3428 ContentRead::Stable { hash, data }
3429 }
3430 ReadMetaOutcome::Unstable => ContentRead::Unstable,
3431 ReadMetaOutcome::Unreadable => ContentRead::Unreadable,
3432 }
3433 }
3434
3435 fn teach_hash(&self, path: &str, meta: NodeMeta) {
3438 {
3439 let mut learned = self.shared.learned.lock().unwrap();
3440 if learned.len() >= 65536 {
3443 learned.clear();
3444 }
3445 learned.insert(path.to_string(), meta.clone());
3446 }
3447 let _ = self.shared.tx.send(RootMsg::HashLearned {
3448 path: path.to_string(),
3449 meta,
3450 });
3451 }
3452
3453 fn rekey_move(&mut self, from: &str, to: &str) {
3459 let moved: Vec<(String, u128)> = self
3460 .held
3461 .iter()
3462 .filter(|(path, _)| is_under(path, from))
3463 .map(|(path, &hash)| (path.clone(), hash))
3464 .collect();
3465 for (path, _) in &moved {
3466 self.held.remove(path);
3467 }
3468 for (path, hash) in moved {
3469 self.held.insert(rebase_subtree_path(&path, from, to), hash);
3470 }
3471 for path in subtree_keys(&self.retry, from) {
3472 if let Some(entry) = self.retry.remove(&path) {
3473 self.retry
3474 .insert(rebase_subtree_path(&path, from, to), entry);
3475 }
3476 }
3477 }
3478
3479 fn send_update(
3481 &mut self,
3482 records: Vec<u8>,
3483 reset_pending: &mut bool,
3484 sync: bool,
3485 ) -> Result<(), Exit> {
3486 self.wait_for_credit()?;
3487 let mut flags = 0u8;
3488 if *reset_pending {
3489 flags |= FS_UPDATE_RESET;
3490 *reset_pending = false;
3491 }
3492 if sync {
3493 flags |= FS_UPDATE_SYNC;
3494 }
3495 let update_id = self.next_update_id;
3496 self.next_update_id = self.next_update_id.wrapping_add(1);
3497 self.highest_sent = update_id;
3498 let msg = msg_fs_update(self.sync_id, update_id, flags, &records);
3499 self.unacked.push_back((update_id, msg.len()));
3500 self.unacked_bytes += msg.len();
3501 if !(self.outbox)(msg) {
3502 return Err(Exit::ClientGone);
3503 }
3504 Ok(())
3505 }
3506
3507 fn wait_for_credit(&mut self) -> Result<(), Exit> {
3510 while self.unacked_bytes >= self.opts.window_bytes {
3511 match self.rx.recv() {
3512 Ok(SyncMsg::Cmd(Command::Ack(id))) => self.handle_ack(id)?,
3513 Ok(SyncMsg::Cmd(Command::Fetch { nonce, path })) => {
3514 if !self.handle_fetch(nonce, &path) {
3515 return Err(Exit::ClientGone);
3516 }
3517 }
3518 Ok(SyncMsg::Cmd(Command::Write(w))) => {
3519 if !self.handle_write(w) {
3520 return Err(Exit::ClientGone);
3521 }
3522 }
3523 Ok(SyncMsg::Cmd(Command::Op(o))) => {
3524 if !self.handle_op(o) {
3525 return Err(Exit::ClientGone);
3526 }
3527 }
3528 Ok(SyncMsg::Cmd(Command::Stop)) => return Err(Exit::Stopped),
3529 Ok(SyncMsg::Root(update)) => self.handle_root(update)?,
3530 Err(_) => return Err(Exit::ClientGone),
3531 }
3532 }
3533 Ok(())
3534 }
3535
3536 fn handle_fetch(&mut self, nonce: u16, wire_path: &str) -> bool {
3537 if self.single {
3538 let msg = if wire_path.is_empty() {
3543 let root = self.root.clone();
3544 self.fetch_confined(nonce, &root)
3545 } else {
3546 msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3547 };
3548 return (self.outbox)(msg);
3549 }
3550 let msg = match confine_target(&self.root, wire_path) {
3556 Err(ConfineError::Invalid) => msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[]),
3557 Err(ConfineError::Io(e)) if e.kind() == io::ErrorKind::NotFound => {
3558 msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3559 }
3560 Err(ConfineError::Io(_)) => msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3561 Err(ConfineError::Escapes) => msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]),
3562 Ok(abs) => self.fetch_confined(nonce, &abs),
3563 };
3564 (self.outbox)(msg)
3565 }
3566
3567 fn fetch_confined(&self, nonce: u16, abs: &Path) -> Vec<u8> {
3573 let md = match fs::symlink_metadata(abs) {
3574 Ok(md) => md,
3575 Err(e) if e.kind() == io::ErrorKind::NotFound => {
3576 return msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[]);
3577 }
3578 Err(_) => return msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3579 };
3580 let ft = md.file_type();
3581 if !ft.is_file() && !ft.is_symlink() {
3582 return msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]);
3583 }
3584 if ft.is_file() && md.len() > blit_remote::fs::FS_MAX_DECOMPRESSED as u64 {
3589 return msg_fs_file(nonce, blit_remote::fs::FS_FILE_OTHER, &[]);
3590 }
3591 match read_verified(abs) {
3592 ReadOutcome::Stable(data) => msg_fs_file(nonce, FS_FILE_OK, &data),
3593 ReadOutcome::Unstable => msg_fs_file(nonce, FS_FILE_UNREADABLE, &[]),
3594 ReadOutcome::Unreadable => {
3595 if abs.exists() {
3596 msg_fs_file(nonce, FS_FILE_UNREADABLE, &[])
3597 } else {
3598 msg_fs_file(nonce, FS_FILE_NOT_FOUND, &[])
3599 }
3600 }
3601 }
3602 }
3603
3604 fn resolve_target(&self, wire: &str, policy: SymlinkPolicy) -> Result<PathBuf, u8> {
3613 if !self.single {
3614 return resolve_write_target(&self.root, wire, policy);
3615 }
3616 if !wire.is_empty() {
3617 return Err(FS_DONE_INVALID);
3618 }
3619 match fs::symlink_metadata(&self.root) {
3620 Ok(md) if md.file_type().is_symlink() => match policy {
3621 SymlinkPolicy::Refuse => Err(FS_DONE_PERMISSION),
3622 SymlinkPolicy::Operate => Ok(self.root.clone()),
3623 SymlinkPolicy::Follow => {
3624 let resolved = fs::canonicalize(&self.root).map_err(|e| write_io_status(&e))?;
3625 if resolved == self.root {
3626 Ok(resolved)
3627 } else {
3628 Err(FS_DONE_PERMISSION)
3629 }
3630 }
3631 },
3632 _ => Ok(self.root.clone()),
3633 }
3634 }
3635
3636 fn handle_write(&mut self, w: WriteReq) -> bool {
3637 let (status, hash, mtime_ns) = self.exec_write(&w);
3638 (self.outbox)(msg_fs_done(w.nonce, status, hash, mtime_ns))
3639 }
3640
3641 fn exec_write(&mut self, w: &WriteReq) -> (u8, u128, u64) {
3647 use blit_remote::fs::{FS_WRITE_CONTENT_DELTA, FS_WRITE_CONTENT_FULL, apply_fs_delta};
3648 let is_delta = w.content_kind == FS_WRITE_CONTENT_DELTA;
3649 if !is_delta && w.content_kind != 0 && w.content_kind != FS_WRITE_CONTENT_FULL {
3652 return (FS_DONE_INVALID, 0, 0);
3653 }
3654 let no_cas = w.flags & FS_WRITE_NO_CAS != 0;
3655 if is_delta && (no_cas || w.base == 0) {
3659 return (FS_DONE_INVALID, 0, 0);
3660 }
3661 if w.content.len() as u64 > fs_write_max() {
3662 return (FS_DONE_TOO_LARGE, 0, 0);
3663 }
3664 if !self.single
3667 && w.flags & FS_WRITE_MKPARENTS != 0
3668 && let Some(parent) = resolve_wire_path(&self.root, &w.path)
3669 .and_then(|a| a.parent().map(Path::to_path_buf))
3670 && let Err(status) = create_parents_confined(&self.root, &parent)
3671 {
3672 return (status, 0, 0);
3673 }
3674 let policy = if w.flags & FS_WRITE_FOLLOW_SYMLINK != 0 {
3675 SymlinkPolicy::Follow
3676 } else {
3677 SymlinkPolicy::Refuse
3678 };
3679 let target = match self.resolve_target(&w.path, policy) {
3680 Ok(t) => t,
3681 Err(status) => return (status, 0, 0),
3682 };
3683 let durable = w.flags & FS_WRITE_DURABLE != 0;
3684
3685 let lock = path_write_lock(&target);
3690 let _guard = lock.lock().unwrap();
3691
3692 if fs::symlink_metadata(&target)
3694 .map(|m| m.is_dir())
3695 .unwrap_or(false)
3696 {
3697 return (FS_DONE_WRONG_TYPE, 0, 0);
3698 }
3699
3700 let create_exclusive_mode = !no_cas && w.base == 0;
3701 let applied: Option<Vec<u8>> = if is_delta {
3708 match fs::symlink_metadata(&target) {
3713 Ok(md) if md.len() > fs_write_max() => return (FS_DONE_TOO_LARGE, 0, 0),
3714 Err(e) if e.kind() == io::ErrorKind::NotFound => {
3715 return (FS_DONE_CONFLICT, 0, 0);
3718 }
3719 _ => {}
3720 }
3721 let base = match read_verified_meta(&target) {
3722 ReadMetaOutcome::Stable(data, _) => data,
3723 ReadMetaOutcome::Unstable => return (FS_DONE_OTHER, 0, 0),
3727 ReadMetaOutcome::Unreadable => {
3728 return if target.exists() {
3729 (FS_DONE_OTHER, 0, 0)
3730 } else {
3731 (FS_DONE_CONFLICT, 0, 0)
3732 };
3733 }
3734 };
3735 let cur = blake3_128(&base);
3736 if cur != w.base {
3737 return (FS_DONE_CONFLICT, cur, 0);
3738 }
3739 let Some(applied) = apply_fs_delta(&base, &w.content) else {
3740 return (FS_DONE_INVALID, 0, 0);
3742 };
3743 if applied.len() as u64 > fs_write_max() {
3744 return (FS_DONE_TOO_LARGE, 0, 0);
3745 }
3746 Some(applied)
3747 } else {
3748 if !no_cas {
3749 if w.base == 0 {
3750 if target.exists() {
3751 return (FS_DONE_CONFLICT, current_hash(&target), 0);
3752 }
3753 } else {
3754 let cur = current_hash(&target);
3755 if cur != w.base {
3756 return (FS_DONE_CONFLICT, cur, 0);
3757 }
3758 }
3759 }
3760 None
3761 };
3762 let content: &[u8] = applied.as_deref().unwrap_or(&w.content);
3763
3764 let hash = blake3_128(content);
3765 if create_exclusive_mode {
3766 match create_exclusive(&target, content, w.mode, durable) {
3767 Ok(()) => {}
3768 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3769 return (FS_DONE_CONFLICT, current_hash(&target), 0);
3770 }
3771 Err(e) => return (write_io_status(&e), 0, 0),
3772 }
3773 } else if let Err(e) = write_atomic(&target, content, w.mode, durable) {
3774 return (write_io_status(&e), 0, 0);
3775 }
3776
3777 let mtime_ns = stat_meta(&target).map(|m| m.mtime_ns).unwrap_or(0);
3778 let echo_wire = wire_key_for(&self.root, &target).unwrap_or_else(|| w.path.clone());
3782 self.prime_echo(&echo_wire, &target, hash, content, mtime_ns);
3783 (FS_DONE_OK, hash, mtime_ns)
3784 }
3785
3786 fn handle_op(&mut self, o: OpReq) -> bool {
3787 let (status, hash, mtime_ns) = self.exec_op(&o);
3788 (self.outbox)(msg_fs_done(o.nonce, status, hash, mtime_ns))
3789 }
3790
3791 fn exec_op(&mut self, o: &OpReq) -> (u8, u128, u64) {
3794 match o.op {
3795 FS_OP_MKDIR => {
3796 if !self.single
3797 && o.flags & FS_OP_MKPARENTS != 0
3798 && let Some(parent) = resolve_wire_path(&self.root, &o.a)
3799 .and_then(|a| a.parent().map(Path::to_path_buf))
3800 && let Err(status) = create_parents_confined(&self.root, &parent)
3801 {
3802 return (status, 0, 0);
3803 }
3804 let target = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3805 Ok(t) => t,
3806 Err(status) => return (status, 0, 0),
3807 };
3808 let lock = path_write_lock(&target);
3809 let _guard = lock.lock().unwrap();
3810 let mut builder = fs::DirBuilder::new();
3811 #[cfg(unix)]
3812 if o.mode != 0 {
3813 use std::os::unix::fs::DirBuilderExt;
3814 builder.mode(o.mode);
3815 }
3816 match builder.create(&target) {
3817 Ok(()) => {}
3818 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3820 if !target.is_dir() {
3821 return (FS_DONE_CONFLICT, 0, 0);
3822 }
3823 }
3824 Err(e) => return (write_io_status(&e), 0, 0),
3825 }
3826 let mtime_ns = stat_meta(&target).map(|m| m.mtime_ns).unwrap_or(0);
3827 self.hint_change(&target);
3828 (FS_DONE_OK, 0, mtime_ns)
3829 }
3830 FS_OP_REMOVE => {
3831 let target = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3832 Ok(t) => t,
3833 Err(status) => return (status, 0, 0),
3834 };
3835 let lock = path_write_lock(&target);
3836 let _guard = lock.lock().unwrap();
3837 let md = match fs::symlink_metadata(&target) {
3838 Ok(m) => m,
3839 Err(e) if e.kind() == io::ErrorKind::NotFound => {
3840 return (FS_DONE_NOT_FOUND, 0, 0);
3841 }
3842 Err(e) => return (write_io_status(&e), 0, 0),
3843 };
3844 if o.flags & FS_OP_NO_CAS == 0 && o.base != 0 {
3846 let cur = current_hash(&target);
3847 if cur != o.base {
3848 return (FS_DONE_CONFLICT, cur, 0);
3849 }
3850 }
3851 let res = if md.file_type().is_dir() {
3852 fs::remove_dir_all(&target)
3853 } else {
3854 fs::remove_file(&target)
3856 };
3857 if let Err(e) = res {
3858 return (write_io_status(&e), 0, 0);
3859 }
3860 self.hint_change(&target);
3861 (FS_DONE_OK, 0, 0)
3862 }
3863 FS_OP_RENAME => {
3864 if !self.single
3865 && o.flags & FS_OP_MKPARENTS != 0
3866 && let Some(parent) = resolve_wire_path(&self.root, &o.b)
3867 .and_then(|a| a.parent().map(Path::to_path_buf))
3868 && let Err(status) = create_parents_confined(&self.root, &parent)
3869 {
3870 return (status, 0, 0);
3871 }
3872 let from = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3873 Ok(t) => t,
3874 Err(status) => return (status, 0, 0),
3875 };
3876 let lock = path_write_lock(&from);
3877 let _guard = lock.lock().unwrap();
3878 if fs::symlink_metadata(&from).is_err() {
3879 return (FS_DONE_NOT_FOUND, 0, 0);
3880 }
3881 let to = match self.resolve_target(&o.b, SymlinkPolicy::Operate) {
3882 Ok(t) => t,
3883 Err(status) => return (status, 0, 0),
3884 };
3885 if let Err(e) = fs::rename(&from, &to) {
3886 return (write_io_status(&e), 0, 0);
3887 }
3888 self.hint_change(&from);
3889 self.hint_change(&to);
3890 (FS_DONE_OK, 0, 0)
3891 }
3892 FS_OP_SYMLINK | FS_OP_HARDLINK => self.exec_link(o),
3893 _ => (FS_DONE_INVALID, 0, 0),
3894 }
3895 }
3896
3897 fn exec_link(&mut self, o: &OpReq) -> (u8, u128, u64) {
3906 if !self.single
3907 && o.flags & FS_OP_MKPARENTS != 0
3908 && let Some(parent) =
3909 resolve_wire_path(&self.root, &o.b).and_then(|b| b.parent().map(Path::to_path_buf))
3910 && let Err(status) = create_parents_confined(&self.root, &parent)
3911 {
3912 return (status, 0, 0);
3913 }
3914 let src = if o.op == FS_OP_HARDLINK {
3920 let src = match self.resolve_target(&o.a, SymlinkPolicy::Operate) {
3921 Ok(t) => t,
3922 Err(status) => return (status, 0, 0),
3923 };
3924 match fs::symlink_metadata(&src) {
3925 Ok(md) if md.file_type().is_file() => {}
3926 Ok(_) => return (FS_DONE_WRONG_TYPE, 0, 0),
3927 Err(e) if e.kind() == io::ErrorKind::NotFound => {
3928 return (FS_DONE_NOT_FOUND, 0, 0);
3929 }
3930 Err(e) => return (write_io_status(&e), 0, 0),
3931 }
3932 Some(src)
3933 } else {
3934 if o.a.is_empty() {
3935 return (FS_DONE_INVALID, 0, 0);
3936 }
3937 None
3938 };
3939 let link = match self.resolve_target(&o.b, SymlinkPolicy::Operate) {
3940 Ok(t) => t,
3941 Err(status) => return (status, 0, 0),
3942 };
3943 let lock = path_write_lock(&link);
3944 let _guard = lock.lock().unwrap();
3945 if fs::symlink_metadata(&link)
3948 .map(|m| m.is_dir())
3949 .unwrap_or(false)
3950 {
3951 return (FS_DONE_WRONG_TYPE, 0, 0);
3952 }
3953 let no_cas = o.flags & FS_OP_NO_CAS != 0;
3954 let create_exclusive_mode = !no_cas && o.base == 0;
3955 if !no_cas {
3956 if o.base == 0 {
3957 if fs::symlink_metadata(&link).is_ok() {
3960 return (FS_DONE_CONFLICT, current_hash(&link), 0);
3961 }
3962 } else {
3963 let cur = current_hash(&link);
3964 if cur != o.base {
3965 return (FS_DONE_CONFLICT, cur, 0);
3966 }
3967 }
3968 }
3969 let create = |at: &Path| -> io::Result<()> {
3970 match &src {
3971 Some(src) => fs::hard_link(src, at),
3972 None => symlink_at(&o.a, at),
3973 }
3974 };
3975 if create_exclusive_mode {
3976 match create(&link) {
3979 Ok(()) => {}
3980 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
3981 return (FS_DONE_CONFLICT, current_hash(&link), 0);
3982 }
3983 Err(e) => return (write_io_status(&e), 0, 0),
3984 }
3985 } else {
3986 let tmp = temp_sibling(&link);
3987 if let Err(e) = create(&tmp) {
3988 return (write_io_status(&e), 0, 0);
3989 }
3990 if let Err(e) = fs::rename(&tmp, &link) {
3991 let _ = fs::remove_file(&tmp);
3992 return (write_io_status(&e), 0, 0);
3993 }
3994 }
3995 let mtime_ns = stat_meta(&link).map(|m| m.mtime_ns).unwrap_or(0);
3996 let echo_wire = wire_key_for(&self.root, &link).unwrap_or_else(|| o.b.clone());
3997 match &src {
3998 None => {
3999 let hash = blake3_128(o.a.as_bytes());
4000 self.prime_echo(&echo_wire, &link, hash, o.a.as_bytes(), mtime_ns);
4001 (FS_DONE_OK, hash, mtime_ns)
4002 }
4003 Some(src) => {
4004 let small = fs::symlink_metadata(src)
4008 .map(|m| m.len() <= fs_write_max())
4009 .unwrap_or(false);
4010 match if small {
4011 read_verified(&link)
4012 } else {
4013 ReadOutcome::Unstable
4014 } {
4015 ReadOutcome::Stable(data) => {
4016 let hash = blake3_128(&data);
4017 self.prime_echo(&echo_wire, &link, hash, &data, mtime_ns);
4018 (FS_DONE_OK, hash, mtime_ns)
4019 }
4020 _ => {
4021 self.hint_change(&link);
4022 (FS_DONE_OK, 0, mtime_ns)
4023 }
4024 }
4025 }
4026 }
4027 }
4028
4029 fn prime_echo(&mut self, wire: &str, abs: &Path, hash: u128, bytes: &[u8], mtime_ns: u64) {
4034 blob_store()
4035 .lock()
4036 .unwrap()
4037 .put(hash, Arc::new(bytes.to_vec()));
4038 self.held.insert(wire.to_string(), hash);
4039 if !racily_clean(mtime_ns)
4040 && let Ok(mut meta) = stat_meta(abs)
4041 {
4042 meta.hash = hash;
4043 self.teach_hash(wire, meta);
4044 }
4045 self.hint_change(abs);
4046 }
4047
4048 fn hint_change(&self, abs: &Path) {
4052 let _ = self
4053 .shared
4054 .tx
4055 .send(RootMsg::Hint(Hint::Dirty(abs.to_path_buf())));
4056 if let Some(parent) = abs.parent() {
4057 let _ = self
4058 .shared
4059 .tx
4060 .send(RootMsg::Hint(Hint::Dirty(parent.to_path_buf())));
4061 }
4062 }
4063}
4064
4065const FS_CLOSED_BACKEND_FAILED_COMPAT: u8 = blit_remote::fs::FS_CLOSED_BACKEND_FAILED;
4067const FS_CLOSED_PERMISSION_LOST_COMPAT: u8 = blit_remote::fs::FS_CLOSED_PERMISSION_LOST;
4068const RESOURCE_LIMIT_ERRNO: i32 = libc_enfile();
4070
4071const fn libc_enfile() -> i32 {
4072 23 }
4074
4075#[cfg(test)]
4076mod tests {
4077 use super::*;
4078 use blit_remote::fs::FsMirror;
4079 use std::sync::atomic::{AtomicU64, Ordering};
4080 use std::sync::{Arc, Mutex};
4081
4082 static TEST_DIR_SEQ: AtomicU64 = AtomicU64::new(0);
4083
4084 fn temp_dir() -> PathBuf {
4085 let dir = std::env::temp_dir().join(format!(
4086 "blit-fssync-test-{}-{}",
4087 std::process::id(),
4088 TEST_DIR_SEQ.fetch_add(1, Ordering::Relaxed)
4089 ));
4090 fs::create_dir_all(&dir).unwrap();
4091 dir
4092 }
4093
4094 fn test_key(root: &Path) -> RootKey {
4095 RootKey {
4096 path: root.to_path_buf(),
4097 recursive: true,
4098 cross_filesystem: false,
4099 ignores: IgnoreSpec::default(),
4100 }
4101 }
4102
4103 fn test_key_ignoring(root: &Path, ignores: IgnoreSpec) -> RootKey {
4106 RootKey {
4107 ignores,
4108 ..test_key(root)
4109 }
4110 }
4111
4112 #[test]
4113 fn escape_roundtrip() {
4114 assert_eq!(escape_bytes(b"plain.txt"), "plain.txt");
4115 assert_eq!(escape_bytes(b"50%.txt"), "50%25.txt");
4116 let bad = b"a\xFFb";
4117 let escaped = escape_bytes(bad);
4118 assert_eq!(escaped, "a%FFb");
4119 assert_eq!(unescape_to_bytes(&escaped).unwrap(), bad.to_vec());
4120 assert_eq!(unescape_to_bytes("50%25.txt").unwrap(), b"50%.txt".to_vec());
4121 }
4122
4123 #[test]
4124 fn wide_escape_roundtrip() {
4125 let plain: Vec<u16> = "file.txt".encode_utf16().collect();
4127 assert_eq!(escape_wide(&plain), "file.txt");
4128 assert_eq!(unescape_to_wide("file.txt").unwrap(), plain);
4129 let percent: Vec<u16> = "50%u.txt".encode_utf16().collect();
4131 assert_eq!(escape_wide(&percent), "50%25u.txt");
4132 assert_eq!(unescape_to_wide("50%25u.txt").unwrap(), percent);
4133 let clef: Vec<u16> = "𝄞.txt".encode_utf16().collect();
4135 assert_eq!(escape_wide(&clef), "𝄞.txt");
4136 assert_eq!(unescape_to_wide("𝄞.txt").unwrap(), clef);
4137 let bad = [0xD800u16, 0x0041, 0xDFFF];
4139 let escaped = escape_wide(&bad);
4140 assert_eq!(escaped, "%uD800A%uDFFF");
4141 assert_eq!(unescape_to_wide(&escaped).unwrap(), bad.to_vec());
4142 assert!(unescape_to_wide("%u12").is_none());
4144 assert!(unescape_to_wide("%uZZZZ").is_none());
4145 }
4146
4147 #[test]
4148 fn wire_path_traversal_rejected() {
4149 let root = Path::new("/tmp/root");
4150 assert!(resolve_wire_path(root, "a/../b").is_none());
4151 assert!(resolve_wire_path(root, "..").is_none());
4152 assert!(resolve_wire_path(root, "a//b").is_none());
4153 assert_eq!(resolve_wire_path(root, ""), Some(root.to_path_buf()));
4154 assert_eq!(
4155 resolve_wire_path(root, "a/b"),
4156 Some(root.join("a").join("b"))
4157 );
4158 }
4159
4160 #[test]
4168 fn encoded_traversal_rejected() {
4169 let root = Path::new("/tmp/root");
4170 assert!(resolve_wire_path(root, "%2E%2E").is_none());
4172 assert!(resolve_wire_path(root, "%2e%2e/etc/passwd").is_none());
4173 assert!(resolve_wire_path(root, "%2E").is_none());
4175 assert!(resolve_wire_path(root, "a%2F..%2Fb").is_none());
4178 assert!(resolve_wire_path(root, "a%2Fb").is_none());
4179 assert_eq!(resolve_wire_path(root, "%2525"), Some(root.join("%25")));
4181 }
4182
4183 fn meta(node_type: u8, size: u64, mtime: u64, ino: u64) -> NodeMeta {
4184 NodeMeta {
4185 node_type,
4186 size,
4187 mtime_ns: mtime,
4188 mode: 0o644,
4189 hash: 0,
4190 dev_ino: (1, ino),
4191 link_dir: false,
4192 filtered: false,
4193 }
4194 }
4195
4196 #[test]
4208 fn diff_reports_a_flag_flip_under_an_unchanged_stat() {
4209 for (label, flip) in [
4210 (
4211 "filtered",
4212 (|m: &mut NodeMeta| m.filtered = true) as fn(&mut NodeMeta),
4213 ),
4214 ("link_dir", |m: &mut NodeMeta| m.link_dir = true),
4215 ] {
4216 let mut prev = Index::new();
4217 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4218 prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4219 let mut curr = prev.clone();
4220 flip(curr.get_mut("d").unwrap());
4221
4222 let changed = std::collections::BTreeSet::from(["d".to_string()]);
4223 for (how, ops) in [
4224 ("diff", diff(&prev, &curr)),
4225 ("diff_changed", diff_changed(&prev, &curr, &changed)),
4226 ] {
4227 let [
4228 DiffOp::Upsert {
4229 path,
4230 content_changed,
4231 },
4232 ] = &ops[..]
4233 else {
4234 panic!("{label} via {how}: expected one Upsert, got {ops:?}");
4235 };
4236 assert_eq!(path, "d", "{label} via {how}");
4237 assert!(!content_changed, "{label} via {how} asked for content");
4239 }
4240 }
4241 }
4242
4243 #[test]
4244 fn diff_detects_directory_move() {
4245 let mut prev = Index::new();
4246 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4247 prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4248 prev.insert("d/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4249 let mut curr = Index::new();
4250 curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4251 curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
4252 curr.insert("e/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4253 let ops = diff(&prev, &curr);
4254 assert_eq!(
4255 ops,
4256 vec![DiffOp::Move {
4257 from: "d".into(),
4258 to: "e".into()
4259 }]
4260 );
4261 }
4262
4263 #[test]
4266 fn diff_move_with_same_window_child_changes() {
4267 let mut prev = Index::new();
4268 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4269 prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 50, 2));
4270 prev.insert("d/modified".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
4271 prev.insert("d/deleted".into(), meta(FS_ENTRY_FILE, 5, 10, 4));
4272 let mut curr = Index::new();
4273 curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
4274 curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 50, 2));
4275 curr.insert("e/modified".into(), meta(FS_ENTRY_FILE, 999, 777, 3));
4276 curr.insert("e/created".into(), meta(FS_ENTRY_FILE, 1, 900, 9));
4277 let ops = diff(&prev, &curr);
4278 assert!(ops.contains(&DiffOp::Move {
4279 from: "d".into(),
4280 to: "e".into()
4281 }));
4282 assert!(
4283 ops.contains(&DiffOp::Upsert {
4284 path: "e/modified".into(),
4285 content_changed: true
4286 }),
4287 "modified child swallowed: {ops:?}"
4288 );
4289 assert!(
4290 ops.contains(&DiffOp::Upsert {
4291 path: "e/created".into(),
4292 content_changed: true
4293 }),
4294 "created child swallowed: {ops:?}"
4295 );
4296 assert!(
4297 ops.contains(&DiffOp::Delete {
4298 path: "e/deleted".into()
4299 }),
4300 "deleted child swallowed: {ops:?}"
4301 );
4302 }
4303
4304 #[cfg(unix)]
4307 fn drive_engine(root: &Path) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4308 drive_engine_keyed(test_key(root))
4309 }
4310
4311 fn drive_engine_keyed(key: RootKey) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4312 let shared = open_root_unwatched(key);
4313 let hint_tx = shared.hint_sender();
4314 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4315 let sent2 = sent.clone();
4316 let opts = SyncOptions {
4317 content: true,
4318 latency: Duration::from_millis(5),
4319 ..Default::default()
4320 };
4321 let handle = start_sync(
4322 &shared,
4323 1,
4324 opts,
4325 Box::new(move |msg| {
4326 sent2.lock().unwrap().push(msg);
4327 true
4328 }),
4329 );
4330 (sent, handle, hint_tx)
4331 }
4332
4333 fn await_done(
4335 handle: &SyncHandle,
4336 sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4337 nonce: u16,
4338 cmd: Command,
4339 ) -> (u8, u128, u64) {
4340 handle.command(cmd);
4341 let deadline = Instant::now() + Duration::from_secs(5);
4342 loop {
4343 for msg in sent.lock().unwrap().iter() {
4344 if let Some((n, s, h, m)) = blit_remote::fs::parse_fs_done(msg)
4345 && n == nonce
4346 {
4347 return (s, h, m);
4348 }
4349 }
4350 assert!(Instant::now() < deadline, "no FS_DONE for nonce {nonce}");
4351 std::thread::sleep(Duration::from_millis(2));
4352 }
4353 }
4354
4355 fn write_req(nonce: u16, path: &str, base: u128, flags: u8, content: &[u8]) -> Command {
4356 Command::Write(WriteReq {
4357 nonce,
4358 path: path.into(),
4359 base,
4360 mode: 0,
4361 flags,
4362 content_kind: 1,
4363 content: content.to_vec(),
4364 inflight: None,
4365 })
4366 }
4367
4368 fn drive_single_engine(file: &Path) -> (Arc<Mutex<Vec<Vec<u8>>>>, SyncHandle, HintSender) {
4370 let shared = open_single_root_unwatched(file.to_path_buf());
4371 assert!(shared.is_single());
4372 let hint_tx = shared.hint_sender();
4373 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4374 let sent2 = sent.clone();
4375 let opts = SyncOptions {
4376 content: true,
4377 recursive: false,
4378 latency: Duration::from_millis(5),
4379 ..Default::default()
4380 };
4381 let handle = start_sync(
4382 &shared,
4383 1,
4384 opts,
4385 Box::new(move |msg| {
4386 sent2.lock().unwrap().push(msg);
4387 true
4388 }),
4389 );
4390 (sent, handle, hint_tx)
4391 }
4392
4393 fn count_updates(sent: &Arc<Mutex<Vec<Vec<u8>>>>) -> usize {
4394 sent.lock()
4395 .unwrap()
4396 .iter()
4397 .filter(|m| m[0] == blit_remote::fs::S2C_FS_UPDATE)
4398 .count()
4399 }
4400
4401 fn count_closed(sent: &Arc<Mutex<Vec<Vec<u8>>>>) -> usize {
4402 sent.lock()
4403 .unwrap()
4404 .iter()
4405 .filter(|m| m[0] == blit_remote::fs::S2C_FS_CLOSED)
4406 .count()
4407 }
4408
4409 fn pump_mirror(
4411 sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4412 handle: &SyncHandle,
4413 mirror: &mut FsMirror,
4414 seen: &mut usize,
4415 ) {
4416 let msgs = sent.lock().unwrap().clone();
4417 for msg in &msgs[*seen..] {
4418 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
4419 let id = mirror.apply_update(msg).expect("valid update");
4420 handle.command(Command::Ack(id));
4421 }
4422 }
4423 *seen = msgs.len();
4424 }
4425
4426 fn pump_until(
4428 sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4429 handle: &SyncHandle,
4430 mirror: &mut FsMirror,
4431 seen: &mut usize,
4432 what: &str,
4433 pred: impl Fn(&FsMirror) -> bool,
4434 ) {
4435 pump_until_nudging(sent, handle, mirror, seen, what, || {}, pred)
4436 }
4437
4438 fn pump_until_nudging(
4450 sent: &Arc<Mutex<Vec<Vec<u8>>>>,
4451 handle: &SyncHandle,
4452 mirror: &mut FsMirror,
4453 seen: &mut usize,
4454 what: &str,
4455 nudge: impl Fn(),
4456 pred: impl Fn(&FsMirror) -> bool,
4457 ) {
4458 let deadline = Instant::now() + Duration::from_secs(30);
4459 loop {
4460 pump_mirror(sent, handle, mirror, seen);
4461 if pred(mirror) {
4462 return;
4463 }
4464 assert!(
4465 Instant::now() < deadline,
4466 "timed out waiting for {what}; live = {:?}",
4467 mirror.live.keys().collect::<Vec<_>>()
4468 );
4469 nudge();
4470 std::thread::sleep(Duration::from_millis(2));
4471 }
4472 }
4473
4474 #[test]
4481 fn single_sync_lifecycle() {
4482 let dir = temp_dir().canonicalize().unwrap();
4483 let file = dir.join("note.txt");
4484 let sibling = dir.join("sibling.txt");
4485 fs::write(&file, b"v1").unwrap();
4486 fs::write(&sibling, b"noise").unwrap();
4487
4488 let shared = open_single_root_unwatched(file.clone());
4491 assert!(Arc::ptr_eq(
4492 &shared,
4493 &open_single_root_unwatched(file.clone())
4494 ));
4495 let dir_root = open_root_unwatched(test_key(&dir));
4496 assert!(!Arc::ptr_eq(&shared, &dir_root));
4497 drop(dir_root);
4498 drop(shared);
4499
4500 let (sent, handle, hint) = drive_single_engine(&file);
4501 let mut mirror = FsMirror::new();
4502 let mut seen = 0usize;
4503
4504 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial ''", |m| {
4506 m.live
4507 .get("")
4508 .is_some_and(|n| n.content.as_deref() == Some(&b"v1"[..]))
4509 });
4510 assert_eq!(mirror.live.len(), 1, "mirror holds exactly the root");
4511 let node = &mirror.live[""];
4512 assert_eq!(node.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_FILE);
4513 assert_eq!(node.hash, blake3_128(b"v1"));
4514
4515 let quiet = count_updates(&sent);
4517 fs::write(&sibling, b"more noise").unwrap();
4518 fs::write(dir.join("new-sibling.txt"), b"x").unwrap();
4519 hint.send(Hint::Dirty(sibling.clone()));
4520 hint.send(Hint::Dirty(dir.join("new-sibling.txt")));
4521 std::thread::sleep(Duration::from_millis(120));
4522 pump_mirror(&sent, &handle, &mut mirror, &mut seen);
4523 assert_eq!(
4524 count_updates(&sent),
4525 quiet,
4526 "sibling churn woke the single sync"
4527 );
4528 assert_eq!(mirror.live[""].content.as_deref(), Some(&b"v1"[..]));
4529
4530 fs::write(&file, b"v2").unwrap();
4532 hint.send(Hint::Dirty(file.clone()));
4533 pump_until(&sent, &handle, &mut mirror, &mut seen, "v2", |m| {
4534 m.live
4535 .get("")
4536 .is_some_and(|n| n.content.as_deref() == Some(&b"v2"[..]))
4537 });
4538
4539 fs::write(&file, b"v3").unwrap();
4541 hint.send(Hint::Dirty(dir.clone()));
4542 pump_until(&sent, &handle, &mut mirror, &mut seen, "v3", |m| {
4543 m.live
4544 .get("")
4545 .is_some_and(|n| n.content.as_deref() == Some(&b"v3"[..]))
4546 });
4547
4548 fs::remove_file(&file).unwrap();
4550 hint.send(Hint::Dirty(file.clone()));
4551 pump_until(&sent, &handle, &mut mirror, &mut seen, "delete", |m| {
4552 m.live.is_empty()
4553 });
4554 assert_eq!(count_closed(&sent), 0, "delete must not close the sync");
4555
4556 fs::write(&file, b"v4").unwrap();
4558 hint.send(Hint::Dirty(file.clone()));
4559 pump_until(&sent, &handle, &mut mirror, &mut seen, "recreate", |m| {
4560 m.live
4561 .get("")
4562 .is_some_and(|n| n.content.as_deref() == Some(&b"v4"[..]))
4563 });
4564
4565 let away = dir.join("renamed.txt");
4567 fs::rename(&file, &away).unwrap();
4568 hint.send(Hint::Dirty(file.clone()));
4569 hint.send(Hint::Dirty(away.clone()));
4570 pump_until(&sent, &handle, &mut mirror, &mut seen, "rename away", |m| {
4571 m.live.is_empty()
4572 });
4573 fs::rename(&away, &file).unwrap();
4574 hint.send(Hint::Dirty(file.clone()));
4575 hint.send(Hint::Dirty(away.clone()));
4576 pump_until(&sent, &handle, &mut mirror, &mut seen, "rename back", |m| {
4577 m.live
4578 .get("")
4579 .is_some_and(|n| n.content.as_deref() == Some(&b"v4"[..]))
4580 });
4581 assert_eq!(count_closed(&sent), 0);
4582
4583 handle.command(Command::Stop);
4585 let deadline = Instant::now() + Duration::from_secs(5);
4586 while count_closed(&sent) == 0 {
4587 assert!(Instant::now() < deadline, "no FS_CLOSED after Stop");
4588 std::thread::sleep(Duration::from_millis(2));
4589 }
4590 let closed = sent
4591 .lock()
4592 .unwrap()
4593 .iter()
4594 .find(|m| m[0] == blit_remote::fs::S2C_FS_CLOSED)
4595 .unwrap()
4596 .clone();
4597 assert_eq!(closed[3], FS_CLOSED_CLIENT_REQUEST);
4598 let _ = fs::remove_dir_all(&dir);
4599 }
4600
4601 #[cfg(target_os = "linux")]
4616 #[test]
4617 fn cross_device_symlink_is_not_descended_on_reconcile() {
4618 use std::os::unix::fs::MetadataExt;
4619
4620 let dir = temp_dir().canonicalize().unwrap();
4621 let Ok(shm) = std::path::Path::new("/dev/shm").canonicalize() else {
4622 return;
4623 };
4624 let foreign = shm.join(format!("blit-xdev-{}", std::process::id()));
4625 if fs::create_dir_all(foreign.join("inner")).is_err() {
4626 return;
4627 }
4628 let (Ok(a), Ok(b)) = (fs::metadata(&dir), fs::metadata(&foreign)) else {
4630 let _ = fs::remove_dir_all(&foreign);
4631 return;
4632 };
4633 if a.dev() == b.dev() {
4634 let _ = fs::remove_dir_all(&foreign);
4635 return;
4636 }
4637 fs::write(foreign.join("inner/secret.txt"), b"elsewhere").unwrap();
4638 fs::write(dir.join("local.txt"), b"here").unwrap();
4639
4640 let (sent, handle, hint) = drive_engine(&dir);
4642 let mut mirror = FsMirror::new();
4643 let mut seen = 0usize;
4644 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4645 m.live.contains_key("local.txt")
4646 });
4647
4648 std::os::unix::fs::symlink(&foreign, dir.join("far")).unwrap();
4651 hint.send(Hint::Dirty(dir.join("far")));
4652 pump_until(&sent, &handle, &mut mirror, &mut seen, "link entry", |m| {
4653 m.live.contains_key("far")
4654 });
4655
4656 let leaked: Vec<String> = mirror
4661 .live
4662 .keys()
4663 .filter(|k| k.starts_with("far/"))
4664 .cloned()
4665 .collect();
4666 handle.command(Command::Stop);
4667 let _ = fs::remove_dir_all(&foreign);
4668
4669 assert!(
4670 leaked.is_empty(),
4671 "cross_filesystem is off: a symlink to another device must not be \
4672 descended, found {leaked:?}"
4673 );
4674 }
4675
4676 #[cfg(target_os = "linux")]
4684 #[test]
4685 fn per_directory_watching_still_delivers_every_change() {
4686 let root = temp_dir().canonicalize().unwrap();
4687 fs::create_dir_all(root.join("src/deep")).unwrap();
4688 fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
4689 fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
4690 fs::write(root.join("src/deep/seed.txt"), b"seed").unwrap();
4691
4692 let key = test_key_ignoring(
4693 &root,
4694 IgnoreSpec {
4695 gitignore: true,
4696 dot_ignore: true,
4697 exclude_git: true,
4698 patterns: Vec::new(),
4699 },
4700 );
4701 let shared = open_root(key).expect("arm native watch");
4702 assert!(
4703 shared
4704 ._backend
4705 .lock()
4706 .unwrap()
4707 .as_ref()
4708 .is_some_and(|b| b.watches.is_per_dir()),
4709 "a filtered root on Linux arms per directory"
4710 );
4711 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4712 let sent2 = sent.clone();
4713 let handle = start_sync(
4714 &shared,
4715 9,
4716 SyncOptions {
4717 content: true,
4718 latency: Duration::from_millis(5),
4719 ..Default::default()
4720 },
4721 Box::new(move |msg| {
4722 sent2.lock().unwrap().push(msg);
4723 true
4724 }),
4725 );
4726 let mut mirror = FsMirror::new();
4727 let mut seen = 0usize;
4728 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4729 m.live.contains_key("src/deep/seed.txt")
4730 });
4731
4732 fs::write(root.join("src/deep/seed.txt"), b"changed").unwrap();
4735 pump_until(&sent, &handle, &mut mirror, &mut seen, "deep write", |m| {
4736 m.live
4737 .get("src/deep/seed.txt")
4738 .is_some_and(|n| n.content.as_deref() == Some(&b"changed"[..]))
4739 });
4740
4741 fs::create_dir(root.join("src/fresh")).unwrap();
4745 fs::write(root.join("src/fresh/a.txt"), b"a").unwrap();
4746 pump_until(&sent, &handle, &mut mirror, &mut seen, "fresh dir", |m| {
4747 m.live.contains_key("src/fresh/a.txt")
4748 });
4749 fs::write(root.join("src/fresh/b.txt"), b"b").unwrap();
4750 pump_until(&sent, &handle, &mut mirror, &mut seen, "fresh child", |m| {
4751 m.live.contains_key("src/fresh/b.txt")
4752 });
4753
4754 fs::remove_dir_all(root.join("src/fresh")).unwrap();
4756 pump_until(&sent, &handle, &mut mirror, &mut seen, "dir gone", |m| {
4757 !m.live.contains_key("src/fresh")
4758 });
4759 fs::create_dir(root.join("src/fresh")).unwrap();
4760 fs::write(root.join("src/fresh/c.txt"), b"c").unwrap();
4761 pump_until(&sent, &handle, &mut mirror, &mut seen, "re-armed", |m| {
4762 m.live.contains_key("src/fresh/c.txt")
4763 });
4764
4765 fs::write(root.join("node_modules/pkg/index.js"), b"x").unwrap();
4767 std::thread::sleep(Duration::from_millis(100));
4768 pump_mirror(&sent, &handle, &mut mirror, &mut seen);
4769 assert!(
4770 !mirror.live.keys().any(|k| k.starts_with("node_modules")),
4771 "live = {:?}",
4772 mirror.live.keys().collect::<Vec<_>>()
4773 );
4774 handle.command(Command::Stop);
4775 }
4776
4777 #[test]
4782 fn excluded_paths_never_reach_the_client() {
4783 let dir = temp_dir().canonicalize().unwrap();
4784 fs::create_dir_all(dir.join(".git")).unwrap();
4785 fs::write(dir.join(".git/config"), b"[core]").unwrap();
4786 fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
4787 fs::write(dir.join("node_modules/pkg/index.js"), b"x").unwrap();
4788 fs::create_dir_all(dir.join("target/debug")).unwrap();
4789 fs::write(dir.join("target/debug/bin"), b"x").unwrap();
4790 fs::create_dir_all(dir.join("src")).unwrap();
4791 fs::write(dir.join("src/a.rs"), b"fn main() {}").unwrap();
4792 fs::write(dir.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
4793
4794 let key = test_key_ignoring(
4795 &dir,
4796 IgnoreSpec {
4797 gitignore: true,
4798 dot_ignore: true,
4799 exclude_git: true,
4800 patterns: Vec::new(),
4801 },
4802 );
4803 let (sent, handle, hint) = drive_engine_keyed(key);
4804 let mut mirror = FsMirror::new();
4805 let mut seen = 0usize;
4806 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4807 m.live.contains_key("src/a.rs")
4808 });
4809 assert_eq!(
4810 mirror.live.keys().cloned().collect::<Vec<_>>(),
4811 ["", ".gitignore", "src", "src/a.rs"],
4812 "the whole checkout, and nothing the exclusions cover"
4813 );
4814
4815 let quiet = count_updates(&sent);
4818 fs::write(dir.join("target/debug/fresh.bin"), b"y").unwrap();
4819 fs::write(dir.join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
4820 hint.send(Hint::Dirty(dir.join("target/debug/fresh.bin")));
4821 hint.send(Hint::Dirty(dir.join(".git/HEAD")));
4822 std::thread::sleep(Duration::from_millis(50));
4823 assert_eq!(count_updates(&sent), quiet, "excluded churn woke the sync");
4824
4825 fs::write(dir.join("src/b.rs"), b"pub fn b() {}").unwrap();
4826 hint.send(Hint::Dirty(dir.join("src/b.rs")));
4827 pump_until(&sent, &handle, &mut mirror, &mut seen, "src/b.rs", |m| {
4828 m.live.contains_key("src/b.rs")
4829 });
4830 assert!(
4831 !mirror
4832 .live
4833 .keys()
4834 .any(|k| k.starts_with("target") || k.starts_with(".git/")),
4835 "live = {:?}",
4836 mirror.live.keys().collect::<Vec<_>>()
4837 );
4838
4839 fs::write(dir.join(".gitignore"), "target/\nnode_modules/\nsrc/a.rs\n").unwrap();
4841 hint.send(Hint::Dirty(dir.join(".gitignore")));
4842 pump_until(&sent, &handle, &mut mirror, &mut seen, "a.rs gone", |m| {
4843 !m.live.contains_key("src/a.rs")
4844 });
4845 assert!(mirror.live.contains_key("src/b.rs"), "only the rule's path");
4846
4847 fs::write(dir.join(".gitignore"), "target/\nnode_modules/\n").unwrap();
4849 hint.send(Hint::Dirty(dir.join(".gitignore")));
4850 pump_until(&sent, &handle, &mut mirror, &mut seen, "a.rs back", |m| {
4851 m.live.contains_key("src/a.rs")
4852 });
4853 handle.command(Command::Stop);
4854 }
4855
4856 #[cfg(target_os = "linux")]
4862 #[test]
4863 fn an_edit_to_an_ignore_file_above_the_root_reaches_the_client() {
4864 let top = temp_dir().canonicalize().unwrap();
4865 fs::create_dir_all(top.join(".git")).unwrap();
4866 let root = top.join("crates");
4867 fs::create_dir_all(&root).unwrap();
4868 fs::write(top.join(".gitignore"), "*.bak\n").unwrap();
4869 fs::write(root.join("a.rs"), b"x").unwrap();
4870 fs::write(root.join("old.bak"), b"x").unwrap();
4871
4872 let key = test_key_ignoring(
4873 &root,
4874 IgnoreSpec {
4875 gitignore: true,
4876 ..Default::default()
4877 },
4878 );
4879 let shared = open_root(key).expect("arm native watch");
4880 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
4881 let sent2 = sent.clone();
4882 let handle = start_sync(
4883 &shared,
4884 9,
4885 SyncOptions {
4886 latency: Duration::from_millis(5),
4887 ..Default::default()
4888 },
4889 Box::new(move |msg| {
4890 sent2.lock().unwrap().push(msg);
4891 true
4892 }),
4893 );
4894 let mut mirror = FsMirror::new();
4895 let mut seen = 0usize;
4896 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4897 m.live.contains_key("a.rs")
4898 });
4899 assert!(!mirror.live.contains_key("old.bak"), "inherited from above");
4900
4901 fs::write(top.join(".gitignore"), "*.tmp\n").unwrap();
4904 pump_until(&sent, &handle, &mut mirror, &mut seen, "uncovered", |m| {
4905 m.live.contains_key("old.bak")
4906 });
4907
4908 fs::write(top.join(".gitignore"), "*.rs\n").unwrap();
4910 pump_until(&sent, &handle, &mut mirror, &mut seen, "covered", |m| {
4911 !m.live.contains_key("a.rs")
4912 });
4913 handle.command(Command::Stop);
4914 }
4915
4916 #[test]
4922 fn a_directory_pattern_excludes_a_symlinked_directory_and_its_subtree() {
4923 let dir = temp_dir().canonicalize().unwrap();
4924 fs::create_dir_all(dir.join("real/inner")).unwrap();
4925 fs::write(dir.join("real/inner/heavy.bin"), b"x").unwrap();
4926 fs::write(dir.join("keep.txt"), b"k").unwrap();
4927 std::os::unix::fs::symlink(dir.join("real"), dir.join("build")).unwrap();
4928
4929 let key = test_key_ignoring(
4930 &dir,
4931 IgnoreSpec {
4932 patterns: vec!["build/".into()],
4933 ..Default::default()
4934 },
4935 );
4936 let (sent, handle, _hint) = drive_engine_keyed(key);
4937 let mut mirror = FsMirror::new();
4938 let mut seen = 0usize;
4939 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4940 m.live.contains_key("keep.txt")
4941 });
4942 assert!(
4943 !mirror.live.keys().any(|k| k.starts_with("build")),
4944 "the link and everything enumerated through it; live = {:?}",
4945 mirror.live.keys().collect::<Vec<_>>()
4946 );
4947 assert!(mirror.live.contains_key("real/inner/heavy.bin"));
4949 handle.command(Command::Stop);
4950 }
4951
4952 #[test]
4958 fn a_directory_reports_that_it_hid_children() {
4959 let dir = temp_dir().canonicalize().unwrap();
4960 fs::create_dir_all(dir.join("src")).unwrap();
4961 fs::create_dir_all(dir.join("plain")).unwrap();
4962 fs::write(dir.join("src/a.rs"), b"x").unwrap();
4963 fs::write(dir.join("src/a.tmp"), b"x").unwrap();
4964 fs::write(dir.join("plain/b.rs"), b"x").unwrap();
4965
4966 let key = test_key_ignoring(
4967 &dir,
4968 IgnoreSpec {
4969 patterns: vec!["*.tmp".into()],
4970 ..Default::default()
4971 },
4972 );
4973 let (sent, handle, hint) = drive_engine_keyed(key);
4974 let mut mirror = FsMirror::new();
4975 let mut seen = 0usize;
4976 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
4977 m.live.contains_key("plain/b.rs")
4978 });
4979 let filtered = |m: &FsMirror, path: &str| {
4980 m.live
4981 .get(path)
4982 .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED != 0)
4983 };
4984 assert!(filtered(&mirror, "src"), "src hid a.tmp");
4985 assert!(!filtered(&mirror, "plain"), "plain hid nothing");
4986 assert!(!filtered(&mirror, ""), "nor did the root");
4987
4988 fs::write(dir.join("plain/c.tmp"), b"x").unwrap();
4990 hint.send(Hint::Dirty(dir.join("plain/c.tmp")));
4991 pump_until_nudging(
4992 &sent,
4993 &handle,
4994 &mut mirror,
4995 &mut seen,
4996 "plain hides",
4997 || {
4998 hint.send(Hint::Dirty(dir.join("plain/c.tmp")));
4999 },
5000 |m| {
5001 m.live
5002 .get("plain")
5003 .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED != 0)
5004 },
5005 );
5006
5007 fs::remove_file(dir.join("plain/c.tmp")).unwrap();
5010 hint.send(Hint::Dirty(dir.join("plain")));
5011 pump_until_nudging(
5012 &sent,
5013 &handle,
5014 &mut mirror,
5015 &mut seen,
5016 "plain clears",
5017 || {
5018 hint.send(Hint::Dirty(dir.join("plain")));
5019 },
5020 |m| {
5021 m.live
5022 .get("plain")
5023 .is_some_and(|n| n.entry_flags & FS_ENTRY_FILTERED == 0)
5024 },
5025 );
5026 assert!(filtered(&mirror, "src"), "src still hides a.tmp");
5027 handle.command(Command::Stop);
5028 }
5029
5030 #[test]
5035 fn client_patterns_outrank_ignore_files_and_key_the_root() {
5036 let dir = temp_dir().canonicalize().unwrap();
5037 fs::write(dir.join(".gitignore"), "*.log\n").unwrap();
5038 fs::write(dir.join("a.log"), b"x").unwrap();
5039 fs::write(dir.join("keep.log"), b"x").unwrap();
5040 fs::write(dir.join("notes.txt"), b"x").unwrap();
5041
5042 let spec = IgnoreSpec {
5043 gitignore: true,
5044 dot_ignore: true,
5045 exclude_git: false,
5046 patterns: IgnoreSpec::parse_patterns("!keep.log\nnotes.txt"),
5047 };
5048 let (sent, handle, _hint) = drive_engine_keyed(test_key_ignoring(&dir, spec.clone()));
5049 let mut mirror = FsMirror::new();
5050 let mut seen = 0usize;
5051 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5052 m.live.contains_key("keep.log")
5053 });
5054 assert_eq!(
5055 mirror.live.keys().cloned().collect::<Vec<_>>(),
5056 ["", ".gitignore", "keep.log"]
5057 );
5058
5059 let same = open_root_unwatched(test_key_ignoring(&dir, spec.clone()));
5060 let again = open_root_unwatched(test_key_ignoring(&dir, spec));
5061 assert!(Arc::ptr_eq(&same, &again), "one spec, one shared root");
5062 let unfiltered = open_root_unwatched(test_key(&dir));
5063 assert!(
5064 !Arc::ptr_eq(&same, &unfiltered),
5065 "an unfiltered sync indexes a different tree"
5066 );
5067 handle.command(Command::Stop);
5068 }
5069
5070 #[test]
5077 fn percent_in_root_survives_the_wire_round_trip() {
5078 let dir = temp_dir().canonicalize().unwrap();
5079 let literal = dir.join("50%.txt");
5080 fs::write(&literal, b"x").unwrap();
5081
5082 let echoed = escape_path(&literal);
5084 assert!(echoed.ends_with("50%25.txt"), "echo escapes the percent");
5085
5086 assert_eq!(
5088 validate_root(&literal.to_string_lossy()).unwrap(),
5089 literal.canonicalize().unwrap(),
5090 "a raw path containing % still works"
5091 );
5092 assert_eq!(
5093 validate_root(&echoed).unwrap(),
5094 literal.canonicalize().unwrap(),
5095 "the escaped echo resolves back to the same file"
5096 );
5097
5098 let ambiguous = dir.join("50%25.txt");
5100 fs::write(&ambiguous, b"y").unwrap();
5101 assert_eq!(
5102 validate_root(&echoed).unwrap(),
5103 ambiguous.canonicalize().unwrap(),
5104 "literal match takes precedence over the decoded one"
5105 );
5106 }
5107
5108 #[cfg(unix)]
5114 #[test]
5115 fn symlinked_directories_are_traversed_and_cycle_safe() {
5116 let dir = temp_dir().canonicalize().unwrap();
5117 fs::create_dir_all(dir.join("real/inner")).unwrap();
5119 fs::write(dir.join("real/inner/deep.txt"), b"payload").unwrap();
5120 fs::write(dir.join("real/top.txt"), b"top").unwrap();
5121 std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
5122 std::os::unix::fs::symlink(dir.join("real"), dir.join("real/loop")).unwrap();
5123 std::os::unix::fs::symlink(dir.join("real/top.txt"), dir.join("tolink")).unwrap();
5125 std::os::unix::fs::symlink(dir.join("nope"), dir.join("dangling")).unwrap();
5126
5127 let (sent, handle, _hint) = drive_engine(&dir);
5128 let mut mirror = FsMirror::new();
5129 let mut seen = 0usize;
5130 pump_until(
5131 &sent,
5132 &handle,
5133 &mut mirror,
5134 &mut seen,
5135 "link subtree",
5136 |m| m.live.contains_key("link/inner/deep.txt"),
5137 );
5138
5139 let link = &mirror.live["link"];
5141 assert_eq!(link.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5142 assert_ne!(
5143 link.entry_flags & FS_ENTRY_LINK_DIR,
5144 0,
5145 "a symlinked directory must advertise that it can be expanded"
5146 );
5147 assert!(mirror.live.contains_key("link/top.txt"));
5149 assert_eq!(
5150 mirror.live["link/inner/deep.txt"].content.as_deref(),
5151 Some(&b"payload"[..])
5152 );
5153
5154 assert_eq!(mirror.live["tolink"].entry_flags & FS_ENTRY_LINK_DIR, 0);
5156 assert_eq!(mirror.live["dangling"].entry_flags & FS_ENTRY_LINK_DIR, 0);
5157
5158 assert!(mirror.live.contains_key("real/loop"));
5161 assert!(
5162 !mirror.live.keys().any(|k| k.starts_with("real/loop/")),
5163 "a link to an ancestor must not be descended: {:?}",
5164 mirror.live.keys().collect::<Vec<_>>()
5165 );
5166 assert!(mirror.live.contains_key("link/loop"));
5168 assert!(
5169 !mirror.live.keys().any(|k| k.starts_with("link/loop/")),
5170 "cycle detection must hold through a symlinked path too: {:?}",
5171 mirror.live.keys().collect::<Vec<_>>()
5172 );
5173 handle.command(Command::Stop);
5174 }
5175
5176 #[test]
5178 fn single_root_validation() {
5179 use blit_remote::fs::{FS_STATUS_NOT_FOUND, FS_STATUS_OTHER};
5180 let dir = temp_dir();
5181 let file = dir.join("f.txt");
5182 fs::write(&file, b"x").unwrap();
5183 assert_eq!(
5184 validate_single_root(&file.to_string_lossy()).unwrap(),
5185 file.canonicalize().unwrap()
5186 );
5187 let (status, _) = validate_single_root(&dir.to_string_lossy()).unwrap_err();
5188 assert_eq!(status, FS_STATUS_OTHER, "directory root refused");
5189 let (status, _) =
5190 validate_single_root(&dir.join("missing.txt").to_string_lossy()).unwrap_err();
5191 assert_eq!(status, FS_STATUS_NOT_FOUND);
5192 let _ = fs::remove_dir_all(&dir);
5193 }
5194
5195 fn copy_mtime(from: &Path, to: &Path) {
5199 let status = std::process::Command::new("touch")
5200 .arg("-r")
5201 .arg(from)
5202 .arg(to)
5203 .status()
5204 .expect("touch");
5205 assert!(status.success(), "touch -r failed");
5206 assert_eq!(
5207 stat_meta(from).unwrap().mtime_ns,
5208 stat_meta(to).unwrap().mtime_ns,
5209 "mtimes must be identical for the test to mean anything"
5210 );
5211 }
5212
5213 #[test]
5221 fn single_sync_same_stat_rewrite() {
5222 let dir = temp_dir().canonicalize().unwrap();
5223 let reference = dir.join("reference");
5224 let file = dir.join("note.txt");
5225 fs::write(&reference, b"").unwrap();
5226 fs::write(&file, b"one").unwrap();
5227 copy_mtime(&reference, &file);
5228
5229 let (sent, handle, hint) = drive_single_engine(&file);
5230 let mut mirror = FsMirror::new();
5231 let mut seen = 0usize;
5232 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5233 m.live
5234 .get("")
5235 .is_some_and(|n| n.content.as_deref() == Some(&b"one"[..]))
5236 });
5237
5238 fs::write(&file, b"two").unwrap();
5239 copy_mtime(&reference, &file);
5240 hint.send(Hint::Dirty(file.clone()));
5241 pump_until(
5242 &sent,
5243 &handle,
5244 &mut mirror,
5245 &mut seen,
5246 "same-stat rewrite",
5247 |m| {
5248 m.live
5249 .get("")
5250 .is_some_and(|n| n.content.as_deref() == Some(&b"two"[..]))
5251 },
5252 );
5253
5254 handle.command(Command::Stop);
5255 let _ = fs::remove_dir_all(&dir);
5256 }
5257
5258 #[test]
5262 fn single_sync_write_through() {
5263 let dir = temp_dir().canonicalize().unwrap();
5264 let file = dir.join("doc.txt");
5265 fs::write(&file, b"hello").unwrap();
5266 let (sent, handle, _hint) = drive_single_engine(&file);
5267
5268 let mut mirror = FsMirror::new();
5269 let mut seen = 0usize;
5270 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5271 m.live.contains_key("")
5272 });
5273 let base = mirror.live[""].hash;
5274 assert_eq!(base, blake3_128(b"hello"));
5275
5276 let (s, h, _) = await_done(&handle, &sent, 1, write_req(1, "", base, 0, b"world"));
5278 assert_eq!(s, FS_DONE_OK);
5279 assert_eq!(h, blake3_128(b"world"));
5280 assert_eq!(fs::read(&file).unwrap(), b"world");
5281 pump_until(&sent, &handle, &mut mirror, &mut seen, "echo", |m| {
5284 m.live
5285 .get("")
5286 .is_some_and(|n| n.hash == blake3_128(b"world"))
5287 });
5288
5289 let (s, disk, _) = await_done(&handle, &sent, 2, write_req(2, "", base, 0, b"x"));
5291 assert_eq!(s, FS_DONE_CONFLICT);
5292 assert_eq!(disk, blake3_128(b"world"));
5293
5294 let (s, _, _) = await_done(&handle, &sent, 3, write_req(3, "other.txt", 0, 0, b"no"));
5296 assert_eq!(s, FS_DONE_INVALID);
5297
5298 let (s, _, _) = await_done(&handle, &sent, 4, write_req(4, "", 0, 0, b"no"));
5300 assert_eq!(s, FS_DONE_CONFLICT);
5301
5302 let (s, _, _) = await_done(
5304 &handle,
5305 &sent,
5306 5,
5307 Command::Op(OpReq {
5308 nonce: 5,
5309 op: FS_OP_REMOVE,
5310 a: String::new(),
5311 b: String::new(),
5312 base: blake3_128(b"world"),
5313 mode: 0,
5314 flags: 0,
5315 inflight: None,
5316 }),
5317 );
5318 assert_eq!(s, FS_DONE_OK);
5319 assert!(!file.exists());
5320 pump_until(&sent, &handle, &mut mirror, &mut seen, "removed", |m| {
5321 m.live.is_empty()
5322 });
5323 assert_eq!(count_closed(&sent), 0, "REMOVE of '' must not close");
5324
5325 handle.command(Command::Stop);
5326 let _ = fs::remove_dir_all(&dir);
5327 }
5328
5329 #[test]
5330 fn write_cas_semantics() {
5331 let root = temp_dir().canonicalize().unwrap();
5334 let (sent, handle, _hint) = drive_engine(&root);
5335
5336 let (s, hash, _) = await_done(&handle, &sent, 1, write_req(1, "a.txt", 0, 0, b"hello"));
5339 assert_eq!(s, FS_DONE_OK);
5340 assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"hello");
5341 assert_eq!(hash, blake3_128(b"hello"));
5342 let (s, disk, _) = await_done(&handle, &sent, 2, write_req(2, "a.txt", 0, 0, b"x"));
5343 assert_eq!(s, FS_DONE_CONFLICT);
5344 assert_eq!(disk, hash, "conflict carries the live disk hash");
5345 assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"hello", "unchanged");
5346
5347 let (s, h2, _) = await_done(&handle, &sent, 3, write_req(3, "a.txt", hash, 0, b"world"));
5349 assert_eq!(s, FS_DONE_OK);
5350 assert_eq!(h2, blake3_128(b"world"));
5351 assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"world");
5352 let (s, _, _) = await_done(&handle, &sent, 4, write_req(4, "a.txt", hash, 0, b"z"));
5353 assert_eq!(s, FS_DONE_CONFLICT, "stale base rejected");
5354
5355 let (s, _, _) = await_done(
5357 &handle,
5358 &sent,
5359 5,
5360 write_req(5, "a.txt", 0, FS_WRITE_NO_CAS, b"forced"),
5361 );
5362 assert_eq!(s, FS_DONE_OK);
5363 assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"forced");
5364
5365 let (s, _, _) = await_done(
5367 &handle,
5368 &sent,
5369 6,
5370 write_req(6, "d/e/f.txt", 0, FS_WRITE_MKPARENTS, b"deep"),
5371 );
5372 assert_eq!(s, FS_DONE_OK);
5373 assert_eq!(fs::read(root.join("d/e/f.txt")).unwrap(), b"deep");
5374
5375 handle.command(Command::Stop);
5376 let _ = fs::remove_dir_all(&root);
5377 }
5378
5379 fn delta_req(nonce: u16, path: &str, base: u128, flags: u8, ops: &[u8]) -> Command {
5380 Command::Write(WriteReq {
5381 nonce,
5382 path: path.into(),
5383 base,
5384 mode: 0,
5385 flags,
5386 content_kind: blit_remote::fs::FS_WRITE_CONTENT_DELTA,
5387 content: ops.to_vec(),
5388 inflight: None,
5389 })
5390 }
5391
5392 #[test]
5398 fn write_delta_applies_against_cas_base() {
5399 let root = temp_dir().canonicalize().unwrap();
5400 let (sent, handle, _hint) = drive_engine(&root);
5401
5402 let old = b"hello world".as_slice();
5404 let (s, h1, _) = await_done(&handle, &sent, 1, write_req(1, "a.txt", 0, 0, old));
5405 assert_eq!(s, FS_DONE_OK);
5406
5407 let new = b"hello brave world".as_slice();
5409 let ops = encode_delta(old, new);
5410 assert_eq!(
5411 blit_remote::fs::apply_fs_delta(old, &ops).as_deref(),
5412 Some(new)
5413 );
5414 let (s, h2, _) = await_done(&handle, &sent, 2, delta_req(2, "a.txt", h1, 0, &ops));
5415 assert_eq!(s, FS_DONE_OK);
5416 assert_eq!(h2, blake3_128(new));
5417 assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5418
5419 let (s, disk, _) = await_done(&handle, &sent, 3, delta_req(3, "a.txt", h1, 0, &ops));
5421 assert_eq!(s, FS_DONE_CONFLICT, "stale delta base must conflict");
5422 assert_eq!(disk, h2, "conflict carries the live disk hash");
5423 assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5424
5425 let (s, _, _) = await_done(
5427 &handle,
5428 &sent,
5429 4,
5430 delta_req(4, "a.txt", h2, FS_WRITE_NO_CAS, &ops),
5431 );
5432 assert_eq!(s, FS_DONE_INVALID);
5433 let (s, _, _) = await_done(&handle, &sent, 5, delta_req(5, "a.txt", 0, 0, &ops));
5434 assert_eq!(s, FS_DONE_INVALID);
5435
5436 let (s, _, _) = await_done(&handle, &sent, 6, delta_req(6, "a.txt", h2, 0, &[0xFF, 1]));
5438 assert_eq!(s, FS_DONE_INVALID);
5439 assert_eq!(fs::read(root.join("a.txt")).unwrap(), new);
5440
5441 let (s, disk, _) = await_done(&handle, &sent, 7, delta_req(7, "gone.txt", h2, 0, &ops));
5444 assert_eq!(s, FS_DONE_CONFLICT);
5445 assert_eq!(disk, 0);
5446
5447 let mut mirror = FsMirror::new();
5449 let mut seen = 0usize;
5450 pump_until(&sent, &handle, &mut mirror, &mut seen, "delta echo", |m| {
5451 m.live.get("a.txt").is_some_and(|n| n.hash == h2)
5452 });
5453
5454 handle.command(Command::Stop);
5455 let _ = fs::remove_dir_all(&root);
5456 }
5457
5458 #[test]
5461 fn write_delta_on_single_sync() {
5462 let dir = temp_dir().canonicalize().unwrap();
5463 let file = dir.join("buf.txt");
5464 fs::write(&file, b"alpha").unwrap();
5465 let (sent, handle, _hint) = drive_single_engine(&file);
5466
5467 let mut mirror = FsMirror::new();
5468 let mut seen = 0usize;
5469 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
5470 m.live.contains_key("")
5471 });
5472 let h0 = mirror.live[""].hash;
5473
5474 let ops = encode_delta(b"alpha", b"alpha beta");
5475 let (s, h1, _) = await_done(&handle, &sent, 1, delta_req(1, "", h0, 0, &ops));
5476 assert_eq!(s, FS_DONE_OK);
5477 assert_eq!(h1, blake3_128(b"alpha beta"));
5478 assert_eq!(fs::read(&file).unwrap(), b"alpha beta");
5479
5480 let ops2 = encode_delta(b"alpha beta", b"alpha beta gamma");
5483 let (s, h2, _) = await_done(&handle, &sent, 2, delta_req(2, "", h1, 0, &ops2));
5484 assert_eq!(s, FS_DONE_OK);
5485 assert_eq!(h2, blake3_128(b"alpha beta gamma"));
5486 assert_eq!(fs::read(&file).unwrap(), b"alpha beta gamma");
5487
5488 let (s, _, _) = await_done(&handle, &sent, 3, delta_req(3, "x.txt", h2, 0, &ops2));
5490 assert_eq!(s, FS_DONE_INVALID);
5491
5492 pump_until(&sent, &handle, &mut mirror, &mut seen, "echo", |m| {
5493 m.live.get("").is_some_and(|n| n.hash == h2)
5494 });
5495
5496 handle.command(Command::Stop);
5497 let _ = fs::remove_dir_all(&dir);
5498 }
5499
5500 #[test]
5501 fn write_refuses_traversal() {
5502 let root = temp_dir().canonicalize().unwrap();
5505 let sibling = root.parent().unwrap().join("blit-escape-victim.txt");
5506 let _ = fs::remove_file(&sibling);
5507 let (sent, handle, _hint) = drive_engine(&root);
5508
5509 for (i, p) in ["../blit-escape-victim.txt", "%2E%2E/blit-escape-victim.txt"]
5511 .iter()
5512 .enumerate()
5513 {
5514 let (s, _, _) = await_done(
5515 &handle,
5516 &sent,
5517 i as u16 + 1,
5518 write_req(i as u16 + 1, p, 0, 0, b"pwn"),
5519 );
5520 assert_eq!(s, FS_DONE_INVALID, "traversal {p} must be refused");
5521 }
5522 assert!(!sibling.exists(), "nothing escaped the root");
5523
5524 handle.command(Command::Stop);
5525 let _ = fs::remove_dir_all(&root);
5526 }
5527
5528 #[cfg(unix)]
5532 #[test]
5533 fn fetch_refuses_symlink_escape() {
5534 let root = temp_dir().canonicalize().unwrap();
5535 let secret = root.parent().unwrap().join("blit-fetch-secret.txt");
5538 fs::write(&secret, b"top secret").unwrap();
5539 std::os::unix::fs::symlink(root.parent().unwrap(), root.join("pub")).unwrap();
5540 let (sent, handle, _hint) = drive_engine(&root);
5541
5542 let await_file = |nonce: u16| -> (u8, Vec<u8>) {
5543 let deadline = Instant::now() + Duration::from_secs(5);
5544 loop {
5545 for msg in sent.lock().unwrap().iter() {
5546 if msg[0] == blit_remote::fs::S2C_FS_FILE
5547 && let Some((n, status, data)) = blit_remote::fs::parse_fs_file(msg)
5548 && n == nonce
5549 {
5550 return (status, data.to_vec());
5551 }
5552 }
5553 assert!(Instant::now() < deadline, "no FS_FILE for nonce {nonce}");
5554 std::thread::sleep(Duration::from_millis(2));
5555 }
5556 };
5557
5558 handle.command(Command::Fetch {
5559 nonce: 1,
5560 path: "pub/blit-fetch-secret.txt".into(),
5561 });
5562 let (status, data) = await_file(1);
5563 assert_ne!(status, FS_FILE_OK, "escape must be refused");
5564 assert!(data.is_empty(), "no bytes leak past the confinement");
5565
5566 handle.command(Command::Stop);
5567 let _ = fs::remove_file(&secret);
5568 let _ = fs::remove_dir_all(&root);
5569 }
5570
5571 #[test]
5572 fn fs_ops_mkdir_rename_remove() {
5573 let root = temp_dir().canonicalize().unwrap();
5576 let (sent, handle, _hint) = drive_engine(&root);
5577 let op = |nonce: u16, op: u8, a: &str, b: &str, base: u128, flags: u8| {
5578 Command::Op(OpReq {
5579 nonce,
5580 op,
5581 a: a.into(),
5582 b: b.into(),
5583 base,
5584 mode: 0,
5585 flags,
5586 inflight: None,
5587 })
5588 };
5589
5590 let (s, _, _) = await_done(&handle, &sent, 1, op(1, FS_OP_MKDIR, "sub", "", 0, 0));
5592 assert_eq!(s, FS_DONE_OK);
5593 assert!(root.join("sub").is_dir());
5594 let (s, _, _) = await_done(&handle, &sent, 2, op(2, FS_OP_MKDIR, "sub", "", 0, 0));
5596 assert_eq!(s, FS_DONE_OK);
5597
5598 let (_, _, _) = await_done(&handle, &sent, 3, write_req(3, "sub/x.txt", 0, 0, b"hi"));
5600 let (s, _, _) = await_done(
5601 &handle,
5602 &sent,
5603 4,
5604 op(4, FS_OP_RENAME, "sub/x.txt", "sub/y.txt", 0, 0),
5605 );
5606 assert_eq!(s, FS_DONE_OK);
5607 assert!(!root.join("sub/x.txt").exists());
5608 assert_eq!(fs::read(root.join("sub/y.txt")).unwrap(), b"hi");
5609
5610 let (s, _, _) = await_done(
5612 &handle,
5613 &sent,
5614 5,
5615 op(5, FS_OP_RENAME, "sub/gone.txt", "sub/z.txt", 0, 0),
5616 );
5617 assert_eq!(s, FS_DONE_NOT_FOUND);
5618
5619 let (s, _, _) = await_done(&handle, &sent, 6, op(6, FS_OP_REMOVE, "sub", "", 0, 0));
5621 assert_eq!(s, FS_DONE_OK);
5622 assert!(!root.join("sub").exists());
5623 let (s, _, _) = await_done(&handle, &sent, 7, op(7, FS_OP_REMOVE, "sub", "", 0, 0));
5625 assert_eq!(s, FS_DONE_NOT_FOUND);
5626
5627 handle.command(Command::Stop);
5628 let _ = fs::remove_dir_all(&root);
5629 }
5630
5631 #[cfg(unix)]
5635 #[test]
5636 fn fs_ops_symlink_hardlink() {
5637 let root = temp_dir().canonicalize().unwrap();
5640 let (sent, handle, hint) = drive_engine(&root);
5641 let op = |nonce: u16, op: u8, a: &str, b: &str, base: u128, flags: u8| {
5642 Command::Op(OpReq {
5643 nonce,
5644 op,
5645 a: a.into(),
5646 b: b.into(),
5647 base,
5648 mode: 0,
5649 flags,
5650 inflight: None,
5651 })
5652 };
5653
5654 let (s, h, _) = await_done(&handle, &sent, 1, op(1, FS_OP_SYMLINK, "a.txt", "ln", 0, 0));
5656 assert_eq!(s, FS_DONE_OK);
5657 assert_eq!(fs::read_link(root.join("ln")).unwrap(), Path::new("a.txt"));
5658 assert_eq!(h, blake3_128(b"a.txt"));
5659 let (s, disk, _) = await_done(&handle, &sent, 2, op(2, FS_OP_SYMLINK, "other", "ln", 0, 0));
5661 assert_eq!(s, FS_DONE_CONFLICT);
5662 assert_eq!(disk, h);
5663 let (s, h2, _) = await_done(&handle, &sent, 3, op(3, FS_OP_SYMLINK, "b.txt", "ln", h, 0));
5665 assert_eq!(s, FS_DONE_OK);
5666 assert_eq!(h2, blake3_128(b"b.txt"));
5667 assert_eq!(fs::read_link(root.join("ln")).unwrap(), Path::new("b.txt"));
5668 let (s, _, _) = await_done(&handle, &sent, 4, op(4, FS_OP_SYMLINK, "c", "ln", h, 0));
5670 assert_eq!(s, FS_DONE_CONFLICT);
5671 let (s, _, _) = await_done(
5673 &handle,
5674 &sent,
5675 5,
5676 op(5, FS_OP_SYMLINK, "gone/dangling", "ln", 0, FS_OP_NO_CAS),
5677 );
5678 assert_eq!(s, FS_DONE_OK);
5679 assert_eq!(
5680 fs::read_link(root.join("ln")).unwrap(),
5681 Path::new("gone/dangling")
5682 );
5683 fs::create_dir(root.join("d")).unwrap();
5685 let (s, _, _) = await_done(
5686 &handle,
5687 &sent,
5688 6,
5689 op(6, FS_OP_SYMLINK, "x", "d", 0, FS_OP_NO_CAS),
5690 );
5691 assert_eq!(s, FS_DONE_WRONG_TYPE);
5692
5693 let (s, fh, _) = await_done(&handle, &sent, 10, write_req(10, "f.txt", 0, 0, b"hello"));
5695 assert_eq!(s, FS_DONE_OK);
5696 let (s, lh, _) = await_done(
5697 &handle,
5698 &sent,
5699 11,
5700 op(11, FS_OP_HARDLINK, "f.txt", "f2.txt", 0, 0),
5701 );
5702 assert_eq!(s, FS_DONE_OK);
5703 assert_eq!(lh, fh);
5704 assert_eq!(fs::read(root.join("f2.txt")).unwrap(), b"hello");
5705 {
5706 use std::os::unix::fs::MetadataExt;
5707 assert_eq!(
5708 fs::metadata(root.join("f.txt")).unwrap().ino(),
5709 fs::metadata(root.join("f2.txt")).unwrap().ino()
5710 );
5711 }
5712 let (s, _, _) = await_done(
5714 &handle,
5715 &sent,
5716 12,
5717 op(12, FS_OP_HARDLINK, "f.txt", "f2.txt", 0, 0),
5718 );
5719 assert_eq!(s, FS_DONE_CONFLICT);
5720 let (s, _, _) = await_done(
5722 &handle,
5723 &sent,
5724 13,
5725 op(13, FS_OP_HARDLINK, "ln", "ln2", 0, 0),
5726 );
5727 assert_eq!(s, FS_DONE_WRONG_TYPE);
5728 let (s, _, _) = await_done(
5730 &handle,
5731 &sent,
5732 14,
5733 op(14, FS_OP_HARDLINK, "nope", "n2", 0, 0),
5734 );
5735 assert_eq!(s, FS_DONE_NOT_FOUND);
5736
5737 std::os::unix::fs::symlink("ext-target", root.join("ext")).unwrap();
5741 hint.send(Hint::Dirty(root.join("ext")));
5742 let mut mirror = FsMirror::new();
5743 let mut seen = 0usize;
5744 let deadline = Instant::now() + Duration::from_secs(5);
5745 loop {
5746 for msg in sent.lock().unwrap().clone()[seen..].iter() {
5747 seen += 1;
5748 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5749 let id = mirror.apply_update(msg).unwrap();
5750 handle.command(Command::Ack(id));
5751 }
5752 }
5753 if mirror
5754 .live
5755 .get("ext")
5756 .is_some_and(|n| n.content.as_deref() == Some(&b"ext-target"[..]))
5757 && mirror.live.contains_key("ln")
5758 {
5759 break;
5760 }
5761 assert!(Instant::now() < deadline, "symlink content never synced");
5762 std::thread::sleep(Duration::from_millis(2));
5763 }
5764 let node = mirror.live.get("ext").unwrap();
5765 assert_eq!(node.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5766 assert_eq!(node.hash, blake3_128(b"ext-target"));
5767 assert_eq!(node.size, "ext-target".len() as u64);
5768 let own = mirror.live.get("ln").unwrap();
5769 assert_eq!(own.entry_flags & FS_ENTRY_TYPE_MASK, FS_ENTRY_SYMLINK);
5770 assert_eq!(own.hash, blake3_128(b"gone/dangling"));
5771 handle.command(Command::Fetch {
5772 nonce: 20,
5773 path: "ln".into(),
5774 });
5775 let deadline = Instant::now() + Duration::from_secs(5);
5776 'fetch: loop {
5777 for msg in sent.lock().unwrap().iter() {
5778 if msg[0] == blit_remote::fs::S2C_FS_FILE
5779 && let Some((20, status, data)) = blit_remote::fs::parse_fs_file(msg)
5780 {
5781 assert_eq!(status, FS_FILE_OK);
5782 assert_eq!(data, b"gone/dangling");
5783 break 'fetch;
5784 }
5785 }
5786 assert!(Instant::now() < deadline, "no FS_FILE for the symlink");
5787 std::thread::sleep(Duration::from_millis(2));
5788 }
5789
5790 handle.command(Command::Stop);
5791 let _ = fs::remove_dir_all(&root);
5792 }
5793
5794 #[cfg(unix)]
5798 #[test]
5799 fn unreadable_content_recovers_when_readable() {
5800 use std::os::unix::fs::PermissionsExt;
5801 let root = temp_dir();
5802 let file = root.join("secret.txt");
5803 fs::write(&file, b"classified").unwrap();
5804 fs::set_permissions(&file, fs::Permissions::from_mode(0o000)).unwrap();
5805 if fs::read(&file).is_ok() {
5807 let _ = fs::remove_dir_all(&root);
5808 return;
5809 }
5810 let (sent, handle, _hint) = drive_engine(&root);
5811
5812 let mut mirror = FsMirror::new();
5813 let mut acked = 0usize;
5814 let pump = |mirror: &mut FsMirror, acked: &mut usize| {
5815 for msg in sent.lock().unwrap().clone()[*acked..].iter() {
5816 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5817 let id = mirror.apply_update(msg).unwrap();
5818 handle.command(Command::Ack(id));
5819 *acked += 1;
5820 } else {
5821 *acked += 1;
5822 }
5823 }
5824 };
5825 for _ in 0..200 {
5827 pump(&mut mirror, &mut acked);
5828 if let Some(node) = mirror.live.get("secret.txt")
5829 && node.entry_flags & FS_ENTRY_UNREADABLE != 0
5830 {
5831 break;
5832 }
5833 std::thread::sleep(Duration::from_millis(5));
5834 }
5835 let node = mirror.live.get("secret.txt").expect("file present");
5836 assert_ne!(
5837 node.entry_flags & FS_ENTRY_UNREADABLE,
5838 0,
5839 "expected UNREADABLE"
5840 );
5841 assert!(node.content.is_none());
5842
5843 fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
5845 let deadline = Instant::now() + Duration::from_secs(10);
5846 loop {
5847 pump(&mut mirror, &mut acked);
5848 if mirror.live["secret.txt"].content.as_deref() == Some(&b"classified"[..]) {
5849 break;
5850 }
5851 assert!(Instant::now() < deadline, "content never recovered");
5852 std::thread::sleep(Duration::from_millis(5));
5853 }
5854 handle.command(Command::Stop);
5855 let _ = fs::remove_dir_all(&root);
5856 }
5857
5858 #[cfg(unix)]
5862 #[test]
5863 fn retry_survives_rename() {
5864 use std::os::unix::fs::PermissionsExt;
5865 let root = temp_dir();
5866 let old = root.join("a.txt");
5867 fs::write(&old, b"payload").unwrap();
5868 fs::set_permissions(&old, fs::Permissions::from_mode(0o000)).unwrap();
5869 if fs::read(&old).is_ok() {
5870 let _ = fs::remove_dir_all(&root);
5871 return;
5872 }
5873 let (sent, handle, hint_tx) = drive_engine(&root);
5874
5875 let mut mirror = FsMirror::new();
5876 let mut acked = 0usize;
5877 let pump = |mirror: &mut FsMirror, acked: &mut usize| {
5878 for msg in sent.lock().unwrap().clone()[*acked..].iter() {
5879 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
5880 let id = mirror.apply_update(msg).unwrap();
5881 handle.command(Command::Ack(id));
5882 }
5883 *acked += 1;
5884 }
5885 };
5886 for _ in 0..200 {
5888 pump(&mut mirror, &mut acked);
5889 if mirror.live.contains_key("a.txt") {
5890 break;
5891 }
5892 std::thread::sleep(Duration::from_millis(5));
5893 }
5894 assert!(mirror.live["a.txt"].content.is_none());
5895
5896 fs::set_permissions(&old, fs::Permissions::from_mode(0o644)).unwrap();
5899 let new = root.join("b.txt");
5900 fs::rename(&old, &new).unwrap();
5901 hint_tx.send(Hint::Dirty(old));
5902 hint_tx.send(Hint::Dirty(new));
5903 let deadline = Instant::now() + Duration::from_secs(10);
5904 loop {
5905 pump(&mut mirror, &mut acked);
5906 if mirror.live.get("b.txt").and_then(|n| n.content.as_deref()) == Some(&b"payload"[..])
5907 {
5908 break;
5909 }
5910 assert!(
5911 Instant::now() < deadline,
5912 "content did not follow the rename: {:?}",
5913 mirror.live.get("b.txt")
5914 );
5915 std::thread::sleep(Duration::from_millis(5));
5916 }
5917 assert!(!mirror.live.contains_key("a.txt"));
5918 handle.command(Command::Stop);
5919 let _ = fs::remove_dir_all(&root);
5920 }
5921
5922 #[test]
5923 fn diff_plain_changes() {
5924 let mut prev = Index::new();
5925 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5926 prev.insert("a".into(), meta(FS_ENTRY_FILE, 1, 1, 2));
5927 prev.insert("b".into(), meta(FS_ENTRY_FILE, 1, 1, 3));
5928 let mut curr = Index::new();
5929 curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5930 curr.insert("a".into(), meta(FS_ENTRY_FILE, 2, 2, 2)); curr.insert("c".into(), meta(FS_ENTRY_FILE, 1, 1, 9)); let ops = diff(&prev, &curr);
5933 assert!(ops.contains(&DiffOp::Delete { path: "b".into() }));
5934 assert!(ops.contains(&DiffOp::Upsert {
5935 path: "a".into(),
5936 content_changed: true
5937 }));
5938 assert!(ops.contains(&DiffOp::Upsert {
5939 path: "c".into(),
5940 content_changed: true
5941 }));
5942 assert_eq!(ops.len(), 3);
5943 }
5944
5945 #[test]
5949 fn diff_mass_delete_prunes_to_ancestors() {
5950 let mut prev = Index::new();
5951 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5952 prev.insert("a".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5953 prev.insert("a!x".into(), meta(FS_ENTRY_FILE, 1, 1, 3));
5954 prev.insert("a/b".into(), meta(FS_ENTRY_DIR, 0, 0, 4));
5955 prev.insert("a/b/c".into(), meta(FS_ENTRY_FILE, 1, 1, 5));
5956 prev.insert("ab".into(), meta(FS_ENTRY_FILE, 1, 1, 6));
5957 let mut curr = Index::new();
5958 curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5959 let mut deleted: Vec<String> = diff(&prev, &curr)
5960 .into_iter()
5961 .map(|op| match op {
5962 DiffOp::Delete { path } => path,
5963 other => panic!("unexpected {other:?}"),
5964 })
5965 .collect();
5966 deleted.sort();
5967 assert_eq!(deleted, ["a", "a!x", "ab"].map(String::from));
5968 }
5969
5970 #[test]
5974 fn diff_changed_matches_full_diff() {
5975 let mut prev = Index::new();
5976 prev.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5977 prev.insert("d".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5978 prev.insert("d/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
5979 prev.insert("gone".into(), meta(FS_ENTRY_DIR, 0, 0, 4));
5980 prev.insert("gone/x".into(), meta(FS_ENTRY_FILE, 1, 1, 5));
5981 prev.insert("same".into(), meta(FS_ENTRY_FILE, 2, 2, 6));
5982 prev.insert("touched".into(), meta(FS_ENTRY_FILE, 3, 3, 7));
5983 let mut curr = Index::new();
5984 curr.insert("".into(), meta(FS_ENTRY_DIR, 0, 0, 1));
5985 curr.insert("e".into(), meta(FS_ENTRY_DIR, 0, 0, 2));
5986 curr.insert("e/f".into(), meta(FS_ENTRY_FILE, 5, 10, 3));
5987 curr.insert("same".into(), meta(FS_ENTRY_FILE, 2, 2, 6));
5988 curr.insert("touched".into(), meta(FS_ENTRY_FILE, 9, 9, 7));
5989 curr.insert("new".into(), meta(FS_ENTRY_FILE, 1, 1, 8));
5990 let changed: std::collections::BTreeSet<String> = [
5991 "d", "d/f", "e", "e/f", "gone", "gone/x", "touched", "new", "same",
5992 ]
5993 .into_iter()
5994 .map(String::from)
5995 .collect();
5996 let full = diff(&prev, &curr);
5997 assert_eq!(diff_changed(&prev, &curr, &changed), full);
5998 assert!(full.contains(&DiffOp::Move {
5999 from: "d".into(),
6000 to: "e".into()
6001 }));
6002 assert!(full.contains(&DiffOp::Delete {
6003 path: "gone".into()
6004 }));
6005 assert!(!full.iter().any(
6006 |op| matches!(op, DiffOp::Upsert { path, .. } | DiffOp::Delete { path } if path == "same")
6007 ));
6008 }
6009
6010 #[test]
6011 fn retry_backoff_doubles_and_caps() {
6012 let latency = Duration::from_millis(20);
6013 assert_eq!(retry_backoff(1, latency), Duration::from_millis(20));
6014 assert_eq!(retry_backoff(2, latency), Duration::from_millis(40));
6015 assert_eq!(retry_backoff(5, latency), Duration::from_millis(320));
6016 assert_eq!(retry_backoff(8, latency), Duration::from_secs(2));
6017 assert_eq!(retry_backoff(64, latency), Duration::from_secs(2));
6019 }
6020
6021 #[test]
6024 fn engine_converges() {
6025 let root = temp_dir();
6026 fs::write(root.join("hello.txt"), b"hello").unwrap();
6027 fs::create_dir(root.join("sub")).unwrap();
6028 fs::write(root.join("sub/nested.txt"), b"nested").unwrap();
6029
6030 let shared = open_root_unwatched(test_key(&root));
6031 let hint_tx = shared.hint_sender();
6032 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6033 let sent2 = sent.clone();
6034 let opts = SyncOptions {
6035 content: true,
6036 latency: Duration::from_millis(5),
6037 ..Default::default()
6038 };
6039 let handle = start_sync(
6040 &shared,
6041 7,
6042 opts,
6043 Box::new(move |msg| {
6044 sent2.lock().unwrap().push(msg);
6045 true
6046 }),
6047 );
6048
6049 let wait_updates = |min: usize| {
6050 for _ in 0..200 {
6051 if sent.lock().unwrap().len() >= min {
6052 return;
6053 }
6054 std::thread::sleep(Duration::from_millis(5));
6055 }
6056 panic!("timed out waiting for {min} updates");
6057 };
6058
6059 wait_updates(1);
6060 let mut mirror = FsMirror::new();
6061 let mut acked = 0usize;
6062 let apply_all = |mirror: &mut FsMirror, acked: &mut usize| {
6063 let msgs = sent.lock().unwrap().clone();
6064 for msg in &msgs[*acked..] {
6065 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6066 let id = mirror.apply_update(msg).expect("valid update");
6067 handle.command(Command::Ack(id));
6068 }
6069 }
6070 *acked = msgs.len();
6071 };
6072 apply_all(&mut mirror, &mut acked);
6073 assert_eq!(
6074 mirror.live["hello.txt"].content.as_deref(),
6075 Some(&b"hello"[..])
6076 );
6077 assert_eq!(
6078 mirror.live["sub/nested.txt"].content.as_deref(),
6079 Some(&b"nested"[..])
6080 );
6081 assert!(mirror.live.contains_key("")); assert!(mirror.live.contains_key("sub"));
6083
6084 fs::write(root.join("hello.txt"), b"changed").unwrap();
6086 fs::remove_file(root.join("sub/nested.txt")).unwrap();
6087 fs::write(root.join("sub/other.txt"), b"other").unwrap();
6088 hint_tx.send(Hint::Dirty(root.join("hello.txt")));
6089 hint_tx.send(Hint::Dirty(root.join("sub")));
6090 wait_updates(acked + 1);
6091 std::thread::sleep(Duration::from_millis(30));
6092 apply_all(&mut mirror, &mut acked);
6093 assert_eq!(
6094 mirror.live["hello.txt"].content.as_deref(),
6095 Some(&b"changed"[..])
6096 );
6097 assert!(!mirror.live.contains_key("sub/nested.txt"));
6098 assert_eq!(
6099 mirror.live["sub/other.txt"].content.as_deref(),
6100 Some(&b"other"[..])
6101 );
6102
6103 fs::write(root.join("late.txt"), b"late").unwrap();
6105 hint_tx.send(Hint::Rescan);
6106 wait_updates(acked + 1);
6107 std::thread::sleep(Duration::from_millis(30));
6108 apply_all(&mut mirror, &mut acked);
6109 assert_eq!(
6110 mirror.live["late.txt"].content.as_deref(),
6111 Some(&b"late"[..])
6112 );
6113
6114 handle.command(Command::Stop);
6115 let _ = fs::remove_dir_all(&root);
6116 }
6117
6118 #[test]
6122 fn snapshot_respects_ack_window() {
6123 let root = temp_dir();
6124 for i in 0..50 {
6125 fs::write(root.join(format!("f{i:02}.txt")), vec![b'x'; 256]).unwrap();
6126 }
6127 let shared = open_root_unwatched(test_key(&root));
6128 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6129 let sent2 = sent.clone();
6130 let window = 2048usize;
6131 let opts = SyncOptions {
6132 content: true,
6133 latency: Duration::from_millis(5),
6134 window_bytes: window,
6135 batch_target: 512,
6136 ..Default::default()
6137 };
6138 let handle = start_sync(
6139 &shared,
6140 3,
6141 opts,
6142 Box::new(move |msg| {
6143 sent2.lock().unwrap().push(msg);
6144 true
6145 }),
6146 );
6147
6148 let mut mirror = FsMirror::new();
6149 let mut applied = 0usize;
6150 let mut synced = false;
6151 for _ in 0..400 {
6152 std::thread::sleep(Duration::from_millis(5));
6153 let msgs = sent.lock().unwrap().clone();
6154 let outstanding: usize = msgs[applied..].iter().map(|m| m.len()).sum();
6157 let max_update = msgs.iter().map(|m| m.len()).max().unwrap_or(0);
6158 assert!(
6159 outstanding <= window + max_update,
6160 "engine outran the window: {outstanding} unacked bytes"
6161 );
6162 for msg in &msgs[applied..] {
6163 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6164 let flags = msg[7];
6165 let id = mirror.apply_update(msg).expect("valid update");
6166 handle.command(Command::Ack(id));
6167 if flags & FS_UPDATE_SYNC != 0 {
6168 synced = true;
6169 }
6170 }
6171 }
6172 applied = msgs.len();
6173 if synced {
6174 break;
6175 }
6176 }
6177 assert!(synced, "snapshot never reached SYNC");
6178 assert_eq!(
6179 mirror
6180 .live
6181 .iter()
6182 .filter(|(_, n)| n.content.is_some())
6183 .count(),
6184 50
6185 );
6186 assert!(
6188 applied > 5,
6189 "expected a paced series, got {applied} updates"
6190 );
6191
6192 handle.command(Command::Stop);
6193 let _ = fs::remove_dir_all(&root);
6194 }
6195
6196 #[test]
6198 fn native_backend_delivers_changes() {
6199 let root = temp_dir().canonicalize().unwrap();
6203 fs::write(root.join("seed.txt"), b"seed").unwrap();
6204
6205 let shared = open_root(test_key(&root)).expect("arm native watch");
6207 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6208 let sent2 = sent.clone();
6209 let opts = SyncOptions {
6210 content: true,
6211 latency: Duration::from_millis(5),
6212 ..Default::default()
6213 };
6214 let handle = start_sync(
6215 &shared,
6216 9,
6217 opts,
6218 Box::new(move |msg| {
6219 sent2.lock().unwrap().push(msg);
6220 true
6221 }),
6222 );
6223
6224 let mut mirror = FsMirror::new();
6225 let mut applied = 0usize;
6226 let apply_all = |mirror: &mut FsMirror, applied: &mut usize| {
6227 let msgs = sent.lock().unwrap().clone();
6228 for msg in &msgs[*applied..] {
6229 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6230 let id = mirror.apply_update(msg).expect("valid update");
6231 handle.command(Command::Ack(id));
6232 }
6233 }
6234 *applied = msgs.len();
6235 };
6236
6237 for _ in 0..200 {
6239 apply_all(&mut mirror, &mut applied);
6240 if mirror.live.contains_key("seed.txt") {
6241 break;
6242 }
6243 std::thread::sleep(Duration::from_millis(5));
6244 }
6245 assert!(mirror.live.contains_key("seed.txt"));
6246
6247 fs::create_dir(root.join("dir")).unwrap();
6249 fs::write(root.join("dir/new.txt"), b"native").unwrap();
6250 let deadline = Instant::now() + Duration::from_secs(10);
6251 loop {
6252 apply_all(&mut mirror, &mut applied);
6253 if mirror
6254 .live
6255 .get("dir/new.txt")
6256 .is_some_and(|n| n.content.as_deref() == Some(b"native"))
6257 {
6258 break;
6259 }
6260 assert!(
6261 Instant::now() < deadline,
6262 "native backend never delivered the change; live = {:?}",
6263 mirror.live.keys().collect::<Vec<_>>()
6264 );
6265 std::thread::sleep(Duration::from_millis(10));
6266 }
6267
6268 handle.command(Command::Stop);
6269 let _ = fs::remove_dir_all(&root);
6270 }
6271
6272 #[test]
6277 fn single_native_backend_follows_file() {
6278 let dir = temp_dir().canonicalize().unwrap();
6279 let file = dir.join("watched.txt");
6280 fs::write(&file, b"one").unwrap();
6281
6282 let shared = open_single_root(file.clone()).expect("arm native watch on parent");
6283 assert!(shared.is_single());
6284 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6285 let sent2 = sent.clone();
6286 let opts = SyncOptions {
6287 content: true,
6288 recursive: false,
6289 latency: Duration::from_millis(5),
6290 ..Default::default()
6291 };
6292 let handle = start_sync(
6293 &shared,
6294 15,
6295 opts,
6296 Box::new(move |msg| {
6297 sent2.lock().unwrap().push(msg);
6298 true
6299 }),
6300 );
6301
6302 let mut mirror = FsMirror::new();
6303 let mut seen = 0usize;
6304 pump_until(&sent, &handle, &mut mirror, &mut seen, "initial", |m| {
6305 m.live
6306 .get("")
6307 .is_some_and(|n| n.content.as_deref() == Some(&b"one"[..]))
6308 });
6309 assert_eq!(mirror.live.len(), 1);
6310
6311 fs::write(&file, b"two").unwrap();
6312 pump_until(
6313 &sent,
6314 &handle,
6315 &mut mirror,
6316 &mut seen,
6317 "native modify",
6318 |m| {
6319 m.live
6320 .get("")
6321 .is_some_and(|n| n.content.as_deref() == Some(&b"two"[..]))
6322 },
6323 );
6324
6325 fs::remove_file(&file).unwrap();
6326 pump_until(
6327 &sent,
6328 &handle,
6329 &mut mirror,
6330 &mut seen,
6331 "native delete",
6332 |m| m.live.is_empty(),
6333 );
6334 assert_eq!(count_closed(&sent), 0, "delete must not close the sync");
6335
6336 fs::write(&file, b"three").unwrap();
6337 pump_until(
6338 &sent,
6339 &handle,
6340 &mut mirror,
6341 &mut seen,
6342 "native recreate",
6343 |m| {
6344 m.live
6345 .get("")
6346 .is_some_and(|n| n.content.as_deref() == Some(&b"three"[..]))
6347 },
6348 );
6349
6350 handle.command(Command::Stop);
6351 let _ = fs::remove_dir_all(&dir);
6352 }
6353
6354 #[test]
6365 fn property_random_mutations_converge() {
6366 for seed in [1u64, 7, 42, 0xdead_beef] {
6367 property_run(seed);
6368 }
6369 }
6370
6371 fn xorshift(state: &mut u64) -> u64 {
6372 *state ^= *state << 13;
6373 *state ^= *state >> 7;
6374 *state ^= *state << 17;
6375 *state
6376 }
6377
6378 fn scan_disk(root: &Path) -> BTreeMap<String, Option<Vec<u8>>> {
6379 fn walk(map: &mut BTreeMap<String, Option<Vec<u8>>>, abs: &Path, rel: &str) {
6380 let Ok(md) = fs::symlink_metadata(abs) else {
6381 return;
6382 };
6383 if md.is_dir() {
6384 map.insert(rel.to_string(), None);
6385 let Ok(entries) = fs::read_dir(abs) else {
6386 return;
6387 };
6388 for entry in entries.flatten() {
6389 let name = entry.file_name().to_string_lossy().into_owned();
6390 let child_rel = if rel.is_empty() {
6391 name.clone()
6392 } else {
6393 format!("{rel}/{name}")
6394 };
6395 walk(map, &entry.path(), &child_rel);
6396 }
6397 } else if md.is_file() {
6398 map.insert(rel.to_string(), fs::read(abs).ok());
6399 }
6400 }
6401 let mut map = BTreeMap::new();
6402 walk(&mut map, root, "");
6403 map
6404 }
6405
6406 fn mirror_state(mirror: &FsMirror) -> BTreeMap<String, Option<Vec<u8>>> {
6407 mirror
6408 .live
6409 .iter()
6410 .map(|(path, node)| {
6411 let content = if node.entry_flags & FS_ENTRY_TYPE_MASK == FS_ENTRY_FILE {
6412 node.content.clone()
6413 } else {
6414 None
6415 };
6416 (path.clone(), content)
6417 })
6418 .collect()
6419 }
6420
6421 struct PropClient {
6423 sent: Arc<Mutex<Vec<Vec<u8>>>>,
6424 handle: SyncHandle,
6425 mirror: FsMirror,
6426 applied: usize,
6427 highest_unacked: Option<u32>,
6428 }
6429
6430 impl PropClient {
6431 fn start(shared: &Arc<SharedRootHandle>, sync_id: u16) -> Self {
6432 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6433 let sent2 = sent.clone();
6434 let opts = SyncOptions {
6435 content: true,
6436 latency: Duration::from_millis(3),
6437 window_bytes: 4096,
6438 batch_target: 1024,
6439 ..Default::default()
6440 };
6441 let handle = start_sync(
6442 shared,
6443 sync_id,
6444 opts,
6445 Box::new(move |msg| {
6446 sent2.lock().unwrap().push(msg);
6447 true
6448 }),
6449 );
6450 PropClient {
6451 sent,
6452 handle,
6453 mirror: FsMirror::new(),
6454 applied: 0,
6455 highest_unacked: None,
6456 }
6457 }
6458
6459 fn pump(&mut self, rng: &mut u64, flush: bool) {
6463 use blit_remote::fs::S2C_FS_UPDATE;
6464 let msgs = self.sent.lock().unwrap().clone();
6465 for msg in &msgs[self.applied..] {
6466 if msg[0] == S2C_FS_UPDATE {
6467 let id = self.mirror.apply_update(msg).expect("valid update");
6468 self.highest_unacked = Some(id);
6469 }
6470 }
6471 self.applied = msgs.len();
6472 if let Some(id) = self.highest_unacked
6473 && (flush || xorshift(rng).is_multiple_of(2))
6474 {
6475 self.handle.command(Command::Ack(id));
6476 self.highest_unacked = None;
6477 }
6478 }
6479 }
6480
6481 fn property_run(seed: u64) {
6482 let root = temp_dir();
6483 let shared = open_root_unwatched(test_key(&root));
6484 let hint_tx = shared.hint_sender();
6485 let mut clients = [
6488 PropClient::start(&shared, 11),
6489 PropClient::start(&shared, 12),
6490 ];
6491
6492 let mut rng = seed | 1;
6493 let dirs = ["", "d0", "d1", "d0/d2"];
6494 let names = ["f0", "f1", "f2", "f3"];
6495
6496 for _round in 0..25 {
6497 let mutations = 1 + xorshift(&mut rng) % 3;
6498 for _ in 0..mutations {
6499 let dir = dirs[(xorshift(&mut rng) % dirs.len() as u64) as usize];
6500 let name = names[(xorshift(&mut rng) % names.len() as u64) as usize];
6501 let rel: PathBuf = if dir.is_empty() {
6502 name.into()
6503 } else {
6504 Path::new(dir).join(name)
6505 };
6506 let abs = root.join(&rel);
6507 match xorshift(&mut rng) % 5 {
6508 0 | 1 => {
6510 let _ = fs::create_dir_all(abs.parent().unwrap());
6511 let len = (xorshift(&mut rng) % 64) as usize;
6512 let byte = (xorshift(&mut rng) & 0xFF) as u8;
6513 let _ = fs::write(&abs, vec![byte; len]);
6514 }
6515 2 => {
6517 let _ = fs::create_dir_all(&abs);
6518 }
6519 3 => {
6521 if abs.is_dir() {
6522 let _ = fs::remove_dir_all(&abs);
6523 } else {
6524 let _ = fs::remove_file(&abs);
6525 }
6526 }
6527 _ => {
6529 let target = abs.with_file_name(
6530 names[(xorshift(&mut rng) % names.len() as u64) as usize],
6531 );
6532 if target != abs {
6533 let _ = fs::rename(&abs, &target);
6534 hint_tx.send(Hint::Dirty(target));
6535 }
6536 }
6537 }
6538 hint_tx.send(Hint::Dirty(abs.clone()));
6540 hint_tx.send(Hint::Dirty(abs.parent().unwrap().to_path_buf()));
6541 }
6542 if xorshift(&mut rng).is_multiple_of(16) {
6544 hint_tx.send(Hint::Rescan);
6545 }
6546 for client in &mut clients {
6547 client.pump(&mut rng, false);
6548 }
6549 std::thread::sleep(Duration::from_millis(xorshift(&mut rng) % 8));
6550 }
6551
6552 let disk = scan_disk(&root);
6555 let deadline = Instant::now() + Duration::from_secs(30);
6556 loop {
6557 for client in &mut clients {
6558 client.pump(&mut rng, true);
6559 }
6560 if clients
6561 .iter()
6562 .all(|client| mirror_state(&client.mirror) == disk)
6563 {
6564 break;
6565 }
6566 assert!(
6567 Instant::now() < deadline,
6568 "seed {seed}: mirrors never converged\n first: {:?}\n second: {:?}\n disk: {:?}",
6569 mirror_state(&clients[0].mirror).keys().collect::<Vec<_>>(),
6570 mirror_state(&clients[1].mirror).keys().collect::<Vec<_>>(),
6571 disk.keys().collect::<Vec<_>>(),
6572 );
6573 std::thread::sleep(Duration::from_millis(5));
6574 }
6575
6576 for client in &clients {
6577 client.handle.command(Command::Stop);
6578 }
6579 let _ = fs::remove_dir_all(&root);
6580 }
6581
6582 #[test]
6585 fn shared_root_serves_multiple_clients() {
6586 let root = temp_dir();
6587 fs::write(root.join("a.txt"), b"alpha").unwrap();
6588 let shared = open_root_unwatched(test_key(&root));
6589 let joined = open_root_unwatched(test_key(&root));
6590 assert!(Arc::ptr_eq(&shared, &joined));
6591 let hint_tx = shared.hint_sender();
6592
6593 let start = |sync_id: u16| {
6594 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6595 let sent2 = sent.clone();
6596 let opts = SyncOptions {
6597 content: true,
6598 latency: Duration::from_millis(5),
6599 ..Default::default()
6600 };
6601 let handle = start_sync(
6602 &shared,
6603 sync_id,
6604 opts,
6605 Box::new(move |msg| {
6606 sent2.lock().unwrap().push(msg);
6607 true
6608 }),
6609 );
6610 (sent, handle)
6611 };
6612 let (sent_a, handle_a) = start(21);
6613 let (sent_b, handle_b) = start(22);
6614
6615 let converge = |sent: &Arc<Mutex<Vec<Vec<u8>>>>,
6616 handle: &SyncHandle,
6617 mirror: &mut FsMirror,
6618 applied: &mut usize,
6619 path: &str,
6620 want: &[u8]| {
6621 for _ in 0..400 {
6622 let msgs = sent.lock().unwrap().clone();
6623 for msg in &msgs[*applied..] {
6624 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6625 let id = mirror.apply_update(msg).expect("valid update");
6626 handle.command(Command::Ack(id));
6627 }
6628 }
6629 *applied = msgs.len();
6630 if mirror
6631 .live
6632 .get(path)
6633 .is_some_and(|n| n.content.as_deref() == Some(want))
6634 {
6635 return;
6636 }
6637 std::thread::sleep(Duration::from_millis(5));
6638 }
6639 panic!("mirror never saw {path}");
6640 };
6641
6642 let mut mirror_a = FsMirror::new();
6643 let mut mirror_b = FsMirror::new();
6644 let (mut applied_a, mut applied_b) = (0usize, 0usize);
6645 converge(
6646 &sent_a,
6647 &handle_a,
6648 &mut mirror_a,
6649 &mut applied_a,
6650 "a.txt",
6651 b"alpha",
6652 );
6653 converge(
6654 &sent_b,
6655 &handle_b,
6656 &mut mirror_b,
6657 &mut applied_b,
6658 "a.txt",
6659 b"alpha",
6660 );
6661
6662 fs::write(root.join("b.txt"), b"beta").unwrap();
6664 hint_tx.send(Hint::Dirty(root.join("b.txt")));
6665 converge(
6666 &sent_a,
6667 &handle_a,
6668 &mut mirror_a,
6669 &mut applied_a,
6670 "b.txt",
6671 b"beta",
6672 );
6673 converge(
6674 &sent_b,
6675 &handle_b,
6676 &mut mirror_b,
6677 &mut applied_b,
6678 "b.txt",
6679 b"beta",
6680 );
6681
6682 handle_a.command(Command::Stop);
6683 handle_b.command(Command::Stop);
6684 let _ = fs::remove_dir_all(&root);
6685 }
6686
6687 #[test]
6688 fn delta_roundtrips_through_client_apply() {
6689 use blit_remote::fs::apply_fs_delta;
6690 let cases: &[(&[u8], &[u8])] = &[
6691 (b"hello world", b"hello world and more"), (b"hello world", b"say: hello world"), (b"hello cruel world", b"hello kind world"), (b"hello world", b"hello"), (b"hello", b"goodbye"), (b"", b"from nothing"), (b"to nothing", b""), (b"same", b"same"), ];
6700 for (base, new) in cases {
6701 let ops = encode_delta(base, new);
6702 assert_eq!(
6703 apply_fs_delta(base, &ops).as_deref(),
6704 Some(*new),
6705 "case {:?} -> {:?}",
6706 base,
6707 new
6708 );
6709 }
6710 let base = vec![b'x'; 10_000];
6712 let mut new = base.clone();
6713 new.extend_from_slice(b"tail");
6714 let ops = encode_delta(&base, &new);
6715 assert!(
6716 ops.len() < 20,
6717 "append delta should be tiny, got {}",
6718 ops.len()
6719 );
6720 assert_eq!(apply_fs_delta(&base, &ops).unwrap(), new);
6721 }
6722
6723 #[test]
6724 fn blob_store_lru_eviction() {
6725 let mut store = BlobStore::new(1000);
6726 let blob = |b: u8| Arc::new(vec![b; 400]);
6727 store.put(1, blob(1));
6728 store.put(2, blob(2));
6729 store.get(1); store.put(3, blob(3)); assert!(store.get(2).is_none());
6732 assert!(store.get(1).is_some());
6733 assert!(store.get(3).is_some());
6734 store.put(4, Arc::new(vec![0; 2000]));
6736 assert!(store.get(4).is_none());
6737 }
6738
6739 #[test]
6743 fn engine_sends_deltas() {
6744 use blit_remote::fs::{FsContent, FsRecord, fs_records, fs_update_records};
6745
6746 let root = temp_dir();
6747 let big = vec![b'x'; 4096];
6748 fs::write(root.join("log.txt"), &big).unwrap();
6749
6750 let shared = open_root_unwatched(test_key(&root));
6751 let hint_tx = shared.hint_sender();
6752 let sent: Arc<Mutex<Vec<Vec<u8>>>> = Default::default();
6753 let sent2 = sent.clone();
6754 let opts = SyncOptions {
6755 content: true,
6756 latency: Duration::from_millis(5),
6757 ..Default::default()
6758 };
6759 let handle = start_sync(
6760 &shared,
6761 13,
6762 opts,
6763 Box::new(move |msg| {
6764 sent2.lock().unwrap().push(msg);
6765 true
6766 }),
6767 );
6768
6769 let mut mirror = FsMirror::new();
6770 let mut applied = 0usize;
6771 let mut kinds: Vec<(String, &'static str)> = Vec::new();
6773 let apply_all = |mirror: &mut FsMirror,
6774 applied: &mut usize,
6775 kinds: &mut Vec<(String, &'static str)>| {
6776 let msgs = sent.lock().unwrap().clone();
6777 for msg in &msgs[*applied..] {
6778 if msg[0] == blit_remote::fs::S2C_FS_UPDATE {
6779 let records = fs_update_records(msg).expect("decompress");
6780 for record in fs_records(&records) {
6781 if let FsRecord::Upsert { path, content, .. } = record {
6782 let kind = match content {
6783 FsContent::None => "none",
6784 FsContent::Full(_) => "full",
6785 FsContent::Delta(_) => "delta",
6786 };
6787 kinds.push((path.to_string(), kind));
6788 }
6789 }
6790 let id = mirror.apply_update(msg).expect("valid update");
6791 handle.command(Command::Ack(id));
6792 }
6793 }
6794 *applied = msgs.len();
6795 };
6796
6797 let wait_for = |sent: &Arc<Mutex<Vec<Vec<u8>>>>, min: usize| {
6798 for _ in 0..400 {
6799 if sent.lock().unwrap().len() >= min {
6800 std::thread::sleep(Duration::from_millis(20));
6801 return;
6802 }
6803 std::thread::sleep(Duration::from_millis(5));
6804 }
6805 panic!("timed out waiting for {min} messages");
6806 };
6807
6808 wait_for(&sent, 1);
6810 apply_all(&mut mirror, &mut applied, &mut kinds);
6811 assert!(kinds.contains(&("log.txt".into(), "full")));
6812 assert_eq!(mirror.live["log.txt"].content.as_deref(), Some(&big[..]));
6813
6814 kinds.clear();
6816 let mut appended = big.clone();
6817 appended.extend_from_slice(b"appended tail");
6818 fs::write(root.join("log.txt"), &appended).unwrap();
6819 hint_tx.send(Hint::Dirty(root.join("log.txt")));
6820 wait_for(&sent, applied + 1);
6821 apply_all(&mut mirror, &mut applied, &mut kinds);
6822 assert!(
6823 kinds.contains(&("log.txt".into(), "delta")),
6824 "expected a delta record, got {kinds:?}"
6825 );
6826 assert_eq!(
6827 mirror.live["log.txt"].content.as_deref(),
6828 Some(&appended[..])
6829 );
6830
6831 kinds.clear();
6834 std::thread::sleep(Duration::from_millis(10)); fs::write(root.join("log.txt"), &appended).unwrap();
6836 hint_tx.send(Hint::Dirty(root.join("log.txt")));
6837 wait_for(&sent, applied + 1);
6838 apply_all(&mut mirror, &mut applied, &mut kinds);
6839 assert!(
6840 kinds.contains(&("log.txt".into(), "none")),
6841 "expected metadata-only, got {kinds:?}"
6842 );
6843 assert_eq!(
6844 mirror.live["log.txt"].content.as_deref(),
6845 Some(&appended[..])
6846 );
6847 assert_eq!(
6848 mirror.live["log.txt"].entry_flags & blit_remote::fs::FS_ENTRY_NO_CONTENT,
6849 0
6850 );
6851
6852 handle.command(Command::Stop);
6853 let _ = fs::remove_dir_all(&root);
6854 }
6855
6856 #[test]
6857 fn read_verified_stable() {
6858 let root = temp_dir();
6859 let f = root.join("x");
6860 fs::write(&f, b"stable").unwrap();
6861 match read_verified(&f) {
6862 ReadOutcome::Stable(data) => assert_eq!(data, b"stable"),
6863 _ => panic!("expected stable read"),
6864 }
6865 match read_verified(&root.join("missing")) {
6866 ReadOutcome::Unreadable => {}
6867 _ => panic!("expected unreadable"),
6868 }
6869 let _ = fs::remove_dir_all(&root);
6870 }
6871}