diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b45f7d8..1cf64ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: qopen_qmlcachegen=$(find /usr/lib/qt6 -type f -name qmlcachegen -print -quit) test -n "$qopen_qmlcachegen" qopen_qml_output=$(mktemp -d) - for qopen_qml_file in QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do + for qopen_qml_file in BoundedProcess.qml QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do "$qopen_qmlcachegen" --only-bytecode \ -o "$qopen_qml_output/$qopen_qml_file.qmlc" "$qopen_qml_file" done diff --git a/AGENTS.md b/AGENTS.md index a890f90..bdc5fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ patch release, then propagate the released commit back to `uat` and `dev`. | `QOpen.qml` | Main menu, search, collections, routing, mutations, and status | | `ResourceEditor.qml` | Type-aware add/edit form and validation feedback | | `PathPicker.qml` | Embedded bounded local file/directory browser | +| `BoundedProcess.qml` | One-line bounded backend transport and process deadlines | | `BarWidget.qml` | Optional Omarchy bar entry point | | `bin/qopen` | Python CLI, JSON API, validation, persistence, and launching | | `tests/test_backend.py` | Backend, API, safety, and QML regression tests | @@ -158,6 +159,8 @@ early feedback, but every write and launch must still be validated by Python. ### QML changes - Keep process commands as arrays. +- Route every backend response through `BoundedProcess.qml`; do not use + `StdioCollector` or direct catalog `FileView` access. - Keep all file selection inside `PathPicker.qml`. - Preserve request ids and stale-response rejection in path browsing. - Preserve explicit error feedback for clipboard, target checks, and writes. @@ -171,11 +174,17 @@ early feedback, but every write and launch must still be validated by Python. requirement. - Keep machine API output compact JSON and send human diagnostics to the appropriate stream. +- Keep catalog, backup and recovery operations descriptor-anchored to one + trusted state directory. Do not reintroduce ordinary pathname reads, + `shutil.copy2`, pathname `chmod`, or a pathname lock file. +- Enforce producer-side byte limits and monotonic deadlines before data reaches + QML. Every captured helper subprocess must have bounded output. - Create starter resources only when the catalog is missing; preserve existing catalogs byte-for-byte during reads and upgrades. - Validate the complete catalog before writes, recovery, and rendering. -- Keep file modes private (`0600`) for configuration, backup, lock, and invalid - recovery snapshots. +- Keep the default state directory private (`0700`) and configuration, backup + and invalid recovery snapshots private (`0600`). Never change the parent + directory permissions of a custom `QOPEN_CONFIG`. - Use `subprocess` argv lists and bounded timeouts where a subprocess can wait. - Expand paths only in the backend; do not commit machine-specific absolute paths. @@ -206,7 +215,7 @@ Compile QML with the same strategy as CI: qopen_qmlcachegen=$(find /usr/lib/qt6 -type f -name qmlcachegen -print -quit) test -n "$qopen_qmlcachegen" qopen_qml_output=$(mktemp -d) -for qopen_qml_file in QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do +for qopen_qml_file in BoundedProcess.qml QOpen.qml ResourceEditor.qml PathPicker.qml BarWidget.qml; do "$qopen_qmlcachegen" --only-bytecode \ -o "$qopen_qml_output/$qopen_qml_file.qmlc" "$qopen_qml_file" done @@ -246,6 +255,8 @@ At minimum, backend changes must cover: - command argument round trips, including spaces and quotes; - unsafe URL, SSH, and control-character rejection; - permission audit and repair; +- symlink, hard-link, FIFO and non-regular state-file rejection; +- held-lock, oversized-response and concurrent pathname-race behavior; - recovery with valid and invalid backups; - project/file browser filtering and stale request behavior. diff --git a/BoundedProcess.qml b/BoundedProcess.qml new file mode 100644 index 0000000..e2da35c --- /dev/null +++ b/BoundedProcess.qml @@ -0,0 +1,108 @@ +import Quickshell.Io +import QtQuick + +Item { + id: root + + width: 0 + height: 0 + visible: false + + property int timeoutMs: 2000 + property int responseLimit: 1310720 + readonly property bool running: child.running + readonly property var processId: child.processId + + property int activeRequestId: 0 + property string responseText: "" + property string protocolError: "" + property bool responseSeen: false + property bool timedOut: false + property bool canceled: false + + signal finished(string response, int exitCode, int requestId, string error) + + function start(command, requestId) { + if (child.running || !Array.isArray(command) || command.length === 0) return false + root.activeRequestId = Number(requestId || 0) + root.responseText = "" + root.protocolError = "" + root.responseSeen = false + root.timedOut = false + root.canceled = false + deadlineTimer.restart() + child.command = command + child.running = true + return true + } + + function cancel() { + root.canceled = true + deadlineTimer.stop() + if (child.running) { + child.signal(15) + killTimer.restart() + } + } + + function abortProtocol(message) { + if (root.protocolError) return + root.protocolError = String(message || "Invalid backend response") + if (child.running) { + child.signal(15) + killTimer.restart() + } + } + + Process { + id: child + + stdout: SplitParser { + splitMarker: "\n" + onRead: function(data) { + if (root.canceled || root.protocolError) return + var line = String(data || "") + if (root.responseSeen) { + root.abortProtocol("Backend returned more than one response") + return + } + if (line.length > root.responseLimit) { + root.abortProtocol("Backend response exceeded its safety limit") + return + } + root.responseText = line + root.responseSeen = true + } + } + + onExited: function(exitCode) { + deadlineTimer.stop() + killTimer.stop() + var error = root.protocolError + if (root.timedOut) error = "Backend operation timed out" + else if (root.canceled) error = "Backend operation canceled" + else if (!root.responseSeen) error = "Backend returned no response" + root.finished(root.responseText, exitCode, root.activeRequestId, error) + } + } + + Timer { + id: deadlineTimer + interval: root.timeoutMs + onTriggered: { + root.timedOut = true + if (child.running) { + child.signal(15) + killTimer.restart() + } + } + } + + Timer { + id: killTimer + interval: 250 + onTriggered: { + if (child.running) child.signal(9) + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dcf96f..dad16c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable user-facing changes to QOpen are documented here. +## [2.5.1] - 2026-08-28 + +### Security + +- Anchored catalog, backup and recovery operations to a trusted, non-symlinked + state-directory descriptor and rejected symlinks, hard links and non-regular + state files. +- Replaced the pathname lock file with a deadline-bound lock on the trusted + state-directory descriptor. +- Added hard limits for catalog reads, API input/output, helper output, + resource counts and directory browsing work. +- Removed direct QML `FileView` catalog access and every unbounded + `StdioCollector`; backend calls now use one-line responses and real process + deadlines with TERM-to-KILL escalation. + +### Changed + +- Backups now reuse the exact bytes from the validated pre-mutation catalog. +- The default state directory is secured to `0700` by the explicit permission + repair command; custom `QOPEN_CONFIG` parent permissions are never changed. +- Direct raw catalog editing through `qopen --edit` is disabled so writes cannot + bypass validation, backup and atomic replacement. +- Path browsing now bounds both returned entries and total scan work. + +### Tests + +- Added adversarial coverage for parent and state-file symlinks, FIFOs, hard + links, oversized catalogs, held locks, unbounded clipboard output and + concurrent pathname replacement. + ## [2.5.0] - 2026-08-28 ### Added diff --git a/DESIGN.md b/DESIGN.md index 85d5323..27046a0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,4 +1,4 @@ -# QOpen 2.4 Design +# QOpen 2.5.1 Design ## Product boundary @@ -18,14 +18,21 @@ shell commands well. QOpen owns the resources that do not fit that model: Omarchy menu / bar | v -QOpen.qml <--- watches ---> ~/.config/qopen/config.json +QOpen.qml + | + | bounded argv / one-line JSON + v +BoundedProcess.qml | - | argv only v bin/qopen - |-- validate schema and targets - |-- lock + backup + atomic replace + |-- descriptor-anchored state validation + |-- directory FD lock + backup + atomic replace + |-- bounded reads, responses and helper processes `-- dispatch through Omarchy launch helpers + | + v +~/.config/qopen/config.json ``` The split is deliberate. QML is responsible for presentation, search and @@ -83,14 +90,29 @@ Supported item types are `web`, `file`, `project`, `tui`, `command` and ## Persistence and safety -- A file lock serializes mutations. -- Writes go to a same-directory temporary file and use atomic replacement. -- The previous catalog is copied to `config.json.bak` before mutation. +- Every state-directory component is opened from `/` with directory + descriptors, `O_DIRECTORY` and `O_NOFOLLOW`; the final directory must be + owned by the current user and not writable by another account. +- The trusted state-directory descriptor itself is locked with nonblocking + `flock` and a monotonic deadline; no pathname lock file is needed. +- Configuration and backup files use `O_NOFOLLOW`, `O_NONBLOCK` and descriptor + metadata checks that reject symlinks, hard links and non-regular files. +- Reads are capped at 1 MiB and detect in-place changes before accepting JSON. +- Writes go to a descriptor-relative same-directory temporary file, `fsync` + the file, atomically replace with `src_dir_fd`/`dst_dir_fd`, then `fsync` the + directory. +- The same validated bytes used for a mutation are retained as + `config.json.bak`; the backup is never reopened through an ordinary path. - Backups are validated before recovery, and the replaced invalid catalog is retained as a private timestamped snapshot. -- New state files use mode `0600`; `doctor` audits permissions and repair is - explicit. +- The default state directory uses `0700`; state files use `0600`. Custom + `QOPEN_CONFIG` parent permissions are validated but never changed. - The complete schema is validated before every write. +- API input, producer output, helper output, item count and directory scan work + all have explicit limits. +- QML has no catalog `FileView` and no `StdioCollector`. Backend responses use + one compact JSON line, a second consumer-side limit and real TERM-to-KILL + process deadlines. - Commands are represented as argument arrays and use lossless POSIX quoting when displayed for editing. - File and project paths expand `~` and environment variables only in the @@ -100,6 +122,12 @@ Supported item types are `web`, `file`, `project`, `tui`, `command` and replacing newer navigation state. - Destructive removal always asks for confirmation. +These controls prevent pathname redirection, special-file blocking, unbounded +retention and writes outside the trusted state directory. They do not claim to +prevent a deliberately malicious process running as the same Unix uid from +directly replacing valid user-owned application code or data; that process +already has the user's filesystem authority. + ## Integration The plugin id is `qopen.launcher` and its manifest exposes `menu` and diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0fa93d0..ce6c807 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -112,6 +112,48 @@ React 富文本、动画、图标、状态、表单和交互能力分组。 该版本没有引入新数据 schema。2.3 的配置可以直接使用,不需要迁移。 +### v2.5:首次目录与市场发布准备 + +2.5 将首次启动资源收敛为六项通用示例,并统一 `main`、`uat`、`dev`、tag、 +双语 README、预览图和 AI 协作流程,为 Omarchy Plugin Marketplace 提交建立精确 +commit 基线。 + +### v2.5.1:市场审核安全加固 + +市场审核在 `bcaf8f047fc581019cecb29f1689752e0303c89f` 指出了两个阻塞项:状态文件 +仍通过普通路径操作,QML 仍直接使用 `FileView` 和无界 `StdioCollector`。2.5.1 +据此重构完整安全边界: + +- 从 `/` 开始逐级使用目录描述符和 `O_NOFOLLOW` 打开状态目录; +- 配置、备份和恢复快照只通过可信目录 FD 与相对名称访问; +- 拒绝 symlink、hardlink、FIFO、设备、Socket、目录和非当前用户文件; +- 取消路径锁文件,直接对可信状态目录 FD 使用带截止时间的非阻塞 `flock`; +- 使用同一次已验证读取的原始 bytes 创建备份,再执行 descriptor-relative 原子替换; +- 配置读取、API 输入输出、helper 输出、目录扫描数量和扫描时间都具有硬上限; +- 删除 QML `FileView` 与全部 `StdioCollector`,统一通过 `BoundedProcess.qml` + 读取单行 JSON,并执行真实 TERM 到 KILL 截止时间; +- 禁用无法参与锁、校验、备份与原子替换协议的原始 `$QOPEN --edit` 入口; +- 增加父目录和文件 symlink、FIFO、hardlink、超限输入、锁超时、无界剪贴板输出和 + 并发路径替换攻击测试。 + +安全保证针对路径重定向、特殊文件阻塞、竞态写出和资源耗尽。相同 Unix uid 的恶意 +进程本身拥有用户文件权限,因此不能声称阻止它直接替换合法普通文件或插件代码。 + +#### v2.5.1 实施与验收计划 + +| 阶段 | 实施内容 | 完成标准 | +| --- | --- | --- | +| 1. 威胁建模 | 固定审核 commit、枚举配置/备份/锁/QML 输出入口与同 uid 边界 | 每个审核意见都有明确代码入口和测试映射 | +| 2. 状态目录加固 | 从 `/` 逐级无跟随打开目录,校验最终目录和状态文件,移除路径锁文件 | symlink、hardlink、FIFO、设备及越权权限全部 fail closed | +| 3. 原子生命周期 | 在同一目录 FD 与锁内完成读取、备份、临时写入、替换和恢复 | 备份来自同一次已验证读取,文件和目录均完成 `fsync` | +| 4. 资源边界 | 限制目录、item、API 输入输出、helper 输出与扫描工作量 | 超限输入快速失败,不进入 QML 或形成无界内存占用 | +| 5. QML 进程协议 | 删除 `FileView`/`StdioCollector`,统一单行 JSON、请求 id、截止时间和 TERM→KILL | 后端挂起、重复响应、无响应和陈旧响应均得到确定处理 | +| 6. 对抗验证 | 覆盖预置路径、并发替换、锁占用、特殊文件、超大输出和超时 helper | 自动测试无挂起、外部哨兵文件不变、真实个人目录哈希不变 | +| 7. 发布门禁 | 校验 manifest、Python、全部 QML、插件规范、双语文档与安装运行时 | CI 与本机门禁通过,UAT 后以精确 HEAD 请求市场复审 | + +版本号、文档和实现一起提升到 `2.5.1`。该修复先通过 hotfix PR 进入 `main`;在用户 +运行验收和市场复审完成前,不创建或移动 release tag,也不覆盖已发布的 `v2.5.0`。 + ## 3. v2.2 文件选择器事故复盘 ### 现象 @@ -164,14 +206,17 @@ QOpen.qml │ └─ PathPicker.qml │ ├─ 路径与列表交互 │ └─ api browse-path - └─ Process argv + └─ BoundedProcess.qml + ├─ 单行 JSON 与响应上限 + └─ 真实进程截止时间 | v bin/qopen ├─ JSON schema 验证 - ├─ 目录枚举 - ├─ 文件锁 - ├─ 备份与原子替换 + ├─ 有界目录枚举 + ├─ 描述符锚定状态目录 + ├─ 目录 FD 锁 + ├─ 有界备份与原子替换 └─ 资源启动分发 ``` @@ -200,6 +245,7 @@ QOpen.qml | `QOpen.qml` | 主界面、搜索、资源列表、路由与 CRUD 协调 | | `ResourceEditor.qml` | 新增/编辑表单 | | `PathPicker.qml` | 安全内嵌文件和目录浏览器 | +| `BoundedProcess.qml` | 有界单行后端协议与进程截止时间 | | `BarWidget.qml` | Omarchy 状态栏按钮 | | `bin/qopen` | Python CLI、机器 API、持久化与启动后端 | | `README.md` | 用户安装、使用与排障文档 | @@ -211,7 +257,7 @@ QOpen.qml ### 首次目录初始化 -`api catalog` 在 `~/.config/qopen/config.json` 不存在时,通过现有锁和原子写入流程创建 +`api catalog` 在 `~/.config/qopen/config.json` 不存在时,通过目录 FD 锁和原子写入流程创建 一个六项通用示例目录。示例覆盖 web、project、file、TUI 和 command,包括 Omarchy、 GitHub、home 目录、Omarchy Shell 配置、btop 与 Fastfetch。它们不包含维护者本机路径、 凭据或网络主机。 @@ -222,17 +268,19 @@ GitHub、home 目录、Omarchy Shell 配置、btop 与 Fastfetch。它们不包 ### 写入流程 1. QML 将编辑结果序列化为 JSON 参数。 -2. 后端读取当前配置并取得独占文件锁。 +2. 后端逐级无跟随打开可信状态目录并取得目录 FD 独占锁。 3. 在副本上执行 mutation。 4. 验证完整配置,而不只验证当前 item。 -5. 将旧配置复制为 `config.json.bak`。 -6. 在配置同目录创建临时文件并 `fsync`。 -7. 使用 `os.replace` 原子替换。 -8. 对配置目录执行 `fsync`。 - -新建的配置、备份和锁文件使用 `0600`。旧文件不会在普通启动过程中被静默改权; -维护者或用户通过 `qopen --doctor` 检查,再显式运行 -`qopen fix-permissions`。恢复流程先验证备份,再保存当前目录快照,最后原子替换, +5. 将同一次安全读取的旧 bytes 写入 `config.json.bak` 临时文件并 `fsync`。 +6. 通过目录 FD 原子替换备份并 `fsync` 目录。 +7. 在同一目录 FD 下创建新配置临时文件并 `fsync`。 +8. 使用带 `src_dir_fd`/`dst_dir_fd` 的 `os.replace` 原子替换。 +9. 对配置目录执行 `fsync`。 + +默认状态目录使用 `0700`,配置、备份和快照使用 `0600`,不再创建锁文件。旧文件 +不会在普通启动过程中被静默改权;维护者或用户通过 `qopen --doctor` 检查,再显式 +运行 `qopen fix-permissions`。自定义 `QOPEN_CONFIG` 的父目录权限只校验、不修改。 +恢复流程先验证备份,再保存当前目录快照,最后原子替换, 因此无效备份不会覆盖现有目录。 ### 命令边界 diff --git a/PathPicker.qml b/PathPicker.qml index b74cc5d..3b76620 100644 --- a/PathPicker.qml +++ b/PathPicker.qml @@ -58,7 +58,7 @@ FocusScope { root.requestSerial++ root.pendingPath = "" root.pendingRequestSerial = 0 - if (browseProcess.running) browseProcess.running = false + if (browseProcess.running) browseProcess.cancel() root.canceled() } @@ -78,6 +78,13 @@ FocusScope { if (!browseProcess.running) root.startPendingRequest() } + function refreshCurrentPath() { + if (!root.opened || root.loading) return + var target = root.currentPath + if (!target) target = String(pathField.text || "").trim() + root.loadPath(target || Quickshell.env("HOME")) + } + function startPendingRequest() { if (!root.opened || !root.pendingPath || browseProcess.running) return var requested = root.pendingPath @@ -85,10 +92,10 @@ FocusScope { root.activeRequestSerial = root.pendingRequestSerial root.pendingPath = "" root.pendingRequestSerial = 0 - browseProcess.command = [root.backendPath, "api", "browse-path", "--path", requested, + var command = [root.backendPath, "api", "browse-path", "--path", requested, "--type", root.resourceType, "--request-id", String(root.activeRequestSerial)] - if (hidden) browseProcess.command.push("--show-hidden") - browseProcess.running = true + if (hidden) command.push("--show-hidden") + browseProcess.start(command, root.activeRequestSerial) } function consumeResponse(raw) { @@ -177,6 +184,11 @@ FocusScope { event.accepted = true return } + if (event.key === Qt.Key_F5) { + root.refreshCurrentPath() + event.accepted = true + return + } if (pathField.activeFocus) return if (event.key === Qt.Key_Down && entryModel.count > 0) { root.selectIndex(Math.min(entryModel.count - 1, entryList.currentIndex + 1)) @@ -269,13 +281,18 @@ FocusScope { } TextField { id: pathField - width: parent.width - Style.space(42 * 4) - parent.spacing * 4 + width: parent.width - Style.space(42 * 5) - parent.spacing * 5 height: parent.height text: root.currentPath placeholderText: "Enter a directory path" foreground: root.foreground onAccepted: root.loadPath(text) Keys.onPressed: function(event) { + if (event.key === Qt.Key_F5) { + root.refreshCurrentPath() + event.accepted = true + return + } if (event.key === Qt.Key_Escape) { root.forceActiveFocus() event.accepted = true @@ -289,6 +306,14 @@ FocusScope { enabled: !root.loading onClicked: root.loadPath(pathField.text) } + Button { + width: Style.space(42); height: parent.height + iconText: "󰑐"; tooltipText: "Refresh current directory · F5" + foreground: root.foreground; bordered: true + enabled: root.currentPath !== "" && !root.loading + opacity: enabled ? 1 : 0.38 + onClicked: root.refreshCurrentPath() + } Button { width: Style.space(42); height: parent.height iconText: root.showHidden ? "󰈈" : "󰈉" @@ -484,14 +509,16 @@ FocusScope { } } - Process { + BoundedProcess { id: browseProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.consumeResponse(text) - } - onExited: function(exitCode) { + timeoutMs: 3000 + onFinished: function(response, exitCode, requestId, error) { if (!root.opened) return + if (requestId === root.requestSerial && !error) root.consumeResponse(response) + else if (requestId === root.requestSerial && error) { + root.loading = false + root.errorText = error + } if (root.pendingPath) { Qt.callLater(root.startPendingRequest) return diff --git a/QOpen.qml b/QOpen.qml index dc3c124..5854d1e 100644 --- a/QOpen.qml +++ b/QOpen.qml @@ -16,7 +16,6 @@ Item { readonly property string pluginId: (manifest && manifest.id) || "qopen.launcher" readonly property string pluginDir: (manifest && manifest.__sourceDir) || "" readonly property string backendPath: pluginDir ? pluginDir + "/bin/qopen" : "" - readonly property string configPath: Quickshell.env("HOME") + "/.config/qopen/config.json" onPluginDirChanged: { if (root.pluginDir) root.requestCatalogReload() @@ -46,6 +45,9 @@ Item { property var deleteCandidate: null property bool configMissing: false property string pendingCopyText: "" + property int catalogRequestSerial: 0 + property int mutationRequestSerial: 0 + property int apiRequestSerial: 0 readonly property color background: Color.menu.background readonly property color foreground: Color.menu.text @@ -104,7 +106,7 @@ Item { root.pendingBrowse = payload.browse === true if (root.shell && typeof root.shell.hide === "function") root.shell.hide("omarchy.menu") root.opened = true - configFile.reload() + root.requestCatalogReload() Qt.callLater(function() { searchField.text = root.query if (!root.pendingAction) { @@ -143,7 +145,7 @@ Item { } function refresh() { - configFile.reload() + root.requestCatalogReload() return "ok" } @@ -192,9 +194,8 @@ Item { ].join(" ").toLowerCase() } - function loadConfig(raw) { + function loadConfig(parsed) { try { - var parsed = JSON.parse(raw) if (!parsed || !Array.isArray(parsed.items)) throw new Error("items must be an array") root.catalog = parsed root.configError = "" @@ -219,8 +220,11 @@ Item { return } root.catalogResponseHandled = false - catalogProcess.command = [root.backendPath, "api", "catalog"] - catalogProcess.running = true + root.catalogRequestSerial++ + catalogProcess.start( + [root.backendPath, "api", "catalog"], + root.catalogRequestSerial + ) } function handleCatalogResponse(raw) { @@ -237,7 +241,7 @@ Item { root.rebuildItems() return } - root.loadConfig(JSON.stringify(response.result || ({}))) + root.loadConfig(response.result || ({})) } catch (e) { root.configError = "Invalid catalog response: " + e root.configMissing = false @@ -416,12 +420,6 @@ Item { Qt.callLater(function() { Quickshell.execDetached(args) }) } - function editRawConfig() { - if (!root.backendPath) return - root.dismiss() - Qt.callLater(function() { Quickshell.execDetached([root.backendPath, "--edit"]) }) - } - function toggleFavorite(itemId) { if (!itemId || mutationProcess.running) return root.statusText = "Updating favorite…" @@ -473,8 +471,8 @@ Item { function runMutation(command) { if (mutationProcess.running) return root.mutationResponseHandled = false - mutationProcess.command = command - mutationProcess.running = true + root.mutationRequestSerial++ + mutationProcess.start(command, root.mutationRequestSerial) } function handleMutationResponse(raw) { @@ -495,7 +493,7 @@ Item { else root.statusText = "Catalog updated" root.deleteCandidate = null - configFile.reload() + root.requestCatalogReload() root.statusError = false statusTimer.restart() } catch (e) { @@ -509,9 +507,14 @@ Item { return } root.apiResponseHandled = false - apiProcess.command = [root.backendPath, "api", action, "--payload", payload] - if (action === "update") apiProcess.command.push("--original", originalPayload) - apiProcess.running = true + if (String(payload || "").length > 32768 || String(originalPayload || "").length > 32768) { + resourceEditor.saveFailed("Resource data exceeds the safety limit") + return + } + var command = [root.backendPath, "api", action, "--payload", payload] + if (action === "update") command.push("--original", originalPayload) + root.apiRequestSerial++ + apiProcess.start(command, root.apiRequestSerial) } function handleApiResponse(raw) { @@ -535,7 +538,7 @@ Item { + (warnings.length ? " · " + warnings[0] : "") root.statusError = false statusTimer.restart() - configFile.reload() + root.requestCatalogReload() Qt.callLater(function() { searchField.forceActiveFocus() }) } catch (e) { resourceEditor.saveFailed("Invalid response from QOpen backend") @@ -545,34 +548,14 @@ Item { ListModel { id: groupModel } ListModel { id: displayModel } - FileView { - id: configFile - path: root.configPath - watchChanges: true - printErrors: false - onLoaded: root.requestCatalogReload() - onLoadFailed: function(error) { - root.configError = "Preparing first-run resources…" - root.configMissing = true - root.catalog = ({ version: 1, defaults: ({}), items: [] }) - root.rebuildGroups() - root.rebuildItems() - root.requestCatalogReload() - } - onFileChanged: reload() - } - - Process { + BoundedProcess { id: catalogProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - catalogFallbackTimer.stop() - root.handleCatalogResponse(text) - } - } - onExited: function(exitCode) { - if (!root.catalogResponseHandled) catalogFallbackTimer.restart() + timeoutMs: 3000 + onFinished: function(response, exitCode, requestId, error) { + if (requestId !== root.catalogRequestSerial) return + root.handleCatalogResponse(error + ? JSON.stringify({ ok: false, error: error }) + : response) if (root.catalogReloadPending) { root.catalogReloadPending = false Qt.callLater(root.requestCatalogReload) @@ -580,31 +563,15 @@ Item { } } - Timer { - id: catalogFallbackTimer - interval: 120 - onTriggered: root.handleCatalogResponse('{"ok":false,"error":"QOpen catalog validation failed"}') - } - - Process { + BoundedProcess { id: mutationProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - mutationFallbackTimer.stop() - root.handleMutationResponse(text) - } + timeoutMs: 3000 + onFinished: function(response, exitCode, requestId, error) { + if (requestId !== root.mutationRequestSerial) return + root.handleMutationResponse(error + ? JSON.stringify({ ok: false, error: error }) + : response) } - onExited: function(exitCode) { - if (!root.mutationResponseHandled) - mutationFallbackTimer.restart() - } - } - - Timer { - id: mutationFallbackTimer - interval: 120 - onTriggered: root.handleMutationResponse('{"ok":false,"error":"QOpen backend operation failed"}') } Process { @@ -617,27 +584,17 @@ Item { } } - Process { + BoundedProcess { id: apiProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - apiFallbackTimer.stop() - root.handleApiResponse(text) - } - } - onExited: function(exitCode) { - if (!root.apiResponseHandled) - apiFallbackTimer.restart() + timeoutMs: 3000 + onFinished: function(response, exitCode, requestId, error) { + if (requestId !== root.apiRequestSerial) return + root.handleApiResponse(error + ? JSON.stringify({ ok: false, error: error }) + : response) } } - Timer { - id: apiFallbackTimer - interval: 120 - onTriggered: root.handleApiResponse('{"ok":false,"error":"QOpen backend failed"}') - } - Timer { id: statusTimer interval: 4000 @@ -762,7 +719,7 @@ Item { root.manage("remove", root.selectedItemId()) event.accepted = true } else if (ctrl && event.key === Qt.Key_R) { - configFile.reload() + root.requestCatalogReload() root.statusText = "Catalog reloaded" root.statusError = false statusTimer.restart() @@ -857,7 +814,7 @@ Item { ListView { id: groupList width: parent.width - height: parent.height - rawConfigButton.height - parent.spacing * 2 - Style.space(18) + height: parent.height - parent.spacing - Style.space(18) model: groupModel clip: true spacing: Style.space(3) @@ -932,32 +889,6 @@ Item { } } - Rectangle { - id: rawConfigButton - width: parent.width - height: Style.space(36) - radius: root.radius - color: rawMouse.containsMouse ? root.subtle : "transparent" - border.width: 1 - border.color: root.subtle - - Text { - text: " Edit raw config" - color: root.muted - font.family: root.fontFamily - font.pixelSize: Style.font.caption - anchors.centerIn: parent - } - - MouseArea { - id: rawMouse - anchors.fill: parent - enabled: !root.editorOpen - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: root.editRawConfig() - } - } } Rectangle { diff --git a/README.md b/README.md index 2482366..507a616 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ regrouped or removed by you. > 而是把项目目录、文件、常用文档、前端生态网站、TUI、命令和 SSH 目标集中到 > 一个支持搜索、分组、收藏与原生编辑的界面中。 -Current release: **2.5.0** +Current release: **2.5.1** **Documentation:** [简体中文](README.zh-CN.md) · [Development record](DEVELOPMENT.md) @@ -28,7 +28,8 @@ Current release: **2.5.0** - Normal keyboard paste plus an explicit clipboard button for path fields. - Automatic name, id, default group and icon inference where appropriate. - Inline favorite, copy-target, edit and confirmed remove actions. -- Atomic JSON writes with locking and an automatic last-known-good backup. +- Descriptor-anchored JSON writes with directory locking and a last-known-good backup. +- Bounded backend responses and real process deadlines before data reaches QML. - Validated backup recovery and explicit private-permission repair commands. - Lossless editing of command arguments containing spaces or quotes. - Optional bar widget: left-click opens all resources; right-click opens favorites. @@ -97,7 +98,7 @@ environment with: ~/.config/omarchy/plugins/qopen.launcher/bin/qopen --doctor ``` -The 2.5.0 release was developed and verified on Omarchy 4.0.1, Quickshell +The 2.5.1 release was developed and verified on Omarchy 4.0.1, Quickshell 0.3.1 and Qt 6.11.2. These are tested versions, not strict pins. ## Installation @@ -356,20 +357,30 @@ Important guarantees: - ids contain lowercase letters, numbers, `_` or `-` and must be unique; - each mutation validates the complete catalog; -- writes use a lock and same-directory atomic replacement; +- every state-directory component is opened without following symlinks; +- configuration and backup access rejects symlinks, hard links and non-regular files; +- writes use a lock on the trusted state-directory descriptor and same-directory + atomic replacement; - the previous catalog is retained as `config.json.bak`; -- new state files are private (`0600`), and existing permissions can be audited - or repaired explicitly; -- QML never writes the catalog directly; +- new default state directories are created with `0700`, and new or rewritten state + files use `0600`; existing state is accepted only when it is not group- or + world-writable, while `--doctor` reports looser private modes and + `fix-permissions` repairs the default state directory and files to `0700`/`0600`; +- catalog reads, API responses, helper output and directory scans are bounded; +- QML never opens or writes the catalog directly and backend processes have deadlines; - personal resource data is not automatically synchronized to this GitHub repository. Set `QOPEN_CONFIG` to use a different catalog with the CLI: ```bash -QOPEN_CONFIG=~/Documents/qopen-work.json \ +QOPEN_CONFIG=~/Documents/qopen-work/config.json \ ~/.config/omarchy/plugins/qopen.launcher/bin/qopen --list ``` +Use a dedicated directory that is owned by your account and is not group- or +world-writable. QOpen validates custom state directories but never changes their +directory permissions. + ## Command-line interface The backend is installed with the plugin: @@ -387,8 +398,7 @@ $QOPEN edit [id] # Guided edit flow $QOPEN remove [id] # Confirmed removal $QOPEN favorite toggle # Toggle favorite state $QOPEN recover # Validate and restore config.json.bak -$QOPEN fix-permissions # Restrict QOpen state files to mode 0600 -$QOPEN --edit # Open raw JSON in the configured editor +$QOPEN fix-permissions # Secure the default state directory and files $QOPEN --doctor # Validate dependencies and every item $QOPEN --version ``` @@ -420,11 +430,15 @@ Omarchy menu / shortcut / bar widget ResourceEditor.qml PathPicker.qml | | +---------+----------+ - | argv only + | bounded argv / one-line JSON + v + BoundedProcess.qml + | v bin/qopen | - config lock + validation + descriptor-anchored validation + + directory FD lock | v ~/.config/qopen/config.json @@ -440,9 +454,26 @@ terminating the entire desktop shell. Version 2.2 briefly used native dialogs; 2.3 removed them after reproducible Quickshell crashes. The evidence and release validation are documented in [DEVELOPMENT.md](DEVELOPMENT.md). +User-selected resource browsing intentionally follows symlinks so ordinary +project and file workflows behave like the filesystem the user selected. That +browser path is not used for QOpen persistence: catalog, backup, lock and +replacement operations stay inside the separately validated `SecureStateStore` +trust boundary. + Commands are passed as argument arrays. Resource values are never concatenated into a shell command by QML. +QML does not use `FileView` for the catalog and does not retain complete process +streams with `StdioCollector`. The Python producer validates and caps every API +response before writing it; QML then applies a secondary protocol-size check. +Bounded helper processes run in their own process groups and have real deadlines +with TERM-to-KILL escalation. Catalog, backup and recovery operations remain +anchored to one trusted directory descriptor for their complete lifecycle. + +Direct raw editing through `$QOPEN --edit` is intentionally disabled because an +external editor cannot participate in QOpen's lock, validation, backup and atomic +replacement protocol. Use the native editor or `$QOPEN edit [id]` instead. + ## Updating and removing Update a Git-installed copy: @@ -499,7 +530,7 @@ mutation. Restore it only after validation: Before replacing the catalog, QOpen preserves the current invalid file as a private timestamped `config.json.invalid-*` snapshot. If `--doctor` reports -group- or world-readable state files, repair them explicitly: +insecure default-state permissions, repair them explicitly: ```bash ~/.config/omarchy/plugins/qopen.launcher/bin/qopen fix-permissions @@ -532,4 +563,4 @@ python -m unittest discover -s tests -v ## License -[MIT](LICENSE) © 2026 CoderLambert +[MIT](LICENSE) © 2026 CoderLambert \ No newline at end of file diff --git a/README.zh-CN.md b/README.zh-CN.md index 7a230b9..7d46eb4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ QOpen 是运行在 [Omarchy](https://omarchy.org/) 上的个人资源启动器 > English summary: QOpen is a curated personal resource launcher for Omarchy. It brings projects, files, documentation, web tools, TUI applications, commands and SSH destinations into one searchable interface with grouping, favorites and native editing. -当前版本:**2.5.0** +当前版本:**2.5.1** **文档:** [English](README.md) · [开发记录](DEVELOPMENT.md) @@ -22,7 +22,8 @@ QOpen 是运行在 [Omarchy](https://omarchy.org/) 上的个人资源启动器 - 路径字段支持常规键盘粘贴,并提供明确的剪贴板按钮。 - 在适合时自动推导名称、id、默认分组和图标。 - 资源行内支持收藏、复制目标、编辑和确认后删除。 -- JSON 写入使用文件锁、原子替换和自动的上一个可用版本备份。 +- JSON 写入锚定目录描述符,并使用目录锁、原子替换和上一个可用版本备份。 +- 后端响应在进入 QML 前受字节上限和真实进程截止时间保护。 - 支持经过校验的备份恢复,以及显式的私有权限修复命令。 - 编辑带空格或引号的命令参数时保持无损往返。 - 可选状态栏组件:左键打开全部资源,右键打开收藏。 @@ -73,7 +74,7 @@ QOpen 会在可用时使用 Omarchy 的启动 helper,并可通过以下命令 ~/.config/omarchy/plugins/qopen.launcher/bin/qopen --doctor ``` -2.5.0 发布版本在 Omarchy 4.0.1、Quickshell 0.3.1 和 Qt 6.11.2 上完成开发与验证。这些是已测试版本,并非严格版本锁定。 +2.5.1 发布版本在 Omarchy 4.0.1、Quickshell 0.3.1 和 Qt 6.11.2 上完成开发与验证。这些是已测试版本,并非严格版本锁定。 ## 安装 @@ -312,19 +313,27 @@ Project 模式只列出目录,并通过底部按钮选择当前目录。File - id 只能包含小写字母、数字、`_` 或 `-`,并且必须唯一; - 每次修改都会验证完整目录; -- 写入使用文件锁和同目录原子替换; +- 状态目录的每一级都以不跟随符号链接的方式打开; +- 配置和备份拒绝符号链接、硬链接及非普通文件; +- 写入锁定可信状态目录描述符,并使用同目录原子替换; - 上一个目录版本会保留为 `config.json.bak`; -- 新状态文件使用私有权限(`0600`),已有文件可显式审计和修复权限; -- QML 不会直接写入目录; +- 新建的默认状态目录使用 `0700`,新建或重写的状态文件使用 `0600`;已有状态 + 只有在组用户和其他用户不可写时才会被接受,而 `--doctor` 会报告更宽松的私有 + 权限,`fix-permissions` 可将默认状态目录和状态文件修复为 `0700`/`0600`; +- 目录读取、API 响应、helper 输出和目录扫描都有明确上限; +- QML 不会直接打开或写入目录,后端进程也具有真实截止时间; - 个人资源数据不会自动同步到这个 GitHub 仓库。 可以设置 `QOPEN_CONFIG`,让 CLI 使用另一份目录: ```bash -QOPEN_CONFIG=~/Documents/qopen-work.json \ +QOPEN_CONFIG=~/Documents/qopen-work/config.json \ ~/.config/omarchy/plugins/qopen.launcher/bin/qopen --list ``` +请使用由当前账户拥有、且组用户和其他用户不可写的专用目录。QOpen 会验证 +自定义状态目录,但绝不会修改该目录本身的权限。 + ## 命令行界面 后端会随插件一起安装: @@ -342,8 +351,7 @@ $QOPEN edit [id] # 引导式编辑 $QOPEN remove [id] # 确认后删除 $QOPEN favorite toggle # 切换收藏状态 $QOPEN recover # 校验并恢复 config.json.bak -$QOPEN fix-permissions # 将 QOpen 状态文件限制为 0600 -$QOPEN --edit # 使用配置的编辑器打开原始 JSON +$QOPEN fix-permissions # 保护默认状态目录和状态文件 $QOPEN --doctor # 验证依赖和全部条目 $QOPEN --version ``` @@ -374,11 +382,14 @@ Omarchy 菜单 / 快捷键 / 状态栏组件 ResourceEditor.qml PathPicker.qml | | +---------+----------+ - | 仅传 argv + | 有界 argv / 单行 JSON + v + BoundedProcess.qml + | v bin/qopen | - 配置锁 + 完整校验 + 描述符锚定校验 + 目录 FD 锁 | v ~/.config/qopen/config.json @@ -388,8 +399,20 @@ QML 负责展示、焦点和交互。Python 负责目录枚举、规范化、校 内嵌路径浏览器刻意避开 Qt `FileDialog`、GTK、GIO 和 GVFS。QOpen 运行在共享的 Omarchy Shell 进程中;不在该进程中接入原生文件对话框,可以避免选择器故障终止整个桌面 Shell。2.2 版本曾短暂使用原生对话框;2.3 在复现 Quickshell 崩溃后将其移除。证据和发布验证记录见 [DEVELOPMENT.md](DEVELOPMENT.md)。 +用户主动选择的资源浏览会有意跟随符号链接,使普通 project/file 工作流与用户所选 +文件系统路径保持一致。该浏览路径不会用于 QOpen 自身持久化:配置、备份、锁和替换 +始终由经过独立校验的 `SecureStateStore` 信任边界负责。 + 命令始终以参数数组传递。QML 不会把资源值拼接成 Shell 命令。 +QML 不使用 `FileView` 读取目录,也不通过 `StdioCollector` 保留完整进程流。 +Python 生产端会在写出前校验并限制每个 API 响应;QML 随后只执行第二层协议大小检查。 +有界 helper 运行在各自独立的进程组中,并通过 TERM 到 KILL 的升级机制落实真实截止 +时间。配置、备份和恢复在完整生命周期内始终锚定到同一个可信目录描述符。 + +`$QOPEN --edit` 原始编辑入口已主动禁用,因为外部编辑器无法参与 QOpen 的锁、 +校验、备份和原子替换协议。请改用原生编辑界面或 `$QOPEN edit [id]`。 + ## 更新和卸载 更新通过 Git 安装的插件: @@ -471,4 +494,4 @@ python -m unittest discover -s tests -v ## 许可证 -[MIT](LICENSE) © 2026 CoderLambert +[MIT](LICENSE) © 2026 CoderLambert \ No newline at end of file diff --git a/ResourceEditor.qml b/ResourceEditor.qml index bfbfa3d..cb3a320 100644 --- a/ResourceEditor.qml +++ b/ResourceEditor.qml @@ -37,6 +37,8 @@ Item { property string targetLevel: "" property string expandedTarget: "" property string clipboardOutput: "" + property int clipboardRequestSerial: 0 + property int targetRequestSerial: 0 readonly property color foreground: Color.menu.text readonly property color background: Color.menu.background @@ -70,6 +72,22 @@ Item { signal cancelRequested() signal submitRequested(string action, string payload, string originalPayload) + component FormTextField: TextField { + foreground: root.foreground + accent: root.accent + background: Rectangle { + color: Style.controlFill( + parent.activeFocus, + parent.hovered || parent.hasCursor, + root.foreground, + root.accent + ) + radius: root.radius + border.width: 1 + border.color: parent.activeFocus ? root.accent : root.borderColor + } + } + function slugify(value) { var slug = String(value || "").toLowerCase() .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") @@ -269,19 +287,30 @@ Item { function checkTarget() { var value = root.primaryValue().trim() if (!value || root.backendPath === "" || targetProcess.running) return + if (value.length > 8192) { + root.targetStatus = "Value exceeds the safety limit" + root.targetLevel = "error" + return + } root.targetStatus = "Checking…" root.targetLevel = "" - targetProcess.command = [root.backendPath, "api", "check-target", "--type", root.selectedType, "--value", value] - targetProcess.running = true + root.targetRequestSerial++ + targetProcess.start( + [root.backendPath, "api", "check-target", "--type", root.selectedType, "--value", value], + root.targetRequestSerial + ) } function useClipboard() { - if (clipboardProcess.running) return + if (clipboardProcess.running || !root.backendPath) return root.clipboardOutput = "" root.targetStatus = "Reading clipboard…" root.targetLevel = "" - clipboardProcess.command = ["wl-paste", "--type", "text", "--no-newline"] - clipboardProcess.running = true + root.clipboardRequestSerial++ + clipboardProcess.start( + [root.backendPath, "api", "clipboard-read"], + root.clipboardRequestSerial + ) } function expandedLocalPath(value) { @@ -340,17 +369,23 @@ Item { pathPicker.openPicker(root.selectedType, root.initialPickerFolder()) } - Process { + BoundedProcess { id: clipboardProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - root.clipboardOutput = String(text || "").trim().split("\n")[0] - } - } - onExited: function(exitCode) { - if (exitCode !== 0 || !root.clipboardOutput) { - root.targetStatus = "Clipboard is unavailable or does not contain text" + timeoutMs: 1500 + responseLimit: 16384 + onFinished: function(raw, exitCode, requestId, error) { + if (requestId !== root.clipboardRequestSerial) return + try { + var response = error ? ({ ok: false, error: error }) : JSON.parse(String(raw || "{}")) + var value = response.ok && response.result ? String(response.result.text || "") : "" + if (!value) { + root.targetStatus = String(response.error || "Clipboard is unavailable or does not contain text") + root.targetLevel = "error" + return + } + root.clipboardOutput = value + } catch (e) { + root.targetStatus = "Clipboard returned an invalid response" root.targetLevel = "error" return } @@ -361,27 +396,27 @@ Item { } } - Process { + BoundedProcess { id: targetProcess - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: { - try { - var response = JSON.parse(String(text || "{}")) - if (!response.ok) { - root.targetStatus = String(response.error || "Could not validate this value") - root.targetLevel = "error" - root.expandedTarget = "" - return - } - var result = response.result || ({}) - root.targetStatus = String(result.message || "") - root.targetLevel = String(result.level || "") - root.expandedTarget = String(result.expanded || "") - } catch (e) { - root.targetStatus = "Could not validate this value" + timeoutMs: 1500 + responseLimit: 32768 + onFinished: function(raw, exitCode, requestId, error) { + if (requestId !== root.targetRequestSerial) return + try { + var response = error ? ({ ok: false, error: error }) : JSON.parse(String(raw || "{}")) + if (!response.ok) { + root.targetStatus = String(response.error || "Could not validate this value") root.targetLevel = "error" + root.expandedTarget = "" + return } + var result = response.result || ({}) + root.targetStatus = String(result.message || "") + root.targetLevel = String(result.level || "") + root.expandedTarget = String(result.expanded || "") + } catch (e) { + root.targetStatus = "Could not validate this value" + root.targetLevel = "error" } } } @@ -464,7 +499,8 @@ Item { Column { id: formColumn - width: formFlick.width + x: Style.space(2) + width: formFlick.width - Style.space(4) topPadding: Style.space(16) bottomPadding: Style.space(16) spacing: Style.space(14) @@ -521,12 +557,11 @@ Item { width: (parent.width - parent.spacing) * 0.58 spacing: Style.space(6) Text { text: "Name *"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { + FormTextField { id: nameField width: parent.width text: root.nameValue placeholderText: "Resource name" - foreground: root.foreground onTextChanged: { root.nameValue = text if (!root.idTouched) root.idValue = root.slugify(text) @@ -539,11 +574,10 @@ Item { width: parent.width - parent.spacing - (parent.width - parent.spacing) * 0.58 spacing: Style.space(6) Text { text: "Group *"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { + FormTextField { width: parent.width text: root.groupValue placeholderText: root.defaultGroups[root.selectedType] - foreground: root.foreground onTextChanged: { root.groupValue = text; root.markDirty() } } } @@ -576,14 +610,13 @@ Item { Row { width: parent.width spacing: Style.space(8) - TextField { + FormTextField { id: primaryField width: parent.width - pasteButton.width - checkButton.width - (browseButton.visible ? browseButton.width + parent.spacing : 0) - parent.spacing * 2 text: root.primaryValue() placeholderText: root.primaryPlaceholder() - foreground: root.foreground onTextChanged: { root.setPrimaryValue(text) root.targetStatus = "" @@ -638,11 +671,10 @@ Item { width: parent.width spacing: Style.space(6) Text { text: "Description"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { + FormTextField { width: parent.width text: root.descriptionValue placeholderText: "Why this resource is useful" - foreground: root.foreground onTextChanged: { root.descriptionValue = text; root.markDirty() } } } @@ -720,8 +752,8 @@ Item { width: (parent.width - parent.spacing) * 0.65 spacing: Style.space(6) Text { text: "Stable ID"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { - width: parent.width; text: root.idValue; placeholderText: root.slugify(root.nameValue); foreground: root.foreground + FormTextField { + width: parent.width; text: root.idValue; placeholderText: root.slugify(root.nameValue) onTextEdited: root.idTouched = true onTextChanged: { root.idValue = text; root.markDirty() } } @@ -730,7 +762,7 @@ Item { width: parent.width - parent.spacing - (parent.width - parent.spacing) * 0.65 spacing: Style.space(6) Text { text: "Icon"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { width: parent.width; text: root.iconValue; placeholderText: root.defaultIcons[root.selectedType]; foreground: root.foreground; onTextChanged: { root.iconValue = text; root.markDirty() } } + FormTextField { width: parent.width; text: root.iconValue; placeholderText: root.defaultIcons[root.selectedType]; onTextChanged: { root.iconValue = text; root.markDirty() } } } } @@ -738,14 +770,14 @@ Item { visible: root.selectedType === "web" width: parent.width; spacing: Style.space(6) Text { text: "Window focus pattern"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { width: parent.width; text: root.focusValue; placeholderText: "Optional app window match"; foreground: root.foreground; onTextChanged: { root.focusValue = text; root.markDirty() } } + FormTextField { width: parent.width; text: root.focusValue; placeholderText: "Optional app window match"; onTextChanged: { root.focusValue = text; root.markDirty() } } } Column { visible: root.selectedType === "file" width: parent.width; spacing: Style.space(6) Text { text: "Editor command"; color: root.foreground; font.family: root.fontFamily; font.pixelSize: Style.font.caption } - TextField { width: parent.width; text: root.editorValue; placeholderText: "Uses the default editor when empty"; foreground: root.foreground; onTextChanged: { root.editorValue = text; root.markDirty() } } + FormTextField { width: parent.width; text: root.editorValue; placeholderText: "Uses the default editor when empty"; onTextChanged: { root.editorValue = text; root.markDirty() } } } } diff --git a/bin/qopen b/bin/qopen index ce8ee7e..743aca0 100755 --- a/bin/qopen +++ b/bin/qopen @@ -5,17 +5,20 @@ from __future__ import annotations import argparse import copy +import errno import fcntl import ipaddress import json import os import re +import secrets +import selectors import shlex import shutil +import signal import stat import subprocess import sys -import tempfile import time import unicodedata import urllib.parse @@ -24,11 +27,21 @@ from pathlib import Path from typing import Any, Callable -VERSION = "2.5.0" -CONFIG_PATH = Path(os.path.expandvars(os.environ.get("QOPEN_CONFIG", "~/.config/qopen/config.json"))).expanduser() -LOCK_PATH = CONFIG_PATH.with_name(CONFIG_PATH.name + ".lock") +VERSION = "2.5.1" +DEFAULT_CONFIG_PATH = Path("~/.config/qopen/config.json").expanduser() +CONFIG_PATH = Path(os.path.expandvars(os.environ.get( + "QOPEN_CONFIG", str(DEFAULT_CONFIG_PATH) +))).expanduser() BACKUP_PATH = CONFIG_PATH.with_name(CONFIG_PATH.name + ".bak") +MAX_CONFIG_BYTES = 1024 * 1024 +MAX_API_INPUT_BYTES = 32 * 1024 +MAX_API_OUTPUT_BYTES = 1280 * 1024 +MAX_ERROR_CHARS = 4096 +MAX_ITEMS = 2000 +STATE_LOCK_TIMEOUT_SECONDS = 2.0 +STATE_LOCK_RETRY_SECONDS = 0.02 + SUPPORTED_TYPES = {"web", "file", "project", "tui", "command", "ssh"} RESERVED_IDS = {"add", "edit", "remove"} ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") @@ -111,6 +124,8 @@ MENU_WIDTH = 720 MENU_MAX_HEIGHT = 640 MENU_SETTLE_SECONDS = 0.08 PATH_BROWSE_LIMIT = 1000 +PATH_BROWSE_SCAN_LIMIT = 5000 +PATH_BROWSE_TIMEOUT_SECONDS = 1.5 GROUP_META = { "projects": ("󰉋", "Projects", "Directories opened in a terminal"), "config": ("󰒓", "Configuration", "Frequently edited configuration files"), @@ -138,6 +153,10 @@ class QOpenError(Exception): pass +class StateFileMissing(QOpenError): + pass + + def fail(message: str) -> None: raise QOpenError(message if message.startswith("QOpen:") else f"QOpen: {message}") @@ -199,7 +218,9 @@ def normalize_url(url: str) -> str: def validate_http_url(url: str, field: str = "URL") -> str: - normalized = normalize_url(url) + raw = require_safe_text(url, field).strip() + has_explicit_scheme = bool(re.match(r"^https?://", raw, re.IGNORECASE)) + normalized = normalize_url(raw) if any(character.isspace() for character in normalized): fail(f"{field} must not contain whitespace") try: @@ -214,8 +235,10 @@ def validate_http_url(url: str, field: str = "URL") -> str: if port is not None and not 1 <= port <= 65535: fail(f"{field} contains an invalid port") hostname = parsed.hostname.rstrip(".") + is_ip_address = False try: ipaddress.ip_address(hostname) + is_ip_address = True except ValueError: try: ascii_hostname = hostname.encode("idna").decode("ascii") @@ -228,6 +251,10 @@ def validate_http_url(url: str, field: str = "URL") -> str: or not re.fullmatch(r"[A-Za-z0-9-]+", label) for label in labels)): fail(f"{field} contains an invalid host") + if (not has_explicit_scheme and not is_ip_address + and hostname.lower() != "localhost" and "." not in hostname): + fail(f"{field} must be a domain, IP address, localhost, " + "or include http:// / https://") return normalized @@ -308,19 +335,130 @@ def spawn_detached(argv: list[str], cwd: Path | None = None) -> None: fail(f"failed to launch {argv[0]}: {exc}") +def _signal_process_group(process: subprocess.Popen[bytes], signal_number: int) -> None: + try: + os.killpg(process.pid, signal_number) + except ProcessLookupError: + pass + + +def _stop_process(process: subprocess.Popen[bytes]) -> None: + _signal_process_group(process, signal.SIGTERM) + try: + process.wait(timeout=0.25) + except subprocess.TimeoutExpired: + pass + _signal_process_group(process, signal.SIGKILL) + if process.poll() is None: + try: + process.wait(timeout=0.25) + except subprocess.TimeoutExpired: + pass + + +def run_bounded(command: list[str], *, input_text: str | None = None, + timeout: float, stdout_limit: int, stderr_limit: int = 16 * 1024 + ) -> subprocess.CompletedProcess[str]: + """Run a helper without retaining unbounded output in the QOpen process.""" + input_data = input_text.encode("utf-8") if input_text is not None else None + try: + process = subprocess.Popen( + command, + stdin=subprocess.PIPE if input_data is not None else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + except OSError as exc: + fail(f"failed to start {command[0]}: {exc}") + + assert process.stdout is not None and process.stderr is not None + selector = selectors.DefaultSelector() + output = {"stdout": bytearray(), "stderr": bytearray()} + input_offset = 0 + deadline = time.monotonic() + timeout + streams: list[Any] = [process.stdout, process.stderr] + try: + os.set_blocking(process.stdout.fileno(), False) + os.set_blocking(process.stderr.fileno(), False) + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + if process.stdin is not None: + streams.append(process.stdin) + os.set_blocking(process.stdin.fileno(), False) + selector.register(process.stdin, selectors.EVENT_WRITE, "stdin") + + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + _stop_process(process) + raise subprocess.TimeoutExpired(command, timeout) + for key, _events in selector.select(min(0.05, remaining)): + stream = key.fileobj + channel = key.data + if channel == "stdin": + assert process.stdin is not None and input_data is not None + try: + count = os.write(process.stdin.fileno(), input_data[input_offset:input_offset + 65536]) + except BrokenPipeError: + count = 0 + input_offset += count + if count == 0 or input_offset >= len(input_data): + selector.unregister(stream) + process.stdin.close() + continue + + try: + chunk = os.read(stream.fileno(), 65536) + except BlockingIOError: + continue + if not chunk: + selector.unregister(stream) + stream.close() + continue + limit = stdout_limit if channel == "stdout" else stderr_limit + output[channel].extend(chunk) + if len(output[channel]) > limit: + _stop_process(process) + fail(f"{command[0]} exceeded its {channel} safety limit") + + remaining = max(0.01, deadline - time.monotonic()) + try: + return_code = process.wait(timeout=remaining) + except subprocess.TimeoutExpired: + _stop_process(process) + raise subprocess.TimeoutExpired(command, timeout) + finally: + selector.close() + for stream in streams: + if not stream.closed: + stream.close() + + return subprocess.CompletedProcess( + command, + return_code, + output["stdout"].decode("utf-8", errors="replace"), + output["stderr"].decode("utf-8", errors="replace"), + ) + + # Omarchy compatibility layer def ui_select(prompt: str, options: list[str], width: int = MENU_WIDTH, max_height: int = MENU_MAX_HEIGHT) -> str | None: if not options: return None require_command("omarchy-menu-select") + command = ["omarchy-menu-select", prompt, "--", "--width", str(width), + "--maxheight", str(max_height)] try: - command = ["omarchy-menu-select", prompt, "--", "--width", str(width), - "--maxheight", str(max_height)] - result = subprocess.run(command, input="\n".join(options) + "\n", - text=True, capture_output=True, check=False) - except OSError as exc: - fail(f"failed to start picker: {exc}") + result = run_bounded( + command, + input_text="\n".join(options) + "\n", + timeout=3600, + stdout_limit=64 * 1024, + ) + except subprocess.TimeoutExpired: + fail("picker timed out") time.sleep(MENU_SETTLE_SECONDS) if result.returncode != 0: return None @@ -331,10 +469,13 @@ def ui_select(prompt: str, options: list[str], width: int = MENU_WIDTH, def ui_input(prompt: str) -> str | None: require_command("omarchy-menu-input") try: - result = subprocess.run(["omarchy-menu-input", prompt], text=True, - capture_output=True, check=False) - except OSError as exc: - fail(f"failed to start input dialog: {exc}") + result = run_bounded( + ["omarchy-menu-input", prompt], + timeout=3600, + stdout_limit=16 * 1024, + ) + except subprocess.TimeoutExpired: + fail("input dialog timed out") time.sleep(MENU_SETTLE_SECONDS) if result.returncode != 0: return None @@ -346,9 +487,12 @@ def read_clipboard_text() -> str | None: if not has_command("wl-paste"): return None try: - result = subprocess.run(["wl-paste", "--type", "text", "--no-newline"], - text=True, capture_output=True, check=False, timeout=1) - except (OSError, subprocess.TimeoutExpired): + result = run_bounded( + ["wl-paste", "--type", "text", "--no-newline"], + timeout=1, + stdout_limit=16 * 1024, + ) + except subprocess.TimeoutExpired: return None if result.returncode != 0: return None @@ -458,6 +602,8 @@ def validate_item(item: Any, defaults: dict[str, Any], seen_ids: set[str], prefi if not isinstance(item_id, str) or not item_id: fail(f"{prefix}.id must be a non-empty string") require_safe_text(item_id, f"{prefix}.id") + if len(item_id) > 128: + fail(f"{prefix}.id exceeds 128 characters") if not ID_PATTERN.fullmatch(item_id): fail(f"invalid id '{item_id}'. Use lowercase letters, numbers, _ or -") if item_id in RESERVED_IDS: @@ -469,6 +615,8 @@ def validate_item(item: Any, defaults: dict[str, Any], seen_ids: set[str], prefi if not isinstance(item.get("name"), str) or not item["name"].strip(): fail(f"{prefix}.name must be a non-empty string") require_safe_text(item["name"], f"{prefix}.name") + if len(item["name"]) > 256: + fail(f"{prefix}.name exceeds 256 characters") item_type = item.get("type") if not isinstance(item_type, str) or item_type not in SUPPORTED_TYPES: fail(f"unsupported type '{item_type}' for {item_id}") @@ -477,6 +625,12 @@ def validate_item(item: Any, defaults: dict[str, Any], seen_ids: set[str], prefi fail(f"{prefix}.{field} must be a string") if field in item: require_safe_text(item[field], f"{prefix}.{field}") + if len(str(item.get("group", ""))) > 128: + fail(f"{prefix}.group exceeds 128 characters") + if len(str(item.get("icon", ""))) > 32: + fail(f"{prefix}.icon exceeds 32 characters") + if len(str(item.get("description", ""))) > 4096: + fail(f"{prefix}.description exceeds 4096 characters") if "group" in item and not item["group"].strip(): fail(f"{prefix}.group must be a non-empty string") if "favorite" in item and not isinstance(item["favorite"], bool): @@ -486,6 +640,8 @@ def validate_item(item: Any, defaults: dict[str, Any], seen_ids: set[str], prefi if not isinstance(item.get("target"), str) or not item["target"].strip(): fail(f"{prefix}.target must be a non-empty string") require_safe_text(item["target"], f"{prefix}.target") + if len(item["target"]) > 8192: + fail(f"{prefix}.target exceeds 8192 characters") if item_type == "web": mode = item.get("mode", defaults.get("webMode", "app")) if not isinstance(mode, str) or mode not in {"app", "browser"}: @@ -504,6 +660,8 @@ def validate_item(item: Any, defaults: dict[str, Any], seen_ids: set[str], prefi if not isinstance(command, list) or not command or any(not isinstance(arg, str) or not arg for arg in command): fail(f"{prefix}.command must be a non-empty string array") command_argv(command, f"{prefix}.command") + if sum(len(argument) for argument in command) > 8192: + fail(f"{prefix}.command exceeds 8192 characters") if item_type == "command" and "terminal" in item and not isinstance(item["terminal"], bool): fail(f"{prefix}.terminal must be boolean") if item_type == "ssh": @@ -526,154 +684,343 @@ def validate_config(config: Any) -> dict[str, Any]: items = config.get("items") if not isinstance(items, list): fail("items must be an array") + if len(items) > MAX_ITEMS: + fail(f"items exceeds the {MAX_ITEMS}-resource safety limit") seen_ids: set[str] = set() for index, item in enumerate(items): validate_item(item, defaults, seen_ids, f"items[{index}]") return config -def read_config_unlocked(create_if_missing: bool = False) -> dict[str, Any]: - if not CONFIG_PATH.exists(): - if create_if_missing: - return default_config() - fail(f"config not found: {CONFIG_PATH}\nRun: qopen add") - try: - with CONFIG_PATH.open("r", encoding="utf-8") as file: - config = json.load(file) - except json.JSONDecodeError as exc: - fail(f"invalid config.json (line {exc.lineno}, column {exc.colno}): {exc.msg}") - except OSError as exc: - fail(f"cannot read config: {exc}") - return validate_config(config) - - -@contextmanager -def config_lock(): - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - try: - descriptor = os.open(LOCK_PATH, os.O_RDWR | os.O_CREAT, 0o600) - with os.fdopen(descriptor, "a+", encoding="utf-8") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - except OSError as exc: - fail(f"cannot lock config: {exc}") - +class SecureStateStore: + """Descriptor-anchored access to QOpen's complete state lifecycle.""" + + def __init__(self, config_path: Path = CONFIG_PATH, + allow_insecure_directory: bool = False) -> None: + self.config_path = config_path + self.config_name = config_path.name + self.backup_name = f"{self.config_name}.bak" + self.allow_insecure_directory = allow_insecure_directory + self.dir_fd = -1 + self.directory_path = config_path.parent + + def __enter__(self) -> "SecureStateStore": + if not self.config_path.is_absolute(): + fail("QOPEN_CONFIG must expand to an absolute path") + if self.config_name in {"", ".", ".."} or "/" in self.config_name: + fail("QOPEN_CONFIG has an invalid filename") + required_flags = ("O_CLOEXEC", "O_DIRECTORY", "O_NOFOLLOW", "O_NONBLOCK") + if any(not hasattr(os, name) for name in required_flags): + fail("secure state-file operations are not supported on this platform") + + parent = self.config_path.parent + root_fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) + current_fd = root_fd + try: + for component in parent.parts[1:]: + if component in {"", ".", ".."}: + fail("QOPEN_CONFIG contains an unsafe directory component") + try: + child_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=current_fd, + ) + except FileNotFoundError: + try: + os.mkdir(component, 0o700, dir_fd=current_fd) + os.fsync(current_fd) + child_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=current_fd, + ) + except OSError as exc: + fail(f"cannot create secure state directory {parent}: {exc}") + except OSError as exc: + fail(f"cannot open secure state directory {parent}: {exc}") + if current_fd != root_fd: + os.close(current_fd) + current_fd = child_fd + + metadata = os.fstat(current_fd) + if not stat.S_ISDIR(metadata.st_mode): + fail(f"state path is not a directory: {parent}") + if metadata.st_uid != os.geteuid(): + fail(f"state directory is not owned by the current user: {parent}") + mode = stat.S_IMODE(metadata.st_mode) + if not self.allow_insecure_directory and mode & 0o022: + fail(f"state directory is writable by another account: {parent} ({mode:04o})") + self.dir_fd = current_fd + self.directory_path = parent + if current_fd == root_fd: + root_fd = -1 + current_fd = -1 + return self + finally: + if current_fd >= 0: + os.close(current_fd) + if root_fd >= 0 and root_fd != current_fd: + try: + os.close(root_fd) + except OSError: + pass + + def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: + if self.dir_fd >= 0: + os.close(self.dir_fd) + self.dir_fd = -1 + + def path_for(self, name: str) -> Path: + return self.directory_path / name + + def _validate_regular(self, fd: int, name: str, label: str, + allow_insecure_mode: bool = False) -> os.stat_result: + metadata = os.fstat(fd) + if not stat.S_ISREG(metadata.st_mode): + fail(f"{label} is not a regular file: {self.path_for(name)}") + if metadata.st_uid != os.geteuid(): + fail(f"{label} is not owned by the current user: {self.path_for(name)}") + if metadata.st_nlink != 1: + fail(f"{label} must not have hard links: {self.path_for(name)}") + mode = stat.S_IMODE(metadata.st_mode) + if not allow_insecure_mode and mode & 0o022: + fail(f"{label} is writable by another account: {self.path_for(name)} ({mode:04o})") + return metadata + + def open_regular(self, name: str, label: str, writable: bool = False, + allow_insecure_mode: bool = False) -> int: + flags = (os.O_RDWR if writable else os.O_RDONLY) | os.O_CLOEXEC + flags |= os.O_NOFOLLOW | os.O_NONBLOCK + try: + fd = os.open(name, flags, dir_fd=self.dir_fd) + except FileNotFoundError: + raise + except OSError as exc: + fail(f"cannot open {label}: {exc}") + try: + self._validate_regular(fd, name, label, allow_insecure_mode) + return fd + except BaseException: + os.close(fd) + raise + + def read_bytes(self, name: str, label: str, + limit: int = MAX_CONFIG_BYTES) -> bytes: + fd = self.open_regular(name, label) + try: + before = self._validate_regular(fd, name, label) + if before.st_size > limit: + fail(f"{label} exceeds the {limit}-byte safety limit") + chunks: list[bytes] = [] + total = 0 + while total <= limit: + chunk = os.read(fd, min(65536, limit + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > limit: + fail(f"{label} exceeds the {limit}-byte safety limit") + after = self._validate_regular(fd, name, label) + if (before.st_dev, before.st_ino, before.st_size, + before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_dev, after.st_ino, after.st_size, + after.st_mtime_ns, after.st_ctime_ns): + fail(f"{label} changed while it was being read; retry") + return b"".join(chunks) + except OSError as exc: + fail(f"cannot read {label}: {exc}") + finally: + os.close(fd) -def atomic_write_locked(config: dict[str, Any], backup: bool = True) -> None: - validate_config(config) - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - original_mode = 0o600 - if CONFIG_PATH.exists(): - original_mode = stat.S_IMODE(CONFIG_PATH.stat().st_mode) - if backup: + def read_config(self, name: str | None = None, + label: str = "config") -> tuple[dict[str, Any], bytes]: + target = name or self.config_name + try: + raw = self.read_bytes(target, label) + except FileNotFoundError: + raise StateFileMissing(f"QOpen: {label} not found: {self.path_for(target)}") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + fail(f"invalid UTF-8 in {label} at byte {exc.start}") + try: + config = json.loads(text) + except json.JSONDecodeError as exc: + fail(f"invalid {label} (line {exc.lineno}, column {exc.colno}): {exc.msg}") + except RecursionError: + fail(f"invalid {label}: JSON nesting is too deep") + return validate_config(config), raw + + @contextmanager + def lock(self, exclusive: bool = True): + operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH + deadline = time.monotonic() + STATE_LOCK_TIMEOUT_SECONDS + while True: try: - shutil.copy2(CONFIG_PATH, BACKUP_PATH) - if stat.S_IMODE(CONFIG_PATH.stat().st_mode) == 0o600: - os.chmod(BACKUP_PATH, 0o600) + fcntl.flock(self.dir_fd, operation | fcntl.LOCK_NB) + break except OSError as exc: - fail(f"failed to create backup: {exc}") - - fd, temp_name = tempfile.mkstemp(prefix=".qopen-config-", suffix=".tmp", dir=CONFIG_PATH.parent) - temp_path = Path(temp_name) - try: - os.chmod(temp_path, original_mode) - with os.fdopen(fd, "w", encoding="utf-8") as file: - json.dump(config, file, ensure_ascii=False, indent=2) - file.write("\n") - file.flush() - os.fsync(file.fileno()) - os.replace(temp_path, CONFIG_PATH) - directory_fd = os.open(CONFIG_PATH.parent, os.O_DIRECTORY) + if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK}: + fail(f"cannot lock QOpen state directory: {exc}") + if time.monotonic() >= deadline: + fail("timed out waiting for the QOpen state lock") + time.sleep(STATE_LOCK_RETRY_SECONDS) try: - os.fsync(directory_fd) + yield finally: - os.close(directory_fd) - except OSError as exc: - fail(f"failed to write config: {exc}") - finally: - if temp_path.exists(): - temp_path.unlink(missing_ok=True) + fcntl.flock(self.dir_fd, fcntl.LOCK_UN) - -def read_config_path(path: Path, label: str) -> dict[str, Any]: - try: - with path.open("r", encoding="utf-8") as file: - config = json.load(file) - except FileNotFoundError: - fail(f"{label} not found: {path}") - except json.JSONDecodeError as exc: - fail(f"invalid {label} (line {exc.lineno}, column {exc.colno}): {exc.msg}") - except OSError as exc: - fail(f"cannot read {label}: {exc}") - return validate_config(config) + def _validate_destination(self, name: str, label: str) -> None: + try: + fd = self.open_regular(name, label) + except FileNotFoundError: + return + else: + os.close(fd) + + def write_atomic(self, name: str, data: bytes, label: str) -> None: + if len(data) > MAX_CONFIG_BYTES: + fail(f"{label} exceeds the {MAX_CONFIG_BYTES}-byte safety limit") + self._validate_destination(name, label) + temp_name = f".qopen-{secrets.token_hex(12)}.tmp" + temp_fd = -1 + try: + temp_fd = os.open( + temp_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, + dir_fd=self.dir_fd, + ) + written = 0 + while written < len(data): + count = os.write(temp_fd, data[written:]) + if count <= 0: + fail(f"failed to write {label}") + written += count + os.fchmod(temp_fd, 0o600) + os.fsync(temp_fd) + os.close(temp_fd) + temp_fd = -1 + os.replace( + temp_name, + name, + src_dir_fd=self.dir_fd, + dst_dir_fd=self.dir_fd, + ) + os.fsync(self.dir_fd) + except OSError as exc: + fail(f"failed to write {label}: {exc}") + finally: + if temp_fd >= 0: + os.close(temp_fd) + try: + os.unlink(temp_name, dir_fd=self.dir_fd) + except FileNotFoundError: + pass + except OSError: + pass + + def write_config(self, config: dict[str, Any], previous: bytes | None, + backup: bool = True) -> None: + validate_config(config) + serialized = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + if len(serialized) > MAX_CONFIG_BYTES: + fail(f"config exceeds the {MAX_CONFIG_BYTES}-byte safety limit") + if backup and previous is not None: + self.write_atomic(self.backup_name, previous, "backup") + self.write_atomic(self.config_name, serialized, "config") + + def unique_snapshot_name(self) -> str: + timestamp = time.strftime("%Y%m%d-%H%M%S") + for counter in range(1, 1001): + suffix = "" if counter == 1 else f"-{counter}" + candidate = f"{self.config_name}.invalid-{timestamp}{suffix}" + try: + os.stat(candidate, dir_fd=self.dir_fd, follow_symlinks=False) + except FileNotFoundError: + return candidate + except OSError as exc: + fail(f"cannot inspect recovery snapshot: {exc}") + fail("too many recovery snapshots share the same timestamp") -def recovery_snapshot_path() -> Path: - timestamp = time.strftime("%Y%m%d-%H%M%S") - candidate = CONFIG_PATH.with_name(f"{CONFIG_PATH.name}.invalid-{timestamp}") - counter = 2 - while candidate.exists(): - candidate = CONFIG_PATH.with_name(f"{CONFIG_PATH.name}.invalid-{timestamp}-{counter}") - counter += 1 - return candidate +def read_config_unlocked(create_if_missing: bool = False) -> dict[str, Any]: + with SecureStateStore() as store, store.lock(exclusive=False): + try: + config, _raw = store.read_config() + return config + except StateFileMissing: + if create_if_missing: + return default_config() + raise def recover_backup() -> Path | None: """Validate and atomically restore the last-known-good catalog.""" - with config_lock(): - backup_config = read_config_path(BACKUP_PATH, "backup") + with SecureStateStore() as store, store.lock(): + backup_config, backup_raw = store.read_config(store.backup_name, "backup") snapshot: Path | None = None - if CONFIG_PATH.exists(): - snapshot = recovery_snapshot_path() - try: - shutil.copy2(CONFIG_PATH, snapshot) - os.chmod(snapshot, 0o600) - except OSError as exc: - fail(f"failed to preserve the current catalog: {exc}") - atomic_write_locked(backup_config, backup=False) try: - os.chmod(CONFIG_PATH, 0o600) - os.chmod(BACKUP_PATH, 0o600) - os.chmod(LOCK_PATH, 0o600) - except OSError as exc: - fail(f"catalog restored but permissions could not be secured: {exc}") + current_raw = store.read_bytes(store.config_name, "config") + except FileNotFoundError: + current_raw = None + if current_raw is not None: + snapshot_name = store.unique_snapshot_name() + store.write_atomic(snapshot_name, current_raw, "recovery snapshot") + snapshot = store.path_for(snapshot_name) + validate_config(backup_config) + store.write_atomic(store.config_name, backup_raw, "config") return snapshot def fix_permissions() -> list[Path]: - """Restrict QOpen state files without changing their contents.""" + """Restrict QOpen state files without following pathnames.""" changed: list[Path] = [] - with config_lock(): - for path in (CONFIG_PATH, BACKUP_PATH, LOCK_PATH): - if not path.exists(): + with SecureStateStore(allow_insecure_directory=True) as store, store.lock(): + directory_mode = stat.S_IMODE(os.fstat(store.dir_fd).st_mode) + if store.config_path == DEFAULT_CONFIG_PATH and directory_mode != 0o700: + os.fchmod(store.dir_fd, 0o700) + changed.append(store.directory_path) + elif directory_mode & 0o022: + fail("custom QOPEN_CONFIG directory is writable by another account; " + "move the catalog into a private directory") + for name, label in ((store.config_name, "config"), (store.backup_name, "backup")): + try: + fd = store.open_regular(name, label, allow_insecure_mode=True) + except FileNotFoundError: continue try: - if stat.S_IMODE(path.stat().st_mode) != 0o600: - os.chmod(path, 0o600) - changed.append(path) - except OSError as exc: - fail(f"cannot secure {path}: {exc}") + if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600: + os.fchmod(fd, 0o600) + changed.append(store.path_for(name)) + finally: + os.close(fd) return changed -def ensure_config() -> None: - if CONFIG_PATH.exists(): - return - with config_lock(): - if not CONFIG_PATH.exists(): - atomic_write_locked(default_config(), backup=False) +def ensure_config() -> dict[str, Any]: + with SecureStateStore() as store, store.lock(): + try: + config, _raw = store.read_config() + except StateFileMissing: + config = default_config() + store.write_config(config, previous=None, backup=False) + return config def mutate_config(mutator: Callable[[dict[str, Any]], Any]) -> Any: - with config_lock(): - config = read_config_unlocked(create_if_missing=True) + with SecureStateStore() as store, store.lock(): + try: + config, previous = store.read_config() + except StateFileMissing: + config = default_config() + previous = None working = copy.deepcopy(config) result = mutator(working) validate_config(working) - atomic_write_locked(working, backup=CONFIG_PATH.exists()) + store.write_config(working, previous=previous, backup=previous is not None) return result @@ -1185,10 +1532,29 @@ def noninteractive_add(argv: list[str]) -> int: # Machine API used by the native Omarchy plugin editor. It deliberately keeps # all validation and persistence in Python while QML owns only presentation. def api_emit(ok: bool, **payload: Any) -> None: - print(json.dumps({"ok": ok, **payload}, ensure_ascii=False, separators=(",", ":"))) + body = {"ok": ok, **payload} + if "error" in body: + error = "".join( + character if ord(character) >= 32 and ord(character) != 127 else " " + for character in str(body["error"]) + ) + body["error"] = error[:MAX_ERROR_CHARS] + try: + encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + except (TypeError, ValueError, RecursionError): + encoded = b'{"ok":false,"error":"QOpen could not serialize the API response"}' + if len(encoded) > MAX_API_OUTPUT_BYTES: + encoded = b'{"ok":false,"error":"QOpen API response exceeded its safety limit"}' + try: + sys.stdout.buffer.write(encoded + b"\n") + sys.stdout.buffer.flush() + except BrokenPipeError: + return def parse_api_payload(value: str, field: str) -> dict[str, Any]: + if len(value.encode("utf-8")) > MAX_API_INPUT_BYTES: + fail(f"{field} exceeds the {MAX_API_INPUT_BYTES}-byte safety limit") try: parsed = json.loads(value) except json.JSONDecodeError as exc: @@ -1305,6 +1671,8 @@ def validate_api_item(config: dict[str, Any], item: dict[str, Any], def api_check_target(item_type: str, value: str) -> dict[str, Any]: + if len(value.encode("utf-8")) > 8192: + fail("value exceeds the 8192-byte safety limit") try: require_safe_text(value, "value") except QOpenError as exc: @@ -1349,7 +1717,13 @@ def api_check_target(item_type: str, value: str) -> dict[str, Any]: def api_browse_path(path_value: str, item_type: str, show_hidden: bool = False, request_id: str = "") -> dict[str, Any]: """List one local directory without loading Qt, GTK, GIO or GVFS.""" + if len(path_value.encode("utf-8")) > 4096: + fail("directory path exceeds the 4096-byte safety limit") + if len(request_id) > 64: + fail("request id exceeds 64 characters") path = expand_path(path_value or str(Path.home())) + deadline = time.monotonic() + PATH_BROWSE_TIMEOUT_SECONDS + scan_truncated = False try: if path.is_file(): path = path.parent @@ -1360,8 +1734,13 @@ def api_browse_path(path_value: str, item_type: str, show_hidden: bool = False, path = path.absolute() entries: list[dict[str, Any]] = [] + scanned = 0 with os.scandir(path) as directory: for entry in directory: + scanned += 1 + if scanned > PATH_BROWSE_SCAN_LIMIT or time.monotonic() >= deadline: + scan_truncated = True + break if not show_hidden and entry.name.startswith("."): continue try: @@ -1380,13 +1759,16 @@ def api_browse_path(path_value: str, item_type: str, show_hidden: bool = False, "hidden": entry.name.startswith("."), "symlink": entry.is_symlink(), }) + if len(entries) > PATH_BROWSE_LIMIT: + scan_truncated = True + break except PermissionError: fail(f"permission denied: {path}") except OSError as exc: fail(f"cannot read directory {path}: {exc.strerror or exc}") entries.sort(key=lambda item: (item["kind"] != "directory", item["name"].casefold())) - truncated = len(entries) > PATH_BROWSE_LIMIT + truncated = scan_truncated or len(entries) > PATH_BROWSE_LIMIT if truncated: entries = entries[:PATH_BROWSE_LIMIT] parent = path.parent @@ -1416,6 +1798,7 @@ def api_cli(argv: list[str]) -> int: subparsers.add_parser("catalog") subparsers.add_parser("recover") subparsers.add_parser("fix-permissions") + subparsers.add_parser("clipboard-read") check_parser = subparsers.add_parser("check-target") check_parser.add_argument("--type", required=True, choices=sorted(SUPPORTED_TYPES)) check_parser.add_argument("--value", required=True) @@ -1427,6 +1810,15 @@ def api_cli(argv: list[str]) -> int: args = parser.parse_args(argv) try: + if args.action == "clipboard-read": + value = read_clipboard_text() + if not value: + fail("clipboard is unavailable or does not contain text") + first_line = value.strip().splitlines()[0] + if len(first_line.encode("utf-8")) > 8192: + fail("clipboard text exceeds the 8192-byte safety limit") + api_emit(True, result={"text": first_line}) + return 0 if args.action == "check-target": api_emit(True, result=api_check_target(args.type, args.value)) return 0 @@ -1435,8 +1827,7 @@ def api_cli(argv: list[str]) -> int: args.request_id)) return 0 if args.action == "catalog": - ensure_config() - api_emit(True, action="catalog", result=read_config_unlocked()) + api_emit(True, action="catalog", result=ensure_config()) return 0 if args.action == "recover": snapshot = recover_backup() @@ -1479,12 +1870,7 @@ def api_cli(argv: list[str]) -> int: def edit_config() -> int: - ensure_config() - config = read_config_unlocked() - editor = command_argv(config.get("defaults", {}).get("editor", os.environ.get("EDITOR", "nvim")), "editor") - require_command(editor[0]) - launch_tui([*editor, str(CONFIG_PATH)], "org.qopen.config") - return 0 + fail("direct catalog-file editing is disabled; use QOpen's editor or 'qopen edit '") def doctor() -> int: @@ -1496,41 +1882,73 @@ def doctor() -> int: if not ok: failed = True - def check_private(path: Path, required: bool = True) -> None: - if not path.exists(): - if required: - check(False, f"missing state file: {path}") - else: - print(f"~ optional state file absent: {path}") - return - mode = stat.S_IMODE(path.stat().st_mode) - check(mode == 0o600, f"private permissions {path} ({mode:04o}; expected 0600)") - - check(CONFIG_PATH.exists(), f"config: {CONFIG_PATH}") - if not CONFIG_PATH.exists(): - return 1 - check_private(CONFIG_PATH) try: - config = read_config_unlocked() - check(True, "config schema") + with SecureStateStore(allow_insecure_directory=True) as store: + directory_mode = stat.S_IMODE(os.fstat(store.dir_fd).st_mode) + expected_directory_mode = directory_mode == 0o700 \ + if store.config_path == DEFAULT_CONFIG_PATH else not (directory_mode & 0o022) + expected_text = "expected 0700" if store.config_path == DEFAULT_CONFIG_PATH \ + else "must not be group- or world-writable" + check(expected_directory_mode, + f"trusted state directory {store.directory_path} " + f"({directory_mode:04o}; {expected_text})") + try: + with store.lock(exclusive=False): + config, _raw = store.read_config() + check(True, f"config: {CONFIG_PATH}") + check(True, "config schema") + except QOpenError as exc: + check(False, str(exc)) + try: + with store.lock(exclusive=False): + store.read_config(store.backup_name, "backup") + check(True, f"recoverable backup: {BACKUP_PATH}") + print("~ run: qopen recover") + except QOpenError as backup_exc: + check(False, str(backup_exc)) + return 1 + + for name, label, required in ( + (store.config_name, "config", True), + (store.backup_name, "backup", False)): + try: + fd = store.open_regular(name, label, allow_insecure_mode=True) + except FileNotFoundError: + if required: + check(False, f"missing state file: {store.path_for(name)}") + else: + print(f"~ optional state file absent: {store.path_for(name)}") + continue + except QOpenError as exc: + check(False, str(exc)) + continue + try: + mode = stat.S_IMODE(os.fstat(fd).st_mode) + check(mode == 0o600, + f"private permissions {store.path_for(name)} " + f"({mode:04o}; expected 0600)") + finally: + os.close(fd) + + try: + writable_fd = store.open_regular( + store.config_name, "config", writable=True, allow_insecure_mode=True + ) + except (FileNotFoundError, QOpenError): + check(False, "config writable") + else: + os.close(writable_fd) + check(True, "config writable") + try: + with store.lock(): + pass + check(True, "state directory lock") + except QOpenError: + check(False, "state directory lock") except QOpenError as exc: check(False, str(exc)) - try: - read_config_path(BACKUP_PATH, "backup") - check(True, f"recoverable backup: {BACKUP_PATH}") - print("~ run: qopen recover") - except QOpenError as backup_exc: - check(False, str(backup_exc)) return 1 - check(os.access(CONFIG_PATH, os.W_OK), "config writable") - try: - with config_lock(): - pass - check(True, "config lock") - except QOpenError: - check(False, "config lock") - check_private(LOCK_PATH) - check_private(BACKUP_PATH, required=False) + check(has_command("omarchy-menu-select"), "omarchy-menu-select") check(has_command("omarchy-menu-input"), "omarchy-menu-input") print(f"{'✓' if has_command('wl-paste') else '~'} wl-paste (clipboard path input)") diff --git a/manifest.json b/manifest.json index a7a5be8..e1539ab 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "qopen.launcher", "name": "QOpen", - "version": "2.5.0", + "version": "2.5.1", "author": "CoderLambert", "description": "Unified launcher for web, files, projects, TUI tools, commands and SSH targets", "kinds": [ diff --git a/tests/test_backend.py b/tests/test_backend.py index 5bd6edb..03b52bc 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,11 +1,15 @@ from __future__ import annotations +import fcntl import json import os import shlex import stat import subprocess +import sys import tempfile +import threading +import time import unittest from pathlib import Path @@ -28,12 +32,17 @@ def run_qopen( *arguments: str, config: Path | None = None, check: bool = True, + environment_overrides: dict[str, str] | None = None, + timeout: float = 10, ) -> subprocess.CompletedProcess[str]: environment = os.environ.copy() environment.pop("DISPLAY", None) environment.pop("WAYLAND_DISPLAY", None) + environment.pop("QOPEN_CONFIG", None) if config is not None: environment["QOPEN_CONFIG"] = str(config) + if environment_overrides: + environment.update(environment_overrides) return subprocess.run( [str(QOPEN), *arguments], cwd=REPOSITORY, @@ -41,6 +50,7 @@ def run_qopen( text=True, capture_output=True, check=check, + timeout=timeout, ) def api( @@ -190,6 +200,13 @@ def test_qml_does_not_load_native_file_dialogs(self) -> None: self.assertNotIn("QtQuick.Dialogs", qml_source) self.assertNotIn("FileDialog {", qml_source) self.assertNotIn("FolderDialog {", qml_source) + self.assertNotIn("FileView {", qml_source) + self.assertNotIn("StdioCollector {", qml_source) + bounded_source = (REPOSITORY / "BoundedProcess.qml").read_text(encoding="utf-8") + self.assertIn("stdout: SplitParser", bounded_source) + self.assertIn("deadlineTimer.restart()", bounded_source) + self.assertIn("child.signal(15)", bounded_source) + self.assertIn("child.signal(9)", bounded_source) def test_qml_uses_native_mutations_and_closes_the_stock_menu(self) -> None: qopen_source = (REPOSITORY / "QOpen.qml").read_text(encoding="utf-8") @@ -198,6 +215,9 @@ def test_qml_uses_native_mutations_and_closes_the_stock_menu(self) -> None: self.assertIn('"api", "favorite"', qopen_source) self.assertIn('"api", "delete"', qopen_source) self.assertIn('"api", "recover"', qopen_source) + self.assertNotIn('"--edit"', qopen_source) + self.assertNotIn("Edit raw config", qopen_source) + self.assertNotIn("rawConfigButton", qopen_source) self.assertNotIn('[root.backendPath, "favorite"', qopen_source) self.assertNotIn('[root.backendPath, "remove"', qopen_source) @@ -206,7 +226,8 @@ def test_path_picker_tracks_and_rejects_stale_requests(self) -> None: self.assertIn('"--request-id", String(root.activeRequestSerial)', picker_source) self.assertIn("Number(result.requestId) !== root.requestSerial", picker_source) - self.assertIn("if (browseProcess.running) browseProcess.running = false", picker_source) + self.assertIn("if (browseProcess.running) browseProcess.cancel()", picker_source) + self.assertIn("browseProcess.start(command, root.activeRequestSerial)", picker_source) def test_command_arguments_round_trip_through_the_editor_format(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -364,7 +385,8 @@ def test_catalog_api_rejects_semantically_invalid_json(self) -> None: self.assertFalse(response["ok"]) self.assertIn("items[0].id", response["error"]) qopen_source = (REPOSITORY / "QOpen.qml").read_text(encoding="utf-8") - self.assertIn('catalogProcess.command = [root.backendPath, "api", "catalog"]', qopen_source) + self.assertIn('[root.backendPath, "api", "catalog"]', qopen_source) + self.assertIn("catalogProcess.start(", qopen_source) def test_catalog_waits_for_the_host_to_inject_the_plugin_directory(self) -> None: qopen_source = (REPOSITORY / "QOpen.qml").read_text(encoding="utf-8") @@ -380,9 +402,9 @@ def test_catalog_waits_for_the_host_to_inject_the_plugin_directory(self) -> None def test_missing_catalog_requests_first_run_initialization(self) -> None: qopen_source = (REPOSITORY / "QOpen.qml").read_text(encoding="utf-8") - self.assertIn('root.configError = "Preparing first-run resources…"', qopen_source) - self.assertIn("onLoadFailed: function(error)", qopen_source) self.assertIn("root.requestCatalogReload()", qopen_source) + self.assertNotIn("onLoadFailed: function(error)", qopen_source) + self.assertNotIn("configPath:", qopen_source) def test_fix_permissions_secures_all_state_files(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -392,16 +414,36 @@ def test_fix_permissions_secures_all_state_files(self) -> None: self.api( "create", "--payload", self.project_payload(root, "second"), config=config ) - lock = config.with_name("config.json.lock") backup = config.with_name("config.json.bak") - for path in (config, lock, backup): + for path in (config, backup): path.chmod(0o644) + root.chmod(0o755) result = self.run_qopen("fix-permissions", config=config) - self.assertIn("3 changed", result.stdout) - for path in (config, lock, backup): + self.assertIn("2 changed", result.stdout) + self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o755) + for path in (config, backup): self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertFalse(config.with_name("config.json.lock").exists()) + + def test_fix_permissions_secures_only_the_default_state_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + home = Path(temporary_directory) + environment = {"HOME": str(home)} + self.run_qopen("api", "catalog", environment_overrides=environment) + state = home / ".config" / "qopen" + config = state / "config.json" + state.chmod(0o755) + config.chmod(0o644) + + result = self.run_qopen( + "fix-permissions", environment_overrides=environment + ) + + self.assertIn("2 changed", result.stdout) + self.assertEqual(stat.S_IMODE(state.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) def test_doctor_reports_insecure_config_permissions(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: @@ -415,6 +457,247 @@ def test_doctor_reports_insecure_config_permissions(self) -> None: self.assertEqual(result.returncode, 1) self.assertIn("expected 0600", result.stdout) + def test_state_parent_and_files_reject_symlinks_and_special_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + real_state = root / "real-state" + real_state.mkdir(mode=0o700) + linked_state = root / "linked-state" + linked_state.symlink_to(real_state, target_is_directory=True) + + parent_response = self.api( + "catalog", config=linked_state / "config.json", check=False + ) + self.assertFalse(parent_response["ok"]) + self.assertIn("secure state directory", parent_response["error"]) + + sentinel = root / "sentinel" + sentinel.write_text("do-not-change", encoding="utf-8") + config = real_state / "config.json" + config.symlink_to(sentinel) + symlink_response = self.api("catalog", config=config, check=False) + self.assertFalse(symlink_response["ok"]) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "do-not-change") + + config.unlink() + os.mkfifo(config, mode=0o600) + started = time.monotonic() + fifo_response = self.api("catalog", config=config, check=False) + self.assertLess(time.monotonic() - started, 1.0) + self.assertFalse(fifo_response["ok"]) + self.assertIn("regular file", fifo_response["error"]) + + def test_backup_symlink_is_rejected_without_touching_its_target(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + state = root / "state" + state.mkdir(mode=0o700) + config = state / "config.json" + self.api("create", "--payload", self.project_payload(root), config=config) + sentinel = root / "sentinel" + sentinel.write_text("unchanged", encoding="utf-8") + backup = config.with_name("config.json.bak") + backup.unlink() + backup.symlink_to(sentinel) + + response = self.api( + "create", "--payload", self.project_payload(root, "second"), + config=config, check=False, + ) + + self.assertFalse(response["ok"]) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "unchanged") + self.assertFalse(any(item["id"] == "second" for item in json.loads( + config.read_text(encoding="utf-8") + )["items"])) + + def test_hardlinked_and_oversized_catalogs_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + state = root / "state" + state.mkdir(mode=0o700) + source = root / "source.json" + source.write_text( + json.dumps({"version": 1, "defaults": {}, "items": []}), + encoding="utf-8", + ) + config = state / "config.json" + os.link(source, config) + + linked = self.api("catalog", config=config, check=False) + self.assertFalse(linked["ok"]) + self.assertIn("hard links", linked["error"]) + + config.unlink() + config.write_bytes(b" " * (1024 * 1024 + 1)) + oversized = self.run_qopen( + "api", "catalog", config=config, check=False + ) + payload = json.loads(oversized.stdout) + self.assertFalse(payload["ok"]) + self.assertLess(len(oversized.stdout.encode("utf-8")), 4096) + self.assertIn("safety limit", payload["error"]) + + def test_group_writable_state_file_is_rejected_and_repairable(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + config = root / "config.json" + self.api("catalog", config=config) + config.chmod(0o660) + + rejected = self.api("catalog", config=config, check=False) + self.assertFalse(rejected["ok"]) + self.assertIn("writable by another account", rejected["error"]) + + repaired = self.run_qopen("fix-permissions", config=config) + self.assertIn("1 changed", repaired.stdout) + self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600) + self.assertTrue(self.api("catalog", config=config)["ok"]) + + def test_state_directory_lock_has_a_real_deadline(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + config = root / "config.json" + self.api("catalog", config=config) + directory_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY) + try: + fcntl.flock(directory_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + started = time.monotonic() + response = self.api("catalog", config=config, check=False) + elapsed = time.monotonic() - started + finally: + fcntl.flock(directory_fd, fcntl.LOCK_UN) + os.close(directory_fd) + + self.assertFalse(response["ok"]) + self.assertIn("timed out", response["error"]) + self.assertGreaterEqual(elapsed, 1.8) + self.assertLess(elapsed, 3.5) + + def test_path_browser_bounds_work_and_response_size(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + for index in range(1002): + (root / f"entry-{index:04d}").mkdir() + + result = self.run_qopen( + "api", "browse-path", "--path", str(root), "--type", "project" + ) + response = json.loads(result.stdout) + + self.assertTrue(response["ok"]) + self.assertTrue(response["result"]["truncated"]) + self.assertEqual(len(response["result"]["entries"]), 1000) + self.assertLess(len(result.stdout.encode("utf-8")), 1280 * 1024) + + def test_clipboard_helper_output_is_bounded_before_the_api_response(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + helper = root / "wl-paste" + helper.write_text( + f"#!{sys.executable}\nprint('x' * 20000)\n", + encoding="utf-8", + ) + helper.chmod(0o700) + environment = {"PATH": f"{root}:{os.environ.get('PATH', '')}"} + + result = self.run_qopen( + "api", "clipboard-read", + config=root / "config.json", + check=False, + environment_overrides=environment, + ) + response = json.loads(result.stdout) + + self.assertFalse(response["ok"]) + self.assertIn("safety limit", response["error"]) + self.assertLess(len(result.stdout.encode("utf-8")), 4096) + + def test_clipboard_helper_has_a_real_deadline(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + helper = root / "wl-paste" + helper.write_text( + f"#!{sys.executable}\nimport time\ntime.sleep(5)\n", + encoding="utf-8", + ) + helper.chmod(0o700) + environment = {"PATH": f"{root}:{os.environ.get('PATH', '')}"} + + started = time.monotonic() + result = self.run_qopen( + "api", "clipboard-read", + config=root / "config.json", + check=False, + environment_overrides=environment, + ) + elapsed = time.monotonic() - started + response = json.loads(result.stdout) + + self.assertFalse(response["ok"]) + self.assertLess(elapsed, 2.0) + self.assertIn("clipboard is unavailable", response["error"]) + + def test_state_lifecycle_has_no_ordinary_pathname_bypass(self) -> None: + source = QOPEN.read_text(encoding="utf-8") + self.assertNotIn("shutil.copy2", source) + self.assertNotIn("CONFIG_PATH.open", source) + self.assertNotIn("CONFIG_PATH.exists", source) + self.assertNotIn("os.chmod(CONFIG_PATH", source) + self.assertNotIn("config.json.lock", source) + self.assertIn("src_dir_fd=self.dir_fd", source) + self.assertIn("os.O_NOFOLLOW | os.O_NONBLOCK", source) + + def test_state_path_race_never_writes_through_an_external_symlink(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + state = root / "state" + state.mkdir(mode=0o700) + config = state / "config.json" + self.api("catalog", config=config) + valid_catalog = config.read_bytes() + sentinel = root / "sentinel" + sentinel.write_text("external-target", encoding="utf-8") + stopped = threading.Event() + + def race_state_path() -> None: + counter = 0 + while not stopped.is_set(): + temporary = state / f".attacker-{counter % 2}" + counter += 1 + try: + temporary.write_bytes(valid_catalog) + os.replace(temporary, config) + except OSError: + pass + try: + config.unlink(missing_ok=True) + config.symlink_to(sentinel) + except OSError: + pass + + attacker = threading.Thread(target=race_state_path, daemon=True) + attacker.start() + try: + for _index in range(12): + result = self.run_qopen( + "api", "favorite", "--id", "omarchy", "--mode", "toggle", + config=config, check=False, timeout=5, + ) + self.assertLess(len(result.stdout.encode("utf-8")), 4096) + json.loads(result.stdout) + finally: + stopped.set() + attacker.join(timeout=2) + + self.assertFalse(attacker.is_alive()) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "external-target") + + def test_direct_catalog_editing_is_disabled(self) -> None: + result = self.run_qopen("--edit", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("direct catalog-file editing is disabled", result.stderr) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_docs.py b/tests/test_docs.py index dbb063d..ce16561 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -137,6 +137,9 @@ def test_ai_collaboration_guide_keeps_critical_contracts(self) -> None: "feature/* -> dev -> uat -> main", "QOPEN_CONFIG", "QtQuick.Dialogs", + "BoundedProcess.qml", + "StdioCollector", + "descriptor-anchored", "omarchy plugin validate .", "omarchy plugin update qopen.launcher", "~/.config/qopen/config.json", diff --git a/tests/test_path_picker_refresh_regressions.py b/tests/test_path_picker_refresh_regressions.py new file mode 100644 index 0000000..9c86530 --- /dev/null +++ b/tests/test_path_picker_refresh_regressions.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import unittest +from pathlib import Path + + +REPOSITORY = Path(__file__).resolve().parents[1] +PATH_PICKER = REPOSITORY / "PathPicker.qml" + + +class PathPickerRefreshRegressionTests(unittest.TestCase): + def test_refresh_is_explicit_and_does_not_reinterpret_unsubmitted_path_text(self) -> None: + source = PATH_PICKER.read_text(encoding="utf-8") + + self.assertIn("function refreshCurrentPath()", source) + self.assertIn("var target = root.currentPath", source) + self.assertIn('tooltipText: "Refresh current directory · F5"', source) + self.assertGreaterEqual(source.count("event.key === Qt.Key_F5"), 2) + self.assertIn("onClicked: root.refreshCurrentPath()", source) + self.assertIn("onClicked: root.loadPath(pathField.text)", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_process_boundary_regressions.py b/tests/test_process_boundary_regressions.py new file mode 100644 index 0000000..2100ebf --- /dev/null +++ b/tests/test_process_boundary_regressions.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + + +REPOSITORY = Path(__file__).resolve().parents[1] +QOPEN = REPOSITORY / "bin" / "qopen" + + +class ProcessBoundaryRegressionTests(unittest.TestCase): + def run_qopen( + self, + *arguments: str, + environment_overrides: dict[str, str] | None = None, + timeout: float = 5, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.pop("DISPLAY", None) + environment.pop("WAYLAND_DISPLAY", None) + if environment_overrides: + environment.update(environment_overrides) + return subprocess.run( + [str(QOPEN), *arguments], + cwd=REPOSITORY, + env=environment, + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + + @staticmethod + def process_is_running(pid: int) -> bool: + status = Path(f"/proc/{pid}/stat") + try: + fields = status.read_text(encoding="utf-8").split() + except FileNotFoundError: + return False + return len(fields) > 2 and fields[2] != "Z" + + def test_helper_deadline_terminates_the_whole_process_group(self) -> None: + """A helper child that inherits stdout must not survive its parent deadline.""" + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + child_pid_file = root / "child.pid" + helper = root / "wl-paste" + helper.write_text( + f"#!{sys.executable}\n" + "import os, subprocess, sys, time\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" + "with open(os.environ['QOPEN_TEST_CHILD_PID'], 'w', encoding='utf-8') as output:\n" + " output.write(str(child.pid))\n" + " output.flush()\n" + " os.fsync(output.fileno())\n" + "time.sleep(60)\n", + encoding="utf-8", + ) + helper.chmod(0o700) + environment = { + "PATH": f"{root}:{os.environ.get('PATH', '')}", + "QOPEN_TEST_CHILD_PID": str(child_pid_file), + } + + started = time.monotonic() + result = self.run_qopen( + "api", + "clipboard-read", + environment_overrides=environment, + ) + elapsed = time.monotonic() - started + response = json.loads(result.stdout) + + self.assertFalse(response["ok"]) + self.assertIn("clipboard is unavailable", response["error"]) + self.assertLess(elapsed, 2.0) + self.assertTrue(child_pid_file.exists()) + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + + deadline = time.monotonic() + 1.0 + while self.process_is_running(child_pid) and time.monotonic() < deadline: + time.sleep(0.02) + self.assertFalse( + self.process_is_running(child_pid), + "helper descendant survived the bounded-process deadline", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_state_boundary_regressions.py b/tests/test_state_boundary_regressions.py new file mode 100644 index 0000000..e02011a --- /dev/null +++ b/tests/test_state_boundary_regressions.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import os +import stat +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path +from unittest import mock + + +REPOSITORY = Path(__file__).resolve().parents[1] +QOPEN = REPOSITORY / "bin" / "qopen" + + +class StateBoundaryRegressionTests(unittest.TestCase): + def run_qopen( + self, + *arguments: str, + config: Path, + check: bool = True, + timeout: float = 10, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.pop("DISPLAY", None) + environment.pop("WAYLAND_DISPLAY", None) + environment["QOPEN_CONFIG"] = str(config) + return subprocess.run( + [str(QOPEN), *arguments], + cwd=REPOSITORY, + env=environment, + text=True, + capture_output=True, + check=check, + timeout=timeout, + ) + + def api( + self, + *arguments: str, + config: Path, + check: bool = True, + timeout: float = 10, + ) -> dict[str, object]: + result = self.run_qopen( + "api", + *arguments, + config=config, + check=check, + timeout=timeout, + ) + return json.loads(result.stdout) + + def test_root_parent_state_directory_keeps_transferred_descriptor_open(self) -> None: + """The root directory FD must stay owned by the store after transfer.""" + loader = importlib.machinery.SourceFileLoader("qopen_state_boundary", str(QOPEN)) + spec = importlib.util.spec_from_loader(loader.name, loader) + self.assertIsNotNone(spec) + assert spec is not None + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + + with mock.patch.object(module.os, "geteuid", return_value=0): + with module.SecureStateStore(Path("/config.json")) as store: + metadata = os.fstat(store.dir_fd) + self.assertTrue(stat.S_ISDIR(metadata.st_mode)) + self.assertEqual(store.directory_path, Path("/")) + + def test_intermediate_parent_symlink_is_rejected(self) -> None: + """Every ancestor component must be opened with O_NOFOLLOW.""" + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + real_parent = root / "real-parent" + real_state = real_parent / "qopen" + real_state.mkdir(parents=True, mode=0o700) + + linked_parent = root / "linked-parent" + linked_parent.symlink_to(real_parent, target_is_directory=True) + config = linked_parent / "qopen" / "config.json" + + response = self.api("catalog", config=config, check=False) + + self.assertFalse(response["ok"]) + self.assertIn("secure state directory", str(response["error"])) + self.assertFalse((real_state / "config.json").exists()) + + def test_backup_path_race_never_writes_through_external_symlink(self) -> None: + """Concurrent replacement of config.json.bak must never modify its target.""" + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + state = root / "state" + state.mkdir(mode=0o700) + config = state / "config.json" + + initial = self.api("catalog", config=config) + self.assertTrue(initial["ok"]) + + sentinel = root / "sentinel" + sentinel.write_text("external-target", encoding="utf-8") + backup = state / "config.json.bak" + stopped = threading.Event() + + def race_backup_path() -> None: + counter = 0 + while not stopped.is_set(): + temporary = state / f".attacker-backup-{counter % 2}" + counter += 1 + try: + temporary.write_text("{}\n", encoding="utf-8") + os.replace(temporary, backup) + except OSError: + pass + try: + backup.unlink(missing_ok=True) + backup.symlink_to(sentinel) + except OSError: + pass + + attacker = threading.Thread(target=race_backup_path, daemon=True) + attacker.start() + try: + for _index in range(16): + result = self.run_qopen( + "api", + "favorite", + "--id", + "omarchy", + "--mode", + "toggle", + config=config, + check=False, + timeout=5, + ) + self.assertLess(len(result.stdout.encode("utf-8")), 4096) + response = json.loads(result.stdout) + self.assertIn("ok", response) + finally: + stopped.set() + attacker.join(timeout=2) + + self.assertFalse(attacker.is_alive()) + self.assertEqual( + sentinel.read_text(encoding="utf-8"), + "external-target", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_url_validation_regressions.py b/tests/test_url_validation_regressions.py new file mode 100644 index 0000000..dfbb3cf --- /dev/null +++ b/tests/test_url_validation_regressions.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY = Path(__file__).resolve().parents[1] +QOPEN = REPOSITORY / "bin" / "qopen" + + +class UrlValidationRegressionTests(unittest.TestCase): + def run_qopen( + self, + *arguments: str, + config_path: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.pop("DISPLAY", None) + environment.pop("WAYLAND_DISPLAY", None) + if config_path is not None: + environment["QOPEN_CONFIG"] = str(config_path) + return subprocess.run( + [str(QOPEN), *arguments], + cwd=REPOSITORY, + env=environment, + text=True, + capture_output=True, + check=False, + timeout=5, + ) + + def check_web_target(self, value: str) -> dict[str, object]: + result = self.run_qopen( + "api", + "check-target", + "--type", + "web", + "--value", + value, + ) + self.assertEqual(result.returncode, 0, result.stderr) + response = json.loads(result.stdout) + self.assertTrue(response["ok"]) + return response["result"] + + def test_bare_single_label_is_rejected_but_common_local_targets_remain_valid(self) -> None: + invalid = self.check_web_target("asd") + self.assertFalse(invalid["valid"]) + self.assertEqual(invalid["level"], "error") + self.assertIn("domain", str(invalid["message"]).lower()) + + valid_cases = { + "example.com": "https://example.com", + "localhost:3000": "https://localhost:3000", + "127.0.0.1:3000": "https://127.0.0.1:3000", + "[::1]:3000": "https://[::1]:3000", + "https://intranet": "https://intranet", + } + for value, expected in valid_cases.items(): + with self.subTest(value=value): + response = self.check_web_target(value) + self.assertTrue(response["valid"]) + self.assertEqual(response["level"], "ok") + self.assertEqual(response["normalized"], expected) + + def test_editor_validation_uses_the_same_url_rule(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + config_path = Path(temporary_directory) / "qopen" / "config.json" + payload = { + "name": "Invalid URL", + "type": "web", + "group": "web", + "target": "asd", + "mode": "app", + } + result = self.run_qopen( + "api", + "validate", + "--payload", + json.dumps(payload), + config_path=config_path, + ) + self.assertEqual(result.returncode, 1) + response = json.loads(result.stdout) + self.assertFalse(response["ok"]) + self.assertIn("domain", response["error"].lower()) + + def test_embedded_credentials_remain_rejected(self) -> None: + response = self.check_web_target("https://user:pass@example.com") + self.assertFalse(response["valid"]) + self.assertEqual(response["level"], "error") + self.assertIn("credentials", str(response["message"]).lower()) + + +if __name__ == "__main__": + unittest.main()