async def fuse_mountpoint_runner( user_fs: UserFS, workspace_fs: WorkspaceFS, base_mountpoint_path: PurePath, config: dict, event_bus: EventBus, ): """ Raises: MountpointDriverCrash """ fuse_thread_started = threading.Event() fuse_thread_stopped = threading.Event() trio_token = trio.lowlevel.current_trio_token() fs_access = ThreadFSAccess(trio_token, workspace_fs, event_bus) mountpoint_path, initial_st_dev = await _bootstrap_mountpoint( base_mountpoint_path, workspace_fs) # Prepare event information event_kwargs = { "mountpoint": mountpoint_path, "workspace_id": workspace_fs.workspace_id, "timestamp": getattr(workspace_fs, "timestamp", None), } fuse_operations = FuseOperations(fs_access, **event_kwargs) try: teardown_cancel_scope = None event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs) async with trio.open_service_nursery() as nursery: # Let fusepy decode the paths using the current file system encoding # Note that this does not prevent the user from using a certain encoding # in the context of the parsec app and another encoding in the context of # an application accessing the mountpoint. In this case, an encoding error # might be raised while fuspy tries to decode the path. If that happens, # fuspy will log the error and simply return EINVAL, which is acceptable. encoding = sys.getfilesystemencoding() def _run_fuse_thread(): with importlib_resources.files(resources_module).joinpath( "parsec.icns") as parsec_icns_path: fuse_platform_options = {} if sys.platform == "darwin": fuse_platform_options = { "local": True, "defer_permissions": True, "volname": workspace_fs.get_workspace_name(), "volicon": str(parsec_icns_path.absolute()), } # osxfuse-specific options : # local: allows mountpoint to show up correctly in finder (+ desktop) # volname: specify volume name (default is OSXFUSE [...]) # volicon: specify volume icon (default is macOS drive icon) # On defer_permissions: "The defer_permissions option [...] causes macFUSE to assume that all # accesses are allowed; it will forward all operations to the file system, and it is up to # somebody else to eventually allow or deny the operations." See # https://github.com/osxfuse/osxfuse/wiki/Mount-options#default_permissions-and-defer_permissions # This option is necessary on MacOS to give the right permissions to files inside FUSE drives, # otherwise it impedes upon saving and auto-saving from Apple softwares (TextEdit, Preview...) # due to the gid of files seemingly not having writing rights from the software perspective. # One other solution found for this issue was to change the gid of the mountpoint and its files # from staff (default) to wheel (admin gid). Checking defer_permissions allows to ignore the gid # issue entirely and lets Parsec itself handle read/write rights inside workspaces. else: fuse_platform_options = {"auto_unmount": True} logger.info("Starting fuse thread...", mountpoint=mountpoint_path) try: # Do not let fuse start if the runner is stopping # It's important that `fuse_thread_started` is set before the check # in order to avoid race conditions fuse_thread_started.set() if teardown_cancel_scope is not None: return FUSE( fuse_operations, str(mountpoint_path.absolute()), foreground=True, encoding=encoding, **fuse_platform_options, **config, ) except Exception as exc: try: errcode = errno.errorcode[exc.args[0]] except (KeyError, IndexError): errcode = f"Unknown error code: {exc}" raise MountpointDriverCrash( f"Fuse has crashed on {mountpoint_path}: {errcode}" ) from exc finally: fuse_thread_stopped.set() # We're about to call the `fuse_main_real` function from libfuse, so let's make sure # the signals are correctly patched before that (`_path_signals` is idempotent) _patch_signals() nursery.start_soon(lambda: trio.to_thread.run_sync( _run_fuse_thread, cancellable=True)) await _wait_for_fuse_ready(mountpoint_path, fuse_thread_started, initial_st_dev) # Indicate the mountpoint is now started yield mountpoint_path finally: event_bus.send(CoreEvent.MOUNTPOINT_STOPPING, **event_kwargs) with trio.CancelScope(shield=True) as teardown_cancel_scope: await _stop_fuse_thread(mountpoint_path, fuse_operations, fuse_thread_started, fuse_thread_stopped) await _teardown_mountpoint(mountpoint_path)
async def winfsp_mountpoint_runner( user_fs: UserFS, workspace_fs: WorkspaceFS, base_mountpoint_path: PurePath, config: dict, event_bus: EventBus, ): """ Raises: MountpointDriverCrash """ device = workspace_fs.device workspace_name = winify_entry_name(workspace_fs.get_workspace_name()) trio_token = trio.lowlevel.current_trio_token() fs_access = ThreadFSAccess(trio_token, workspace_fs, event_bus) user_manifest = user_fs.get_user_manifest() workspace_ids = [entry.id for entry in user_manifest.workspaces] workspace_index = workspace_ids.index(workspace_fs.workspace_id) # `base_mountpoint_path` is ignored given we only mount from a drive mountpoint_path = await _get_available_drive(workspace_index, len(workspace_ids)) # Prepare event information event_kwargs = { "mountpoint": mountpoint_path, "workspace_id": workspace_fs.workspace_id, "timestamp": getattr(workspace_fs, "timestamp", None), } if config.get("debug", False): enable_debug_log() # Volume label is limited to 32 WCHAR characters, so force the label to # ascii to easily enforce the size. volume_label = (unicodedata.normalize( "NFKD", f"{workspace_name.capitalize()}").encode( "ascii", "ignore")[:32].decode("ascii")) volume_serial_number = _generate_volume_serial_number( device, workspace_fs.workspace_id) operations = WinFSPOperations(fs_access=fs_access, volume_label=volume_label, **event_kwargs) # See https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-getvolumeinformationa # noqa fs = FileSystem( mountpoint_path.drive, operations, sector_size=512, sectors_per_allocation_unit=1, volume_creation_time=filetime_now(), volume_serial_number=volume_serial_number, file_info_timeout=1000, case_sensitive_search=1, case_preserved_names=1, unicode_on_disk=1, persistent_acls=0, reparse_points=0, reparse_points_access_check=0, named_streams=0, read_only_volume=workspace_fs.is_read_only(), post_cleanup_when_modified_only=1, device_control=0, um_file_context_is_user_context2=1, file_system_name="Parsec", prefix="", # The minimum value for IRP timeout is 1 minute (default is 5) irp_timeout=60000, # security_timeout_valid=1, # security_timeout=10000, ) try: event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs) # Manage drive icon drive_letter, *_ = mountpoint_path.drive with parsec_drive_icon_context(drive_letter): # Run fs start in a thread await trio.to_thread.run_sync(fs.start) # The system is too sensitive right after starting await trio.sleep(0.010) # 10 ms # Make sure the mountpoint is ready await _wait_for_winfsp_ready(mountpoint_path) # Notify the manager that the mountpoint is ready yield mountpoint_path # Start recording `sharing.updated` events with event_bus.waiter_on(CoreEvent.SHARING_UPDATED) as waiter: # Loop over `sharing.updated` event while True: # Restart the mountpoint with the right read_only flag if necessary # Don't bother with restarting if the workspace has been revoked # It's the manager's responsibility to unmount the workspace in this case if (workspace_fs.is_read_only() != fs.volume_params["read_only_volume"] and not workspace_fs.is_revoked()): restart = partial( fs.restart, read_only_volume=workspace_fs.is_read_only()) await trio.to_thread.run_sync(restart) # Wait and reset waiter await waiter.wait() waiter.clear() except Exception as exc: raise MountpointDriverCrash( f"WinFSP has crashed on {mountpoint_path}: {exc}") from exc finally: event_bus.send(CoreEvent.MOUNTPOINT_STOPPING, **event_kwargs) # Must run in thread given this call will wait for any winfsp operation # to finish so blocking the trio loop can produce a dead lock... with trio.CancelScope(shield=True): try: await trio.to_thread.run_sync(fs.stop) # The file system might not be started, # if the task gets cancelled before running `fs.start` for instance except FileSystemNotStarted: pass
async def winfsp_mountpoint_runner( workspace_fs, base_mountpoint_path: Path, config: dict, event_bus, *, task_status=trio.TASK_STATUS_IGNORED, ): """ Raises: MountpointDriverCrash """ device = workspace_fs.device workspace_name = winify_entry_name(workspace_fs.get_workspace_name()) trio_token = trio.hazmat.current_trio_token() fs_access = ThreadFSAccess(trio_token, workspace_fs) mountpoint_path = await _bootstrap_mountpoint(base_mountpoint_path, workspace_name) if config.get("debug", False): enable_debug_log() # Volume label is limited to 32 WCHAR characters, so force the label to # ascii to easily enforce the size. volume_label = ( unicodedata.normalize("NFKD", f"{device.user_id}-{workspace_name}") .encode("ascii", "ignore")[:32] .decode("ascii") ) volume_serial_number = _generate_volume_serial_number(device, workspace_fs.workspace_id) operations = WinFSPOperations(event_bus, volume_label, fs_access) # See https://docs.microsoft.com/en-us/windows/desktop/api/fileapi/nf-fileapi-getvolumeinformationa # noqa fs = FileSystem( str(mountpoint_path.absolute()), operations, sector_size=512, sectors_per_allocation_unit=1, volume_creation_time=filetime_now(), volume_serial_number=volume_serial_number, file_info_timeout=1000, case_sensitive_search=1, case_preserved_names=1, unicode_on_disk=1, persistent_acls=0, reparse_points=0, reparse_points_access_check=0, named_streams=0, read_only_volume=0, post_cleanup_when_modified_only=1, device_control=0, um_file_context_is_user_context2=1, file_system_name="Parsec", prefix="", # The minimum value for IRP timeout is 1 minute (default is 5) irp_timeout=60000, # Work around the avast/winfsp incompatibility reject_irp_prior_to_transact0=True, # security_timeout_valid=1, # security_timeout=10000, ) try: event_bus.send("mountpoint.starting", mountpoint=mountpoint_path) # Run fs start in a thread, as a cancellable operation # This is because fs.start() might get stuck for while in case of an IRP timeout await trio.to_thread.run_sync(fs.start, cancellable=True) # Because of reject_irp_prior_to_transact0, the mountpoint isn't ready yet # We have to add a bit of delay here, the tests would fail otherwise # 10 ms is more than enough, although a strict process would be nicer # Still, this is only temporary as avast is working on a fix at the moment # Another way to address this problem would be to migrate to python 3.8, # then use `os.stat` to differentiate between a started and a non-started # file syste. await trio.sleep(0.01) event_bus.send("mountpoint.started", mountpoint=mountpoint_path) task_status.started(mountpoint_path) await trio.sleep_forever() except Exception as exc: raise MountpointDriverCrash(f"WinFSP has crashed on {mountpoint_path}: {exc}") from exc finally: # Must run in thread given this call will wait for any winfsp operation # to finish so blocking the trio loop can produce a dead lock... with trio.CancelScope(shield=True): await trio.to_thread.run_sync(fs.stop) event_bus.send("mountpoint.stopped", mountpoint=mountpoint_path)
async def fuse_mountpoint_runner( workspace_fs, base_mountpoint_path: PurePath, config: dict, event_bus, *, task_status=trio.TASK_STATUS_IGNORED, ): """ Raises: MountpointDriverCrash """ fuse_thread_started = threading.Event() fuse_thread_stopped = threading.Event() trio_token = trio.hazmat.current_trio_token() fs_access = ThreadFSAccess(trio_token, workspace_fs) fuse_operations = FuseOperations(event_bus, fs_access) mountpoint_path, initial_st_dev = await _bootstrap_mountpoint( base_mountpoint_path, workspace_fs) try: event_bus.send("mountpoint.starting", mountpoint=mountpoint_path) async with trio.open_service_nursery() as nursery: # Let fusepy decode the paths using the current file system encoding # Note that this does not prevent the user from using a certain encoding # in the context of the parsec app and another encoding in the context of # an application accessing the mountpoint. In this case, an encoding error # might be raised while fuspy tries to decode the path. If that happends, # fuspy will log the error and simply return EINVAL, which is acceptable. encoding = sys.getfilesystemencoding() def _run_fuse_thread(): logger.info("Starting fuse thread...", mountpoint=mountpoint_path) try: fuse_thread_started.set() FUSE( fuse_operations, str(mountpoint_path.absolute()), foreground=True, auto_unmount=True, encoding=encoding, **config, ) except Exception as exc: try: errcode = errno.errorcode[exc.args[0]] except (KeyError, IndexError): errcode = f"Unknown error code: {exc}" raise MountpointDriverCrash( f"Fuse has crashed on {mountpoint_path}: {errcode}" ) from exc finally: fuse_thread_stopped.set() # The fusepy runner (FUSE) relies on the `fuse_main_real` function from libfuse # This function is high-level helper on top of the libfuse API that is intended # for simple application. As such, it sets some signal handlers to exit cleanly # after a SIGTINT, a SIGTERM or a SIGHUP. This is, however, not compatible with # our multi-instance multi-threaded application. A simple workaround here is to # restore the signals to their previous state once the fuse instance is started. with _reset_signals(): nursery.start_soon(lambda: trio.to_thread.run_sync( _run_fuse_thread, cancellable=True)) await _wait_for_fuse_ready(mountpoint_path, fuse_thread_started, initial_st_dev) event_bus.send("mountpoint.started", mountpoint=mountpoint_path) task_status.started(mountpoint_path) finally: await _stop_fuse_thread(mountpoint_path, fuse_operations, fuse_thread_stopped) event_bus.send("mountpoint.stopped", mountpoint=mountpoint_path) await _teardown_mountpoint(mountpoint_path)
async def fuse_mountpoint_runner( user_fs, workspace_fs, base_mountpoint_path: PurePath, config: dict, event_bus, *, task_status=trio.TASK_STATUS_IGNORED, ): """ Raises: MountpointDriverCrash """ fuse_thread_started = threading.Event() fuse_thread_stopped = threading.Event() trio_token = trio.lowlevel.current_trio_token() fs_access = ThreadFSAccess(trio_token, workspace_fs) fuse_operations = FuseOperations(event_bus, fs_access) mountpoint_path, initial_st_dev = await _bootstrap_mountpoint( base_mountpoint_path, workspace_fs) # Prepare event information event_kwargs = { "mountpoint": mountpoint_path, "workspace_id": workspace_fs.workspace_id, "timestamp": getattr(workspace_fs, "timestamp", None), } try: teardown_cancel_scope = None event_bus.send(CoreEvent.MOUNTPOINT_STARTING, **event_kwargs) async with trio.open_service_nursery() as nursery: # Let fusepy decode the paths using the current file system encoding # Note that this does not prevent the user from using a certain encoding # in the context of the parsec app and another encoding in the context of # an application accessing the mountpoint. In this case, an encoding error # might be raised while fuspy tries to decode the path. If that happens, # fuspy will log the error and simply return EINVAL, which is acceptable. encoding = sys.getfilesystemencoding() def _run_fuse_thread(): fuse_platform_options = {} if sys.platform == "darwin": fuse_platform_options = { "local": True, "volname": workspace_fs.get_workspace_name(), "volicon": Path(resources.__file__).absolute().parent / "parsec.icns", } # osxfuse-specific options : # - local : allows mountpoint to show up correctly in finder (+ desktop) # - volname : specify volume name (default is OSXFUSE [...]) # - volicon : specify volume icon (default is macOS drive icon) else: fuse_platform_options = {"auto_unmount": True} logger.info("Starting fuse thread...", mountpoint=mountpoint_path) try: # Do not let fuse start if the runner is stopping # It's important that `fuse_thread_started` is set before the check # in order to avoid race conditions fuse_thread_started.set() if teardown_cancel_scope is not None: return FUSE( fuse_operations, str(mountpoint_path.absolute()), foreground=True, encoding=encoding, **fuse_platform_options, **config, ) except Exception as exc: try: errcode = errno.errorcode[exc.args[0]] except (KeyError, IndexError): errcode = f"Unknown error code: {exc}" raise MountpointDriverCrash( f"Fuse has crashed on {mountpoint_path}: {errcode}" ) from exc finally: fuse_thread_stopped.set() # The fusepy runner (FUSE) relies on the `fuse_main_real` function from libfuse # This function is high-level helper on top of the libfuse API that is intended # for simple application. As such, it sets some signal handlers to exit cleanly # after a SIGTINT, a SIGTERM or a SIGHUP. This is, however, not compatible with # our multi-instance multi-threaded application. A simple workaround here is to # restore the signals to their previous state once the fuse instance is started. with _reset_signals(): nursery.start_soon(lambda: trio.to_thread.run_sync( _run_fuse_thread, cancellable=True)) await _wait_for_fuse_ready(mountpoint_path, fuse_thread_started, initial_st_dev) event_bus.send(CoreEvent.MOUNTPOINT_STARTED, **event_kwargs) task_status.started(mountpoint_path) finally: with trio.CancelScope(shield=True) as teardown_cancel_scope: await _stop_fuse_thread(mountpoint_path, fuse_operations, fuse_thread_started, fuse_thread_stopped) event_bus.send(CoreEvent.MOUNTPOINT_STOPPED, **event_kwargs) await _teardown_mountpoint(mountpoint_path)
def get_path_and_translate_error( fs_access: ThreadFSAccess, operation: str, file_context: Union[OpenedFile, OpenedFolder, str], mountpoint: PurePath, workspace_id: EntryID, timestamp: Optional[DateTime], ) -> Iterator[FsPath]: path: FsPath = FsPath("/<unkonwn>") try: if isinstance(file_context, (OpenedFile, OpenedFolder)): path = file_context.path else: # FsPath conversion might raise an FSNameTooLongError so make # sure it runs within the try-except so it can be caught by the # FSLocalOperationError filter. path = _winpath_to_parsec(file_context) yield path except NTStatusError: raise except FSLocalOperationError as exc: raise NTStatusError(exc.ntstatus) from exc except FSRemoteOperationError as exc: fs_access.send_event( CoreEvent.MOUNTPOINT_REMOTE_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) raise NTStatusError(exc.ntstatus) from exc except (Cancelled, RunFinishedError) as exc: # WinFSP teardown operation doesn't make sure no concurrent operation # are running raise NTStatusError(NTSTATUS.STATUS_NO_SUCH_DEVICE) from exc except TrioDealockTimeoutError as exc: # See the similar clause in `fuse_operations` for a detailed explanation logger.error( "The trio thread is unreachable, a deadlock might have occured", operation=operation, path=str(path), mountpoint=str(mountpoint), workspace_id=workspace_id, timestamp=timestamp, ) fs_access.send_event( CoreEvent.MOUNTPOINT_TRIO_DEADLOCK_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) raise NTStatusError(NTSTATUS.STATUS_INTERNAL_ERROR) from exc except Exception as exc: logger.exception( "Unhandled exception in winfsp mountpoint", operation=operation, path=str(path), mountpoint=str(mountpoint), workspace_id=workspace_id, timestamp=timestamp, ) fs_access.send_event( CoreEvent.MOUNTPOINT_UNHANDLED_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) raise NTStatusError(NTSTATUS.STATUS_INTERNAL_ERROR) from exc
def get_path_and_translate_error( fs_access: ThreadFSAccess, operation: str, context: Optional[str], mountpoint: PurePath, workspace_id: EntryID, timestamp: Optional[DateTime], ) -> Iterator[FsPath]: path: FsPath = FsPath("/<unknown>") try: # The context argument might be None or "-" in some special cases # related to `release` and `releasedir` (when the file descriptor # is available but the corresponding path is not). In those cases, # we can simply ignore the path. if context not in (None, "-"): # FsPath conversion might raise an FSNameTooLongError so make # sure it runs within the try-except so it can be caught by the # FSLocalOperationError filter. path = FsPath(context) yield path except FuseOSError: raise except FSReadOnlyError as exc: fs_access.send_event( CoreEvent.MOUNTPOINT_READONLY, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) raise FuseOSError(exc.errno) from exc except FSLocalOperationError as exc: raise FuseOSError(exc.errno) from exc except FSRemoteOperationError as exc: fs_access.send_event( CoreEvent.MOUNTPOINT_REMOTE_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) raise FuseOSError(exc.errno) from exc except TrioDealockTimeoutError as exc: # This exception is raised when the trio thread cannot be reached. # This is likely due to a deadlock, i.e the trio thread performing # a synchronous call to access the fuse file system (this can easily # happen in a third party library, e.g `QDesktopServices::openUrl`). # The fuse/winfsp kernel driver being involved in the deadlock, the # effects can easily propagate to the file explorer or the system # in general. This is why it is much better to break out of it with # and to return an error code indicating that the operation failed. logger.error( "The trio thread is unreachable, a deadlock might have occured", operation=operation, path=str(path), mountpoint=str(mountpoint), workspace_id=workspace_id, timestamp=timestamp, ) fs_access.send_event( CoreEvent.MOUNTPOINT_TRIO_DEADLOCK_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) # Use EINVAL as error code, so it behaves the same as internal errors raise FuseOSError(errno.EINVAL) from exc except Exception as exc: logger.exception( "Unhandled exception in fuse mountpoint", operation=operation, path=str(path), mountpoint=str(mountpoint), workspace_id=workspace_id, timestamp=timestamp, ) fs_access.send_event( CoreEvent.MOUNTPOINT_UNHANDLED_ERROR, exc=exc, operation=operation, path=path, mountpoint=mountpoint, workspace_id=workspace_id, timestamp=timestamp, ) # Use EINVAL as fallback error code, since this is what fusepy does. raise FuseOSError(errno.EINVAL) from exc except trio.Cancelled as exc: # Cancelled inherits BaseException # Might be raised by `self.fs_access` if the trio loop finishes in our back raise FuseOSError(errno.EACCES) from exc