Repository: samschott/maestral
Issue: ValueError: "" is not in "/home/user/dummy_dir"

**Describe the bug**

Tests fail with error `ValueError: "" is not in "/home/user/dummy_dir"`

**To Reproduce**

```
cd /tmp
wget -O maestral-1.7.2.tar.gz https://github.com/samschott/maestral/archive/refs/tags/v1.7.2.tar.gz
tar xvaf maestral-1.7.2.tar.gz
cd maestral-1.7.2
python -m build --wheel --no-isolation
pytest -k 'not test_autostart and not benchmark' -x --disable-warnings
```

**Expected behaviour**

The tests should pass.

**Actual behaviour**

The tests fail with:

```
================================================= test session starts =================================================
platform linux -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0
rootdir: /tmp/maestral/maestral-1.7.2
collected 216 items / 2 deselected / 3 skipped / 214 selected                                                         

tests/offline/test_clean_local_events.py .........                                                              [  4%]
tests/offline/test_cli.py ...........                                                                           [  9%]
tests/offline/test_client.py ................                                                                   [ 16%]
tests/offline/test_daemon.py .......                                                                            [ 20%]
tests/offline/test_errorhandling.py ........................................................................... [ 55%]
.........................................................                                                       [ 81%]
tests/offline/test_event_handler.py F

====================================================== FAILURES =======================================================
________________________________________________ test_receiving_events ________________________________________________

sync = <maestral.sync.SyncEngine object at 0x7f18977010d0>

    def test_receiving_events(sync: SyncEngine) -> None:
        new_dir = Path(sync.dropbox_path) / "parent"
        new_dir.mkdir()
    
        sync.wait_for_local_changes()
>       sync_events, _ = sync.list_local_changes()

tests/offline/test_event_handler.py:26: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
/usr/lib/python3.11/site-packages/maestral/sync.py:1826: in list_local_changes
    sync_events = self._sync_events_from_fs_events(events)
/usr/lib/python3.11/site-packages/maestral/sync.py:1623: in _sync_events_from_fs_events
    return list(res)
/usr/lib/python3.11/site-packages/maestral/sync.py:3780: in do_parallel
    yield f.result()
/usr/lib/python3.11/concurrent/futures/_base.py:449: in result
    return self.__get_result()
/usr/lib/python3.11/concurrent/futures/_base.py:401: in __get_result
    raise self._exception
/usr/lib/python3.11/concurrent/futures/thread.py:58: in run
    result = self.fn(*self.args, **self.kwargs)
/usr/lib/python3.11/site-packages/maestral/sync.py:1611: in _sync_event_from_fs_event
    return SyncEvent.from_file_system_event(fs_event, self)
/usr/lib/python3.11/site-packages/maestral/models.py:414: in from_file_system_event
    dbx_path = sync_engine.to_dbx_path(to_path)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <maestral.sync.SyncEngine object at 0x7f18977010d0>, local_path = ''

    def to_dbx_path(self, local_path: str) -> str:
        """
        Converts a local path to a path relative to the Dropbox folder. Casing of the
        given ``local_path`` will be preserved.
    
        :param local_path: Absolute path on local drive.
        :returns: Relative path with respect to Dropbox folder.
        :raises ValueError: When the path lies outside the local Dropbox folder.
        """
        if not is_equal_or_child(local_path, self.dropbox_path):
>           raise ValueError(f'"{local_path}" is not in "{self.dropbox_path}"')
E           ValueError: "" is not in "/home/user/dummy_dir"

/usr/lib/python3.11/site-packages/maestral/sync.py:1330: ValueError
------------------------------------------------- Captured log setup --------------------------------------------------
DEBUG    test-config-maestral.sync:sync.py:1119 Could not load mignore rules: No such file or directory
DEBUG    test-config-maestral.sync:sync.py:722 Remote cursor saved: 
DEBUG    test-config-maestral.sync:sync.py:737 Local cursor saved: 0.0
-------------------------------------------------- Captured log call --------------------------------------------------
DEBUG    test-config-maestral.sync:sync.py:1772 Waiting for local changes since cursor: 0.0
DEBUG    test-config-maestral.sync:sync.py:1823 Retrieved local file events:
[DirCreatedEvent(src_path='/home/user/dummy_dir/parent',
                 dest_path='',
                 event_type='created',
                 is_directory=True,
                 is_synthetic=False)]
=============================================== short test summary info ===============================================
FAILED tests/offline/test_event_handler.py::test_receiving_events - ValueError: "" is not in "/home/user/dummy_dir"
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 175 passed, 3 skipped, 2 deselected, 8 warnings in 39.64s =========================
```

