mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-11-02 21:35:12 -05:00
Deliver watcher events in the spelling the index is keyed on
macOS reaches the writable disk twice. /Users/me and /System/Volumes/Data/Users/me are the same directory joined by a firmlink, realpath does not resolve firmlinks, and FSEvents subscribes to exactly one of the two. Spacedrive resolves a source root through its volume's mount point, so the spelling it asks for is the one that does not work. The outbound half of that translation existed: watchable_spelling rewrote the path handed to FSEvents. The inbound half did not, so events came back in FSEvents' spelling and never matched a watched root. find_by_path missed and the watcher built a second tree beside the one the walk had already built. Both halves now live in one module. /usr/share/firmlinks publishes the mapping, so a watch resolves to a set of subscriptions, each carrying the prefix its events are rewritten back to. Above the watcher edge only one spelling exists, and the two calls to watchable_spelling in the ephemeral handler collapse back to comparing paths. A watch on the whole Data volume fans out. /System/Volumes/Data has no short spelling of its own, so it subscribes to each firmlink beneath it instead; this is Macintosh HD, which received nothing at all before. a_watch_on_the_long_spelling_delivers_under_it drives a real watch end to end. Both halves were checked by removing them: without the outbound rewrite nothing arrives, without the inbound one the path arrives in the wrong spelling. It watches a directory under $HOME because a temp directory will not do — /var/folders is reached through a symlink rather than a firmlink and FSEvents refuses its resolved spelling, which is a separate quirk.
This commit is contained in:
1 parent
bf670e922a
commit
3cfe8eaee6
4 files changed
+421
-88
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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<PathBuf> {
|
||||
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<Subscription> {
|
||||
#[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<Vec<Firmlink>> = 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<Subscription> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
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"
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Subscription>,
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
Reference in new issue
Block a user