Skip to content

dsh-allowed-workspace-roots

Verified

dsh-allowed-workspace-roots · v1.0.0 · MIT · Web UI

DSH Desktop plugin: allow selected non-NTFS roots (e.g. an rclone/WinFsp SFTP mount) as workspaces, and repair fs.realpath on such volumes.

Install

dsh plugin add dsh-allowed-workspace-roots

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Readme

dsh-allowed-workspace-roots

Плагин для DSH Desktop: разрешает использовать как рабочие области папки на томах, которые штатная политика считает неподходящими, и чинит realpath на таких томах.

Эталонный случай — корпоративный диск, смонтированный rclone через WinFsp (например SFTP-сервер): Windows показывает его как обычный фиксированный диск, но имя файловой системы у него не NTFS/ReFS, и нативный realpath на нём не работает.

Что именно чинится

WinFsp не поддерживает GetFinalPathNameByHandleW при монтировании буквой диска:

GetFinalPathNameByHandleW('<том>\каталог')  →  GetLastError = 1005
                                               (ERROR_NO_UNICODE_TRANSLATION)
                                               в Node это код UNKNOWN
GetFinalPathNameByHandleW('C:\Users')       →  OK

При этом stat, opendir и синхронный fs.realpathSync (JS-реализация, без libuv) на том же томе работают. Ломается только нативный вариант.

DSH канонизирует пути через realpath в нескольких независимых местах, и без плагина отказ выглядит как последовательность ошибок:

Что делает пользователь Что происходит без плагина
добавляет папку на томе как рабочую область FUSE-RCLONE cannot safely host a DSH Desktop workspace
повторяет после обхода проверки тома workspace/invalid-path: ... UNKNOWN: unknown error, realpath
отправляет первый запрос в сессии UNKNOWN: unknown error, realpath из слоя файлов
перезапускает DSH сессия исчезает из списка

Как устроено

Три независимых патча. Все три нужны при любой политике файлов — они не про песочницу.

1. Клиентская половина (lib/client.js). Штатная проверка тома живёт в клиентской половине Desktop-плагина и на каждом вызове читает функцию из window.__DSH_DESKTOP_VALIDATE_DIRECTORY__. Плагин подменяет это свойство аксессором: get возвращает обёртку, которая для путей из allow-list отвечает «разрешено» сразу, а для всех остальных вызывает настоящий валидатор DSH; set перехватывает настоящий валидатор, когда Desktop-плагин его устанавливает, поэтому порядок загрузки не важен.

2. Реестр рабочих областей (lib/registry-patch.mjs). Оборачивает create, resolveByPath, indexHeader и attachSession, подменяя канонизацию на JS-реализацию realpathSync — только для путей из allow-list. create дальше идёт ровно как в оригинале, минуя realpath. attachSession живёт на сущности рабочей области, поэтому патчится её прототип.

Отдельно: реестр индексирует заголовки сохранённых сессий при своём старте, то есть раньше, чем плагин получит управление. На «сломанном» томе сессии помечаются недействительными и после перезапуска пропадают из списка, хотя во время работы видны. Поэтому сразу после установки патча индексация прогоняется заново.

3. Слой файлов (lib/fs-patch.mjs). dsh-fs-local канонизирует цель через realpath и пробрасывает UNKNOWN наружу, потому что обрабатывает только ENOTDIR и ENOENT. Канонизация там вызывается ровно в двух местах, поэтому хватает двух методов: resolve — единственная точка входа для путей, и резолв детей при листинге каталога. Все остальные методы (stat, readText, writeText, editText) принимают уже готовую цель, где targetKey и есть путь для ввода-вывода.

resolve обязан переживать ещё не существующий файл — иначе создание файла падает с ENOENT. Для этого канонизируется ближайший существующий предок, а недостающий хвост приклеивается обратно.

Почему не подмена модуля node:fs/promises