**System:**

 - Maestral version: 1.7.2
 - Python version: 3.11.3
 - OS: ArchLinux
 - Desktop environment: n/a
 - PyQt version (for Linux GUI): n/a

**Additional context**

If I go ahead and install maestral anyway, it eventually fails with a similar error on my real folder.


Changed files:
src/maestral/sync.py, src/maestral/models.py

diff --git a/src/maestral/models.py b/src/maestral/models.py
index 03839745..d530372b 100644
--- a/src/maestral/models.py
+++ b/src/maestral/models.py
@@ -16,6 +16,8 @@ from typing import TYPE_CHECKING
 # external imports
 from watchdog.events import (
     FileSystemEvent,
+    FileMovedEvent,
+    DirMovedEvent,
     EVENT_TYPE_CREATED,
     EVENT_TYPE_DELETED,
     EVENT_TYPE_MOVED,
@@ -45,6 +47,19 @@ __all__ = [
 ]
 
 
+def get_dest_path(event: FileSystemEvent) -> str:
+    """
+    Returns the dest_path of a file system event if present (moved events only)
+    otherwise returns the src_path (which is also the "destination").
+
+    :param event: Watchdog file system event.
+    :returns: Destination path for moved event, source path otherwise.
+    """
+    if isinstance(event, (FileMovedEvent, DirMovedEvent)):
+        return event.dest_path
+    return event.src_path
+
+
 class SyncDirection(enum.Enum):
     """Enumeration of sync directions"""
 
@@ -366,7 +381,7 @@ class SyncEvent(Model):
             # This is useful for testing.
             change_dbid = ""
 
-        to_path = getattr(event, "dest_path", event.src_path)
+        to_path = get_dest_path(event)
         from_path = None
 
         if event.event_type == EVENT_TYPE_CREATED:
diff --git a/src/maestral/sync.py b/src/maestral/sync.py
index cd746573..4d84b893 100644
--- a/src/maestral/sync.py
+++ b/src/maestral/sync.py
@@ -324,7 +324,7 @@ class FSEventHandler(FileSystemEventHandler):
         :returns: Whether the event should be ignored.
         """
         for ignore in self._ignored_events.copy():
-            # Check for expired events.
+            # Remove expired ignores.
             if ignore.ttl and ignore.ttl < time.time():
                 self._ignored_events.discard(ignore)
                 continue
@@ -332,20 +332,28 @@ class FSEventHandler(FileSystemEventHandler):
             ignore_event = ignore.event
             recursive = ignore.recursive
 
+            # Check if event is directly marked as to ignore.
             if event == ignore_event:
                 if not recursive:
                     self._ignored_events.discard(ignore)
                 return True
 
+            # Check if event is marked as to ignore by a recursive parent. For moved
+            # events, source and destination path most both be children of the
+            # respective paths of the event to ignore.
             elif recursive:
-                type_match = event.event_type == ignore_event.event_type
-                src_match = is_equal_or_child(event.src_path, ignore_event.src_path)
-                dest_match = is_equal_or_child(
-                    get_dest_path(event), get_dest_path(ignore_event)
-                )
+                if not event.event_type == ignore_event.event_type:
+                    continue
 
-                if type_match and src_match and dest_match:
-                    return True
+                if not is_equal_or_child(event.src_path, ignore_event.src_path):
+                    continue
+
+                if event.event_type == EVENT_TYPE_MOVED and not is_equal_or_child(
+                    event.dest_path, ignore_event.dest_path  # type:ignore[attr-defined]
+                ):
+                    continue
+
+                return True
 
         return False
 
@@ -3851,19 +3859,6 @@ def is_created(event: FileSystemEvent) -> TypeGuard[FileCreatedEvent | DirCreate
     return event.event_type == EVENT_TYPE_CREATED
 
 
-def get_dest_path(event: FileSystemEvent) -> str:
-    """
-    Returns the dest_path of a file system event if present (moved events only)
-    otherwise returns the src_path (which is also the "destination").
-
-    :param event: Watchdog file system event.
-    :returns: Destination path for moved event, source path otherwise.
-    """
-    if isinstance(event, (FileMovedEvent, DirMovedEvent)):
-        return event.dest_path
-    return event.src_path
-
-
 @overload
 def split_moved_event(
     event: FileMovedEvent,