diff --git a/core/src/ops/indexing/handlers/ephemeral.rs b/core/src/ops/indexing/handlers/ephemeral.rs index c837c252a..5c2f2cfaa 100644 --- a/core/src/ops/indexing/handlers/ephemeral.rs +++ b/core/src/ops/indexing/handlers/ephemeral.rs @@ -144,15 +144,11 @@ impl EphemeralEventHandler { // Check if the parent is being watched (shallow watch = immediate children only) let watched_paths = context.ephemeral_cache().watched_paths(); - // Compare the spelling the platform will actually report. On macOS the - // writable disk is reachable twice, and a watch registered under one - // spelling delivers events under the other, so exact equality here - // silently drops every event for a root stored the long way round. - let parent = sd_fs_watcher::watchable_spelling(parent); - let matching_root = watched_paths.iter().find(|watched| { - // For shallow watches, parent must match the watched path - parent == sd_fs_watcher::watchable_spelling(watched) - }); + // Events arrive in the spelling the watch is stored under, translated at + // the watcher edge, so a shallow watch is plain equality on the parent. + let matching_root = watched_paths + .iter() + .find(|watched| parent == watched.as_path()); let Some(root_path) = matching_root else { // Not under any ephemeral watch diff --git a/crates/fs-watcher/src/lib.rs b/crates/fs-watcher/src/lib.rs index 75ea0cb57..8c14f8020 100644 --- a/crates/fs-watcher/src/lib.rs +++ b/crates/fs-watcher/src/lib.rs @@ -57,13 +57,15 @@ mod config; mod error; mod event; mod platform; +mod spelling; mod watcher; pub use config::{EventFilters, WatchConfig, WatcherConfig}; pub use error::{Result, WatcherError}; pub use event::{FsEvent, FsEventKind, RawEventKind, RawNotifyEvent}; pub use platform::{EventHandler, PlatformHandler}; -pub use watcher::{watchable_spelling, FsWatcher, WatchHandle}; +pub use spelling::{restore, subscriptions, Subscription}; +pub use watcher::{FsWatcher, WatchHandle}; // Re-export notify types that users might need pub use notify::RecursiveMode; diff --git a/crates/fs-watcher/src/spelling.rs b/crates/fs-watcher/src/spelling.rs new file mode 100644 index 000000000..0b0dd1352 --- /dev/null +++ b/crates/fs-watcher/src/spelling.rs @@ -0,0 +1,269 @@ +//! Translating between the spelling a watch is stored under and the spellings +//! the platform will actually deliver events for. +//! +//! macOS reaches the writable half of the disk twice. `/Users/me` and +//! `/System/Volumes/Data/Users/me` are the same directory: same device, same +//! inode, joined by a firmlink. `realpath` does not resolve firmlinks, so both +//! spellings survive canonicalization, and FSEvents subscribes to exactly one +//! of them. Registered the long way round it accepts the watch, reports no +//! error, and delivers nothing. +//! +//! The mapping is not guesswork: `/usr/share/firmlinks` publishes it, one line +//! per link, root-volume path and Data-volume-relative path separated by a tab. +//! +//! Everything above this module works in one spelling — the one the source +//! stores, which is the long one, because a whole-drive source is rooted at +//! `/System/Volumes/Data` and its records have to sit under their own root. +//! [`subscriptions`] translates outbound, at registration, and [`restore`] +//! translates back inbound, so a path that leaves in FSEvents' spelling returns +//! in Spacedrive's. + +use std::path::{Path, PathBuf}; + +/// One platform subscription backing a watch. +/// +/// A watch is usually one of these. It is more than one when the requested root +/// spans several firmlinks, which is the whole-drive case: `/System/Volumes/Data` +/// has no single short spelling, so it fans out into the links beneath it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Subscription { + /// The path handed to the platform watcher. + pub subscribe: PathBuf, + /// The prefix an event arriving under `subscribe` is rewritten back to. + pub restore_to: PathBuf, +} + +impl Subscription { + /// A subscription that needs no translation. + fn direct(path: &Path) -> Self { + Self { + subscribe: path.to_path_buf(), + restore_to: path.to_path_buf(), + } + } +} + +/// Rewrite an event path from the spelling it arrived in to the spelling the +/// watch is stored under. +/// +/// Returns `None` when the path is not under this subscription, which is the +/// ordinary answer while looking for the one that owns it. +pub fn restore(subscription: &Subscription, path: &Path) -> Option { + let rest = path.strip_prefix(&subscription.subscribe).ok()?; + Some(subscription.restore_to.join(rest)) +} + +/// The platform subscriptions a watch on `path` requires. +/// +/// Never empty: a path with no firmlink translation subscribes to itself. +pub fn subscriptions(path: &Path) -> Vec { + #[cfg(target_os = "macos")] + { + macos::subscriptions(path) + } + #[cfg(not(target_os = "macos"))] + { + vec![Subscription::direct(path)] + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::Subscription; + use std::path::{Path, PathBuf}; + use std::sync::OnceLock; + + /// Where the Data volume mounts, and the root of every long spelling. + const DATA_VOLUME: &str = "/System/Volumes/Data"; + const FIRMLINKS: &str = "/usr/share/firmlinks"; + + /// One published firmlink: a path on the root volume, and the same + /// directory's path relative to the Data volume root. + struct Firmlink { + root: PathBuf, + relative: PathBuf, + } + + fn table() -> &'static [Firmlink] { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + let Ok(contents) = std::fs::read_to_string(FIRMLINKS) else { + return Vec::new(); + }; + contents + .lines() + .filter_map(|line| { + let (root, relative) = line.split_once('\t')?; + if root.is_empty() || relative.is_empty() { + return None; + } + Some(Firmlink { + root: PathBuf::from(root), + relative: PathBuf::from(relative), + }) + }) + .collect() + }) + } + + /// Whether two paths are the same directory, so a rewrite can never land + /// somewhere else because the published table disagreed with the disk. + fn same_directory(a: &Path, b: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + + pub(super) fn subscriptions(path: &Path) -> Vec { + let Ok(rest) = path.strip_prefix(DATA_VOLUME) else { + return vec![Subscription::direct(path)]; + }; + + let mut found = Vec::new(); + + for link in table() { + // The requested root sits inside this firmlink: one subscription, + // the short spelling of the same directory. + if let Ok(below) = rest.strip_prefix(&link.relative) { + let subscribe = link.root.join(below); + if same_directory(&subscribe, path) { + return vec![Subscription { + subscribe, + restore_to: path.to_path_buf(), + }]; + } + continue; + } + + // The firmlink sits inside the requested root: part of a fan-out. + if link.relative.starts_with(rest) { + let restore_to = Path::new(DATA_VOLUME).join(&link.relative); + if same_directory(&link.root, &restore_to) { + found.push(Subscription { + subscribe: link.root.clone(), + restore_to, + }); + } + } + } + + if found.is_empty() { + // A Data-volume path outside every firmlink is only reachable the + // long way round, so watch it as asked. + return vec![Subscription::direct(path)]; + } + + found + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_path_with_no_translation_watches_itself() { + let path = Path::new("/Volumes/Archive/Photos"); + assert_eq!(subscriptions(path), vec![Subscription::direct(path)]); + } + + #[test] + fn an_event_is_rewritten_into_the_stored_spelling() { + let subscription = Subscription { + subscribe: PathBuf::from("/Users/me/Desktop"), + restore_to: PathBuf::from("/System/Volumes/Data/Users/me/Desktop"), + }; + + assert_eq!( + restore(&subscription, Path::new("/Users/me/Desktop/shot.png")), + Some(PathBuf::from( + "/System/Volumes/Data/Users/me/Desktop/shot.png" + )) + ); + assert_eq!( + restore(&subscription, Path::new("/Users/me/Desktop")), + Some(PathBuf::from("/System/Volumes/Data/Users/me/Desktop")) + ); + } + + #[test] + fn an_event_outside_a_subscription_is_not_claimed() { + let subscription = Subscription { + subscribe: PathBuf::from("/Users/me/Desktop"), + restore_to: PathBuf::from("/System/Volumes/Data/Users/me/Desktop"), + }; + + assert_eq!( + restore(&subscription, Path::new("/Users/me/Documents/notes.txt")), + None + ); + } + + /// A path that merely begins with the same bytes is a different directory + /// and must not be rewritten into one. + #[test] + fn a_lookalike_prefix_is_left_alone() { + let impostor = Path::new("/System/Volumes/Data-not-really/Users"); + assert_eq!( + subscriptions(impostor), + vec![Subscription::direct(impostor)] + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_long_spelling_subscribes_to_the_short_one() { + let long = Path::new("/System/Volumes/Data/Users"); + let subs = subscriptions(long); + + assert_eq!(subs.len(), 1, "one directory, one subscription"); + assert_eq!(subs[0].subscribe, Path::new("/Users")); + assert_eq!(subs[0].restore_to, long); + } + + /// The whole Data volume has no short spelling of its own, so a watch on it + /// only reaches the disk by subscribing to each firmlink beneath it. + #[cfg(target_os = "macos")] + #[test] + fn the_whole_data_volume_fans_out_into_its_firmlinks() { + let subs = subscriptions(Path::new("/System/Volumes/Data")); + + assert!( + subs.len() > 1, + "expected a fan-out, got {:?}", + subs.iter().map(|s| &s.subscribe).collect::>() + ); + assert!( + subs.iter().any(|s| s.subscribe == Path::new("/Users")), + "the home directories are the point of this watch" + ); + for subscription in &subs { + assert!( + subscription.restore_to.starts_with("/System/Volumes/Data"), + "{:?} would deliver events outside the watch", + subscription + ); + } + } + + /// The round trip is what the watcher relies on: subscribe under one + /// spelling, receive under it, hand back the other. + #[cfg(target_os = "macos")] + #[test] + fn a_fanned_out_event_returns_in_the_stored_spelling() { + let subs = subscriptions(Path::new("/System/Volumes/Data")); + let users = subs + .iter() + .find(|s| s.subscribe == Path::new("/Users")) + .expect("the /Users firmlink"); + + assert_eq!( + restore(users, Path::new("/Users/me/Desktop/shot.png")), + Some(PathBuf::from( + "/System/Volumes/Data/Users/me/Desktop/shot.png" + )) + ); + } +} diff --git a/crates/fs-watcher/src/watcher.rs b/crates/fs-watcher/src/watcher.rs index 420658575..84b8ca335 100644 --- a/crates/fs-watcher/src/watcher.rs +++ b/crates/fs-watcher/src/watcher.rs @@ -8,6 +8,7 @@ use crate::config::{WatchConfig, WatcherConfig}; use crate::error::{Result, WatcherError}; use crate::event::{FsEvent, RawNotifyEvent}; use crate::platform::PlatformHandler; +use crate::spelling::{self, Subscription}; use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; /// The mode to hand notify for a watch. /// @@ -21,46 +22,6 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; /// above, by callers that already compare an event's parent against the root /// they asked for. Watching wider than asked costs events that get filtered; /// watching non-recursively costs every event. - -/// The spelling of `path` that the platform's watcher will actually accept. -/// -/// macOS reaches the writable half of the disk twice: `/Users/me` and -/// `/System/Volumes/Data/Users/me` are the same directory, same device, same -/// inode, joined by a firmlink. `realpath` does not resolve firmlinks, so both -/// spellings survive canonicalization, and FSEvents subscribes to exactly one -/// of them. Registered under the `/System/Volumes/Data` spelling it accepts the -/// watch, reports no error, and delivers nothing — the same silent failure as a -/// non-recursive subscription, from a different cause. -/// -/// The stripped path is only used when it is demonstrably the same directory, -/// so a path that merely starts with those bytes is left alone rather than -/// rewritten into something else. -#[cfg(target_os = "macos")] -pub fn watchable_spelling(path: &Path) -> PathBuf { - const DATA_VOLUME: &str = "/System/Volumes/Data"; - - let Ok(rest) = path.strip_prefix(DATA_VOLUME) else { - return path.to_path_buf(); - }; - let stripped = Path::new("/").join(rest); - - match (std::fs::metadata(path), std::fs::metadata(&stripped)) { - (Ok(a), Ok(b)) if same_file(&a, &b) => stripped, - _ => path.to_path_buf(), - } -} - -#[cfg(target_os = "macos")] -fn same_file(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - a.dev() == b.dev() && a.ino() == b.ino() -} - -#[cfg(not(target_os = "macos"))] -pub fn watchable_spelling(path: &Path) -> PathBuf { - path.to_path_buf() -} - fn notify_mode(recursive: bool) -> RecursiveMode { if recursive || cfg!(target_os = "macos") { RecursiveMode::Recursive @@ -111,6 +72,9 @@ impl Drop for WatchHandle { struct WatchState { config: WatchConfig, ref_count: usize, + /// The platform subscriptions backing this watch. More than one when the + /// root spans several firmlinks; see [`crate::spelling`]. + subscriptions: Vec, } /// Internal watcher state @@ -153,17 +117,21 @@ impl FsWatcherInner { return Err(WatcherError::PathNotFound(path)); } + let subscriptions = spelling::subscriptions(&path); + // Register with notify if we're running if self.is_running.load(Ordering::SeqCst) { if let Some(watcher) = self.notify_watcher.write().await.as_mut() { let mode = notify_mode(config.recursive); - watcher - .watch(&watchable_spelling(&path), mode) - .map_err(|e| WatcherError::WatchFailed { - path: path.clone(), - reason: e.to_string(), + for subscription in &subscriptions { + watcher.watch(&subscription.subscribe, mode).map_err(|e| { + WatcherError::WatchFailed { + path: path.clone(), + reason: e.to_string(), + } })?; + } } } @@ -172,6 +140,7 @@ impl FsWatcherInner { WatchState { config, ref_count: 1, + subscriptions, }, ); @@ -179,12 +148,45 @@ impl FsWatcherInner { Ok(()) } + /// Rewrite an event's paths from the spelling the platform delivered into + /// the spelling its watch is stored under. + /// + /// Subscriptions and events are two halves of one translation: a watch + /// registered under a firmlink's short spelling reports events under it + /// too, and everything above the watcher works in the long one. Without + /// this the paths never match a watched root, and an index keyed one way + /// grows a second tree keyed the other. + async fn restore_spelling(&self, event: &mut RawNotifyEvent) { + let watched = self.watched_paths.read().await; + + let translations: Vec<&Subscription> = watched + .values() + .flat_map(|state| state.subscriptions.iter()) + .filter(|subscription| subscription.subscribe != subscription.restore_to) + .collect(); + + if translations.is_empty() { + return; + } + + for path in &mut event.paths { + if let Some(restored) = translations + .iter() + .find_map(|subscription| spelling::restore(subscription, path)) + { + *path = restored; + } + } + } + /// Release a watch (decrement ref count, unwatch if zero) async fn release_watch(&self, path: &Path) -> Result<()> { let mut watched = self.watched_paths.write().await; + let subscriptions; let should_unwatch = if let Some(state) = watched.get_mut(path) { state.ref_count -= 1; + subscriptions = state.subscriptions.clone(); debug!( "Decremented ref count for {}: {}", path.display(), @@ -201,8 +203,14 @@ impl FsWatcherInner { // Unregister from notify if we're running if self.is_running.load(Ordering::SeqCst) { if let Some(watcher) = self.notify_watcher.write().await.as_mut() { - if let Err(e) = watcher.unwatch(path) { - warn!("Failed to unwatch {}: {}", path.display(), e); + for subscription in &subscriptions { + if let Err(e) = watcher.unwatch(&subscription.subscribe) { + warn!( + "Failed to unwatch {}: {}", + subscription.subscribe.display(), + e + ); + } } } } @@ -389,10 +397,21 @@ impl FsWatcher { for (path, state) in watched.iter() { let mode = notify_mode(state.config.recursive); - if let Err(e) = watcher.watch(&watchable_spelling(path), mode) { - warn!("Failed to register watch for {}: {}", path.display(), e); - } else { - debug!("Registered watch for: {}", path.display()); + for subscription in &state.subscriptions { + if let Err(e) = watcher.watch(&subscription.subscribe, mode) { + warn!( + "Failed to register watch for {} as {}: {}", + path.display(), + subscription.subscribe.display(), + e + ); + } else { + debug!( + "Registered watch for {} as {}", + path.display(), + subscription.subscribe.display() + ); + } } } } @@ -419,7 +438,9 @@ impl FsWatcher { tokio::select! { // Process incoming raw events - Some(raw_event) = raw_rx.recv() => { + Some(mut raw_event) = raw_rx.recv() => { + inner.restore_spelling(&mut raw_event).await; + // Check if path should be filtered let should_process = if let Some(path) = raw_event.primary_path() { let watched = inner.watched_paths.read().await; @@ -527,32 +548,6 @@ mod tests { watcher.stop().await.unwrap(); } - /// The same directory reached the long way round has to be watchable. - /// - /// `/System/Volumes/Data/Users/me` and `/Users/me` are one directory joined - /// by a firmlink, and FSEvents only subscribes to the second. Spacedrive - /// resolves paths through the Data volume's mount point, so the spelling it - /// asks for is the one that does not work. - #[cfg(target_os = "macos")] - #[test] - fn the_data_volume_spelling_is_rewritten() { - let long = Path::new("/System/Volumes/Data/Users"); - if !long.exists() { - return; - } - assert_eq!(watchable_spelling(long), Path::new("/Users")); - } - - /// Only when it really is the same directory. A path that merely starts - /// with those bytes must survive untouched rather than being rewritten into - /// something else entirely. - #[cfg(target_os = "macos")] - #[test] - fn a_path_that_only_looks_like_it_is_left_alone() { - let impostor = Path::new("/System/Volumes/Data-not-really/x"); - assert_eq!(watchable_spelling(impostor), impostor); - } - /// A shallow watch has to actually deliver. /// /// This is the regression that mattered: on macOS a `NonRecursive` @@ -602,6 +597,77 @@ mod tests { watcher.stop().await.unwrap(); } + /// A watch on the long spelling has to deliver under the long spelling. + /// + /// This is what `spelling` is for. FSEvents subscribes to the short side of + /// a firmlink and reports events there, while Spacedrive resolves roots + /// through the Data volume's mount point and keys its index on the long + /// side. Registered without translation the watch delivers nothing; + /// translated only outbound it delivers paths that match no watched root, + /// and the index grows a second tree beside the one it already has. + /// + /// The subject has to be a real firmlinked directory, and the home + /// directory is the one every install has. A temp directory will not do: + /// `/var/folders` is reached through a symlink rather than a firmlink and + /// FSEvents refuses its resolved spelling outright, which is a separate + /// quirk this translation does not claim to cover. + #[cfg(target_os = "macos")] + #[tokio::test] + async fn a_watch_on_the_long_spelling_delivers_under_it() { + let Ok(home) = std::env::var("HOME") else { + return; + }; + // A hidden directory is filtered out before it ever reaches a + // subscriber, and a temp directory is hidden by default. + let dir = tempfile::Builder::new() + .prefix("sd-spelling-") + .tempdir_in(&home) + .unwrap(); + let short = dir.path().to_path_buf(); + let long = Path::new("/System/Volumes/Data").join(short.strip_prefix("/").unwrap()); + assert!( + long.exists(), + "{} should be reachable the long way round", + short.display() + ); + + let watcher = FsWatcher::new(WatcherConfig::default()); + watcher.start().await.unwrap(); + let mut events = watcher.subscribe(); + + let _handle = watcher.watch(&long, WatchConfig::shallow()).await.unwrap(); + + // The backend needs a moment before it is actually subscribed. + tokio::time::sleep(Duration::from_millis(500)).await; + std::fs::write(short.join("appeared.txt"), b"hello").unwrap(); + let expected = long.join("appeared.txt"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + assert!( + !remaining.is_zero(), + "no event arrived as {}", + expected.display() + ); + match tokio::time::timeout(remaining, events.recv()).await { + Ok(Ok(event)) if event.path == expected => break, + Ok(Ok(event)) => { + assert!( + !event.path.starts_with(&short), + "event arrived as {} but the watch is stored as {}", + event.path.display(), + long.display() + ); + continue; + } + Ok(Err(_)) | Err(_) => panic!("no event arrived as {}", expected.display()), + } + } + + watcher.stop().await.unwrap(); + } + #[tokio::test] async fn test_watch_handle_drops() { let watcher = FsWatcher::new(WatcherConfig::default());