Естественный вопрос: почему бы просто не подменить realpath в node:fs/promises и не закрыть все вызовы разом? Пробовали, в этом окружении не работает:

  • правка объекта fs.promises не помогает — dsh-app-boot импортирует node:fs/promises задолго до плагинов, а ESM-фасад фиксирует нативную функцию в момент первого импорта;
  • хук загрузчика (module.registerHooks) помогает, но обязан встать раньше первого импорта, а плагин загружается позже — проверено счётчиком модулей: dsh-workspace среди них=false;
  • предзагрузка через NODE_OPTIONS успевает (среди них=true), но переменная окружения до процесса хоста не доходит: хост DSH — это utility-процесс внутри Electron-приложения, а не процесс, запускаемый через сгенерированный dsh.cmd. Правка самого dsh.cmd не переживает перезапуск: приложение сверяет содержимое генерации и создаёт новую.

Поэтому вместо одного общего шима — три точечных патча, которые работают независимо от порядка загрузки.

Установка

dsh plugin --profile desktop add dsh-allowed-workspace-roots

Или из скачанного архива:

dsh plugin --profile desktop add ./dsh-allowed-workspace-roots-1.0.0.tgz

После установки нужен полный перезапуск DSH Desktop: граф клиентских бандлов собирается при старте хоста.

Настройка

Список разрешённых корней задаётся в cordis.patch.yml пакета:

- insert:
    - id: allowed-workspace-roots
      name: 'dsh-allowed-workspace-roots'
      config:
        roots:
          - 'T:\'
          # - 'T:\Shared'   # пример сужения области

Сравнение идёт по границе сегмента пути: корень T:\Shared не пропустит T:\SharedOther. Регистр, прямые слэши и завершающий разделитель значения не имеют. После правки конфигурации нужен перезапуск DSH.

Для всего, что не входит в allow-list, поведение DSH не меняется: решение принимает штатный валидатор.

Ограничения

Нужен режим «Полный доступ» (danger-full-access). При workspace-write команда выполняется отдельным процессом — ACL-runner'ом песочницы, — а до него внутрипроцессные патчи не достают. При danger-full-access dsh-sandbox-local не вызывает confine() вовсе, runner не запускается, и этот слой исчезает.

Учтите цену: песочница ограничивает, куда агент может писать. При полном доступе он пишет всюду, куда может писать пользователь.

Патчи опираются на внутренности DSH. Проверено на DSH Desktop 2.0.9. После обновления DSH стоит пройти сценарий заново: добавить папку, создать сессию, отправить запрос. Отказ безопасен — пользователь просто увидит прежнюю ошибку.

Сам том остаётся сетевым. Плагин чинит канонизацию путей, но не меняет природу монтирования: при обрыве связи с сервером рабочая область виснет, блокировки и атомарные переименования ненадёжны, а VFS-кэш rclone делает обход больших каталогов заметно медленнее локального диска.

Не используйте --network-mode как альтернативу. Он действительно чинит realpath, но переводит монтирование в общее на машину пространство имён: имя шары видно через net use из чужого сеанса, и пользователи получают доступ к файлам друг друга. Изоляция букв диска этого не допускает.

Проверка

node test/verify.mjs

41 проверка: логика allow-list, клиентская половина в обоих порядках загрузки, патч реестра и патч слоя файлов. Часть проверок идёт по настоящему тому — подделка не воспроизведёт сломанный realpath. Корень задаётся переменной DSH_TEST_ROOT (по умолчанию T:\); если тома нет или он обычный NTFS, эти проверки пропускаются с пояснением.

Если Node нет, тесты запускаются встроенным в DSH Desktop:

$env:ELECTRON_RUN_AS_NODE='1'
& 'C:\Program Files\DSH Desktop\DSH Desktop.exe' test/verify.mjs

Внутри запущенного DSH есть и самопроверка хоста: переменная DSH_ALLOWED_ROOTS_PROBE=1 заставляет плагин сразу создать рабочую область, разрешить путь и перечислить каталог, записав результат в лог.

Состав

Файл Назначение
lib/client.js патч 1: снятие проверки тома для путей из allow-list
lib/registry-patch.mjs патч 2: реестр рабочих областей
lib/fs-patch.mjs патч 3: слой файлов
lib/roots.mjs общая логика allow-list
lib/index.js host-половина: ставит патчи 2 и 3
cordis.patch.yml строка плагина в дереве загрузчика + allow-list
test/verify.mjs автотесты