From 9f68379fb85ac50704b9f7d06dd83ba33430722a Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 4 Oct 2026 19:16:29 +0800 Subject: [PATCH] feat(web): enhance web functionality with backup and import/export features, improve logging configuration, and add end-to-end tests for UI components --- .gitignore | 1 + docker-compose.yml | 5 + docs/web-api-inventory.json | 45 +- docs/web-architecture.md | 8 + docs/web-deployment.md | 53 +- e2e/playwright.config.mjs | 4 +- e2e/web-features.spec.mjs | 347 +++++++ scripts/web-local-e2e.mjs | 93 ++ src-tauri/Cargo.lock | 8 + src-tauri/Cargo.toml | 2 +- src-tauri/crates/nyaterm-core/Cargo.lock | 909 +++++++++++++++++- src-tauri/crates/nyaterm-core/Cargo.toml | 13 +- .../crates/nyaterm-core/src/core/backup.rs | 140 +++ .../nyaterm-core/src/core/backup_crypto.rs | 67 ++ .../nyaterm-core}/src/core/importer/common.rs | 0 .../src/core/importer/electerm.rs | 0 .../src/core/importer/finalshell.rs | 0 .../nyaterm-core}/src/core/importer/merge.rs | 44 +- .../src/core/importer/mobaxterm.rs | 6 +- .../nyaterm-core/src/core/importer/mod.rs | 110 +++ .../src/core/importer/nyaterm_json.rs | 5 +- .../src/core/importer/securecrt.rs | 6 +- .../src/core/importer/termius.rs | 73 +- .../nyaterm-core}/src/core/importer/tests.rs | 51 +- .../nyaterm-core}/src/core/importer/text.rs | 0 .../nyaterm-core}/src/core/importer/types.rs | 1 - .../src/core/importer/windterm.rs | 64 +- .../nyaterm-core}/src/core/importer/xshell.rs | 18 +- .../src/core/keyword_highlights.rs | 84 ++ src-tauri/crates/nyaterm-core/src/core/mod.rs | 6 + .../src/core/portable_snapshot/codec.rs | 66 +- .../src/core/portable_snapshot/hash.rs | 9 +- .../src/core/portable_snapshot/legacy.rs | 0 .../src/core/portable_snapshot/mod.rs | 43 + .../src/core/portable_snapshot/redb_codec.rs | 2 +- .../src/core/portable_snapshot/temp_redb.rs | 0 .../src/core/portable_snapshot/tests.rs | 67 +- .../src/core/portable_snapshot/types.rs | 8 +- .../crates/nyaterm-core/src/storage/backup.rs | 120 +++ .../crates/nyaterm-core/src/storage/mod.rs | 1 + .../crates/nyaterm-core/src/storage/notes.rs | 42 +- .../crates/nyaterm-core/src/storage/tests.rs | 33 + .../crates/nyaterm-core/src/utils/crypto.rs | 50 +- src-tauri/crates/nyaterm-web/Cargo.lock | 151 ++- src-tauri/crates/nyaterm-web/Cargo.toml | 5 + src-tauri/crates/nyaterm-web/src/ai.rs | 2 +- src-tauri/crates/nyaterm-web/src/auth.rs | 17 + src-tauri/crates/nyaterm-web/src/backups.rs | 167 ++++ src-tauri/crates/nyaterm-web/src/commands.rs | 17 + src-tauri/crates/nyaterm-web/src/error.rs | 14 +- src-tauri/crates/nyaterm-web/src/lib.rs | 20 + src-tauri/crates/nyaterm-web/src/main.rs | 15 +- src-tauri/crates/nyaterm-web/src/network.rs | 21 +- .../crates/nyaterm-web/src/observability.rs | 776 +++++++++++++++ .../crates/nyaterm-web/src/remote_exec.rs | 2 +- src-tauri/crates/nyaterm-web/src/session.rs | 16 +- src-tauri/crates/nyaterm-web/src/sftp.rs | 25 +- src-tauri/crates/nyaterm-web/src/state.rs | 6 +- src-tauri/crates/nyaterm-web/src/telnet.rs | 4 +- src-tauri/crates/nyaterm-web/src/vnc.rs | 43 +- src-tauri/crates/nyaterm-web/tests/backups.rs | 470 +++++++++ src-tauri/src/cmd/settings.rs | 85 +- src-tauri/src/core/cloud_sync/crypto.rs | 111 +-- src-tauri/src/core/importer/mod.rs | 56 +- src-tauri/src/core/portable_snapshot/build.rs | 9 - src-tauri/src/core/portable_snapshot/mod.rs | 41 +- .../dialog/app/BrowserBackupDialog.tsx | 130 +++ .../dialog/connections/ImportDialog.tsx | 52 +- .../dialog/security-auth/KeyEditorDialog.tsx | 54 +- .../terminal/KeywordHighlightImportDialog.tsx | 21 +- .../dialog/theme/ThemeDesignerDialog.tsx | 71 +- .../panel/security-auth/KeyManagementTab.tsx | 113 ++- src/components/settings/GeneralTab.tsx | 18 +- src/components/settings/TerminalTab.tsx | 283 +++--- src/hooks/useConfigTransfer.test.tsx | 116 +++ src/hooks/useConfigTransfer.tsx | 200 +++- src/i18n/index.ts | 25 +- src/i18n/locales/en.json | 29 +- src/i18n/locales/ko.json | 29 +- src/i18n/locales/zh-CN.json | 29 +- src/i18n/locales/zh-TW.json | 29 +- src/lib/backend/BrowserGate.test.tsx | 88 ++ src/lib/backend/BrowserGate.tsx | 255 ++++- src/lib/backend/api.ts | 11 +- src/lib/backend/browserArtifacts.ts | 36 +- src/lib/backend/browserDiagnostics.ts | 26 + src/lib/backend/configTransfer.ts | 58 ++ src/lib/backend/files.ts | 54 +- src/lib/backend/http.ts | 80 +- src/lib/invoke.ts | 2 +- src/lib/logger.ts | 128 ++- src/lib/logger.web.test.ts | 100 ++ src/main.tsx | 12 +- 93 files changed, 5706 insertions(+), 1020 deletions(-) create mode 100644 e2e/web-features.spec.mjs create mode 100644 scripts/web-local-e2e.mjs create mode 100644 src-tauri/crates/nyaterm-core/src/core/backup.rs create mode 100644 src-tauri/crates/nyaterm-core/src/core/backup_crypto.rs rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/common.rs (100%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/electerm.rs (100%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/finalshell.rs (100%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/merge.rs (92%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/mobaxterm.rs (94%) create mode 100644 src-tauri/crates/nyaterm-core/src/core/importer/mod.rs rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/nyaterm_json.rs (99%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/securecrt.rs (98%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/termius.rs (95%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/tests.rs (97%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/text.rs (100%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/types.rs (99%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/windterm.rs (91%) rename src-tauri/{ => crates/nyaterm-core}/src/core/importer/xshell.rs (81%) create mode 100644 src-tauri/crates/nyaterm-core/src/core/keyword_highlights.rs rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/codec.rs (80%) rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/hash.rs (96%) rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/legacy.rs (100%) create mode 100644 src-tauri/crates/nyaterm-core/src/core/portable_snapshot/mod.rs rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/redb_codec.rs (98%) rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/temp_redb.rs (100%) rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/tests.rs (97%) rename src-tauri/{ => crates/nyaterm-core}/src/core/portable_snapshot/types.rs (99%) create mode 100644 src-tauri/crates/nyaterm-core/src/storage/backup.rs create mode 100644 src-tauri/crates/nyaterm-web/src/backups.rs create mode 100644 src-tauri/crates/nyaterm-web/src/observability.rs create mode 100644 src-tauri/crates/nyaterm-web/tests/backups.rs create mode 100644 src/components/dialog/app/BrowserBackupDialog.tsx create mode 100644 src/hooks/useConfigTransfer.test.tsx create mode 100644 src/lib/backend/BrowserGate.test.tsx create mode 100644 src/lib/backend/browserDiagnostics.ts create mode 100644 src/lib/backend/configTransfer.ts create mode 100644 src/lib/logger.web.test.ts diff --git a/.gitignore b/.gitignore index 8b0a67e7..a46998e5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ web-test-data/ # Isolated deployment test traces and container logs artifacts/web-e2e/ +artifacts/web-local-e2e/ playwright-report/ diff --git a/docker-compose.yml b/docker-compose.yml index 4f96a69c..8ff1673b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key} volumes: - nyaterm-data:/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" restart: unless-stopped init: true read_only: true diff --git a/docs/web-api-inventory.json b/docs/web-api-inventory.json index fd816f55..7edee020 100644 --- a/docs/web-api-inventory.json +++ b/docs/web-api-inventory.json @@ -4,8 +4,8 @@ "scope": "Production frontend invoke calls, conditional literal branches, browser adapter and explicit server allowlists. Static evidence only; capability-gated desktop calls are not Web defects.", "counts": { "browser-adapter": 23, - "server-allowlist": 124, - "desktop-or-unmatched": 147 + "server-allowlist": 125, + "desktop-or-unmatched": 146 }, "serverCommands": [ "append_ai_audit", @@ -93,6 +93,7 @@ "get_system_fonts", "get_terminal_cwd", "import_connection_icon", + "import_keyword_highlight_rules", "import_quick_commands", "increment_quick_command_use_count", "list_ai_model_names", @@ -217,7 +218,7 @@ "locations": [ { "file": "src/lib/logger.ts", - "line": 300 + "line": 331 } ] }, @@ -931,7 +932,7 @@ "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 207 + "line": 203 } ] }, @@ -1103,7 +1104,7 @@ "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", - "line": 46 + "line": 78 } ] }, @@ -1113,7 +1114,7 @@ "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", - "line": 106 + "line": 168 } ] }, @@ -1255,7 +1256,7 @@ "locations": [ { "file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx", - "line": 59 + "line": 66 }, { "file": "src/context/AppProvider.tsx", @@ -1863,7 +1864,7 @@ "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 132 + "line": 133 } ] }, @@ -1873,7 +1874,7 @@ "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 261 + "line": 257 } ] }, @@ -1883,7 +1884,7 @@ "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 274 + "line": 270 } ] }, @@ -1897,7 +1898,7 @@ }, { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 74 + "line": 76 }, { "file": "src/components/sessions/SshForm.tsx", @@ -2013,7 +2014,7 @@ "locations": [ { "file": "src/hooks/useConfigTransfer.tsx", - "line": 70 + "line": 123 } ] }, @@ -2029,11 +2030,11 @@ }, { "command": "import_keyword_highlight_rules", - "boundary": "desktop-or-unmatched", + "boundary": "server-allowlist", "locations": [ { "file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx", - "line": 56 + "line": 63 } ] }, @@ -2063,7 +2064,7 @@ "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", - "line": 167 + "line": 171 } ] }, @@ -2073,7 +2074,7 @@ "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", - "line": 179 + "line": 213 } ] }, @@ -2083,11 +2084,11 @@ "locations": [ { "file": "src/components/dialog/connections/ImportDialog.tsx", - "line": 166 + "line": 170 }, { "file": "src/components/dialog/connections/ImportDialog.tsx", - "line": 200 + "line": 234 } ] }, @@ -2433,7 +2434,7 @@ }, { "file": "src/hooks/useConfigTransfer.tsx", - "line": 85 + "line": 140 } ] }, @@ -2687,7 +2688,7 @@ "locations": [ { "file": "src/components/dialog/theme/ThemeDesignerDialog.tsx", - "line": 229 + "line": 236 } ] }, @@ -3221,7 +3222,7 @@ "locations": [ { "file": "src/components/panel/security-auth/KeyManagementTab.tsx", - "line": 186 + "line": 182 } ] }, @@ -3958,7 +3959,7 @@ }, { "file": "src/lib/backend/api.ts", - "line": 13, + "line": 14, "expression": "command" }, { diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 5e85edf3..1423a83e 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -130,3 +130,11 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返 浏览器 SSH/Telnet 由 `BrowserTerminals` 持有每会话连接状态,初次握手和掉线恢复统一执行有时间预算的附着重试,复用 `get_session_info` 查询和原有终端事件,无新增重连协议。跨 WS/SSE 的关闭事件只投递一次。 Web 上传使用 SFTP lstat 区分存在、目录、权限和连接错误,复用 owner 冲突提示。临时文件和传输许可由 RAII guard 持有直到异步清理结束,显式会话关闭等待清理后才断开 SSH;提示 future 被 HTTP 中断丢弃时仍会移除待响应记录。数据格式保持兼容,新增响应字段见部署文档。 + +## 密码备份与诊断实现 + +可移植快照的类型、编解码、旧版恢复和备份加密现位于 Core;桌面保留原导入导出入口。Web 使用共享格式、显式源/目标密钥转换及单个 redb 恢复事务,不切换全局主密码、不写入源部署密钥。连接解析也迁入 Core,Termius 仅通过桌面 feature 启用;Web WindTerm 解析器明确禁止本机资源读取。 + +Web 的 `backups.rs` 提供有大小限制的上传/下载 API,`observability.rs` 提供 tracing、请求关联、动态设置、有界后台写入、轮转、上报二次脱敏及诊断 ZIP。浏览器文件传输位于 `configTransfer.ts` / `browserArtifacts.ts`;日志认证传输独立于 invoke/logger,避免递归。SSE/WebSocket 使用保存请求 span 的断连守卫,后台连接和阻塞文件任务也继承 span。 + +`tests/backups.rs` 使用真实根路径与子路径 Web 进程,覆盖桌面→Web、不同部署密钥 Web→Web、桌面编解码读取 Web 备份、真实凭据解密、密码/损坏/事务失败回滚、诊断脱敏与请求 ID、重启后解密。Core 保留旧版解码及各来源解析测试;Web 日志测试覆盖轮转、保留上限、文件写入失败和仍打开文件的诊断读取。前端测试覆盖登录状态与重连、取消文件选择、重复提交、备份密码确认、有界认证日志队列和断网退避。实际执行结果以本次交付记录为准;容器/OpenSSH 场景仍需运行 Docker 验收。 diff --git a/docs/web-deployment.md b/docs/web-deployment.md index 9ae88138..f433b4b7 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -121,7 +121,7 @@ Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/ 刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。 -测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像实构建及浏览器手工验收尚未执行,仍需在部署环境完成。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。 +测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像构建、容器内日志和 OpenSSH 部署验收仍需在具备 Docker 的环境执行。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。 ## Web Beta 能力矩阵与验收 @@ -141,4 +141,53 @@ Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/ SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、服务端关闭均取消恢复。注销从 File 菜单进入。 -本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。本机尚未执行实际 Docker/Chromium,不能以接口 fixture 测试代替部署验收。 +本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。Docker/OpenSSH 验收需要 Docker CLI;本机还可运行下述独立 Chromium 验收,但它不代替容器和反向代理验收。 + +## 密码备份与文件导入 + +File 菜单的导出在浏览器中设置并确认本次备份密码,下载密码保护的 `.nya` 文件;导入选择文件、输入该文件的原备份密码,并确认覆盖实例配置。取消选择不会报错,执行期间不能重复提交。备份密码与 Web 登录密码、部署加密密钥相互独立,仅在本次请求中使用。 + +`.nya` 延续桌面已有格式和旧版解码能力。Web 导出将凭据包装到独立的备份密钥中;Web 导入解开源密钥,把凭据重新加密到目标实例。可以在使用不同部署密钥的 Web 实例之间迁移,不需要复制或替换目标 `NYATERM_WEB_ENCRYPTION_KEY`。桌面导出的备份密码是导出时的桌面主密码;当前桌面导入入口也使用桌面主密码解密文件,因此导入 Web 备份前需将桌面主密码设为该备份密码。 + +导入先校验密码、完整性、数据关系和凭据,再用单个 redb 事务恢复;失败保留原配置。备份中的 Serial、本地 Shell 等桌面配置会保留,Web 能力检查控制使用。导入成功后刷新连接、设置、快捷命令、历史和笔记。运行中的会话仍使用已建立的连接。 + +连接导入支持 Xshell、MobaXterm、SecureCRT、WindTerm、Electerm、NyaTerm JSON 的单文件导出。服务端只解析上传内容,不读取上传内容指定的服务器路径。WindTerm 如果依赖外部配置或私钥,需先在桌面完整导入再迁移 `.nya`;FinalShell 目录、Termius 本机数据库和自动扫描 `~/.ssh/config` 仅桌面支持。Xshell、MobaXterm、SecureCRT 的导出通常不包含密码,导入后会提示补充凭据。主题、快捷命令、关键词规则和私钥的可见文件入口使用浏览器选择与下载。 + +| API | 正文与结果 | 上限 | +| ------------------------------ | --------------------------------------------------------- | ---------------------------- | +| `POST api/backups/export` | JSON `{ "password": "…" }`;返回 `.nya` 二进制 | 输出 50 MiB | +| `POST api/backups/import` | multipart `file`、`password`;成功返回 `status=completed` | 文件 50 MiB,解压 50 MiB | +| `POST api/imports/connections` | multipart `file`、`source`;返回 `imported`、`warnings` | 文件 10 MiB,归档展开 50 MiB | + +上述接口要求当前登录、正确的 Origin 和 CSRF;`source` 只接受 `xshell`、`mobaxterm`、`securecrt`、`windterm`、`electerm`、`nyaterm_json`,不接受服务器文件路径。 + +## 部署日志与诊断 + +服务默认输出 JSON 日志到 stdout,可用 `docker logs --tail 200 nyaterm-web` 或 `docker compose logs --tail 200 nyaterm` 查看。同时写入数据目录的 `logs/` 子目录。日志按 UTC 日期或 10 MiB 轮转,总量不超过 100 MiB;诊断设置中的级别和保留天数保存后立即生效,保留天数为 1–30 天。文件写入失败时继续 stdout 日志并保持业务运行,正常停机刷新日志。 + +Compose 使用 Docker `json-file` 日志驱动,单文件 10 MiB、最多 3 个。单独 `docker run` 时也可添加 `--log-opt max-size=10m --log-opt max-file=3`。Docker stdout 日志与应用数据目录日志分别轮转。 + +每个 API 响应包含 `X-Nyaterm-Request-Id`;JSON 错误也带 `request_id`。前端 invoke 的请求 ID 传入服务端,日志记录路由模板、操作、状态码、耗时和相关会话 ID。异步连接任务延续请求关联。底层错误先记录类别、错误码、固定原因及原始错误指纹,再返回通用错误。终端帧和命令正文不会逐条记录。 + +浏览器未登录时仅保留有界内存队列,登录后批量上传现有 logger、未捕获异常和 Promise rejection。每批最多 50 条、256 KiB,服务端每个登录每分钟最多接受 120 批;断网上报最多重试两次(1 秒、2 秒退避),丢弃数量合并到后续批次,不递归记录上报失败。`POST api/logs/frontend` 和 `GET api/diagnostics/export` 要求登录;POST 同时校验 Origin 与 CSRF。 + +在 Web 设置的诊断区下载 ZIP 包,包含最多 20 MiB 的近期脱敏日志,以及版本、系统、架构和能力清单。任意错误文本和不可信文本以指纹记录;密码、Cookie、CSRF、私钥、命令正文、终端内容不会写入诊断包,数据库和备份也不会收录。Web 隐藏打开本地日志目录入口。本次不提供页面日志查看器。 + +## 本机浏览器验收 + +安装 Chromium 后,以本机编译的 Web 服务和真实 dist 执行登录、主题/窄屏截图、备份往返、诊断下载及会话恢复: + +```sh +pnpm exec playwright install chromium +pnpm build:web +cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked +node scripts/web-local-e2e.mjs +``` + +子路径验收先用 `NYATERM_WEB_BASE_PATH=/nyaterm/` 重新构建前端,再设置 `NYATERM_LOCAL_E2E_BASE_PATH=/nyaterm/` 执行同一脚本。测试使用临时数据目录和随机秘密,退出后清理服务进程。截图保存在 `artifacts/web-local-e2e/`。 + +本轮本机验证(2026-10-04):Core 413 项测试通过;Web Rust 单元及集成测试通过;相关 Vitest、桌面 `cargo check --locked`、Web 根路径/子路径构建、lint、四语言键检查和能力审计通过。lint 保留 `CommandSuggestions.tsx` 原有的一项依赖提示,构建保留原有资源体积提示。 + +真实 Chromium 已分别通过根路径和 `/nyaterm/` 的登录错误、限流/断网/SSE 故障注入、会话恢复、密码备份往返、主题与快捷命令文件往返、诊断下载;已检查四语言、浅深主题及 360px 截图。迁移测试使用真实 Web 进程和独立部署密钥,验证密码、私钥和 OTP 解密、损坏/错误密码/超限拒绝、事务回滚及重启后读取,并检查日志脱敏、请求关联、上报频率和级别即时生效。 + +本机没有 Docker CLI,因此没有执行镜像构建、容器内轮转或 Docker/OpenSSH/反向代理验收;使用 `pnpm test:web:e2e` 在具备 Docker 的环境继续验证。桌面 `.nya` 兼容验证使用共享编解码和原桌面快照流程,未执行桌面 UI 导入验收。 diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs index 4aa46107..a99b8847 100644 --- a/e2e/playwright.config.mjs +++ b/e2e/playwright.config.mjs @@ -2,7 +2,9 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: ".", - testMatch: "deployment.spec.mjs", + testMatch: process.env.NYATERM_E2E_FEATURES_ONLY + ? "web-features.spec.mjs" + : ["deployment.spec.mjs", "web-features.spec.mjs"], timeout: 90_000, expect: { timeout: 15_000 }, workers: 1, diff --git a/e2e/web-features.spec.mjs b/e2e/web-features.spec.mjs new file mode 100644 index 00000000..a6626ed2 --- /dev/null +++ b/e2e/web-features.spec.mjs @@ -0,0 +1,347 @@ +import { test, expect } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; + +const locale = (language) => + JSON.parse( + readFileSync( + new URL(`../src/i18n/locales/${language}.json`, import.meta.url), + "utf8", + ), + ); +const en = locale("en"); +const t = (key, dictionary = en) => + key.split(".").reduce((value, part) => value[part], dictionary); + +test("login layouts in four languages, light/dark themes and narrow screens", async ({ + browser, +}, testInfo) => { + for (const language of ["en", "zh-CN", "zh-TW", "ko"]) { + for (const theme of ["github-light", "github-dark"]) { + const context = await browser.newContext({ + viewport: { width: 360, height: 760 }, + locale: language, + }); + await context.addInitScript( + ({ language, theme }) => { + localStorage.setItem("nyaterm-web-language", language); + localStorage.setItem("df-theme-id", theme); + }, + { language, theme }, + ); + const page = await context.newPage(); + await page.goto(process.env.NYATERM_E2E_URL); + const password = page.getByLabel(t("web.password", locale(language)), { + exact: true, + }); + await expect(password).toBeEnabled(); + await expect(password).toBeFocused(); + await expect( + page.getByRole("heading", { name: t("web.welcome", locale(language)) }), + ).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await expect( + page.locator('img[src$="icons/app/nyaterm.svg"]'), + ).toBeVisible(); + const image = await page.screenshot({ + path: testInfo.outputPath(`login-${language}-${theme}-360px.png`), + }); + await testInfo.attach(`login-${language}-${theme}-360px`, { + body: image, + contentType: "image/png", + }); + await context.close(); + } + } +}); + +test("password errors, keyboard login, SSE retry, portable backup and diagnostic downloads", async ({ + page, +}) => { + if (!process.env.NYATERM_E2E_PASSWORD) + throw new Error("Run scripts/web-local-e2e.mjs or pnpm test:web:e2e"); + const base = process.env.NYATERM_E2E_URL; + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto(base); + const password = page.getByLabel(t("web.password"), { exact: true }); + await expect(password).toBeEnabled(); + await password.fill("incorrect-password"); + await password.press("Enter"); + await expect(page.getByRole("alert")).toHaveText(t("web.invalidPassword")); + await page.getByRole("button", { name: t("web.showPassword") }).click(); + await expect(password).toHaveAttribute("type", "text"); + await page.getByRole("button", { name: t("web.hidePassword") }).click(); + + // Faults are injected only at the browser transport; login and restore use the real server. + await page.route("**/api/auth/login", (route) => + route.fulfill({ + status: 429, + contentType: "application/json", + body: JSON.stringify({ error: "Limited" }), + }), + ); + await password.press("Enter"); + await expect(page.getByRole("alert")).toHaveText(t("web.signInRateLimited")); + await page.unroute("**/api/auth/login"); + await page.route("**/api/auth/login", (route) => + route.abort("internetdisconnected"), + ); + await password.press("Enter"); + await expect(page.getByRole("alert")).toHaveText(t("web.serverUnavailable")); + await page.unroute("**/api/auth/login"); + await page.route("**/api/events", (route) => + route.abort("internetdisconnected"), + ); + let loginCount = 0; + page.on("request", (request) => { + if (request.url().endsWith("/api/auth/login")) loginCount++; + }); + await password.fill(process.env.NYATERM_E2E_PASSWORD); + await password.press("Enter"); + await expect(page.getByRole("alert")).toHaveText( + t("web.eventConnectionFailed"), + ); + await page.unroute("**/api/events"); + await page.getByRole("button", { name: t("web.retryConnection") }).click(); + await expect(password).toHaveCount(0); + expect(loginCount).toBe(1); + await page.reload(); + await expect( + page.getByText(t("menu.file"), { exact: true }).first(), + ).toBeVisible(); + expect(loginCount).toBe(1); + + const auth = await ( + await page.request.get(new URL("api/auth/session", base).href) + ).json(); + const headers = { + Origin: new URL(base).origin, + "X-Nyaterm-Request": "1", + "X-Nyaterm-Csrf": auth.csrf, + }; + const command = async (name, args = {}) => { + const id = randomUUID(); + const response = await page.request.post( + new URL(`api/commands/${name}`, base).href, + { headers: { ...headers, "X-Nyaterm-Request-Id": id }, data: args }, + ); + expect(response.headers()["x-nyaterm-request-id"]).toBe(id); + expect(response.ok(), `${name}: ${await response.text()}`).toBe(true); + return response.json(); + }; + await command("save_password", { + entry: { + id: "web-e2e-password", + name: "Web backup fixture", + password: "portable-secret", + }, + }); + const menu = async (key) => { + await page.getByText(t("menu.file"), { exact: true }).first().click(); + await page.getByText(t(key), { exact: true }).click(); + }; + await menu("settings.exportConfig"); + await page + .getByLabel(t("web.backupPassword"), { exact: true }) + .fill("web-e2e-backup-password"); + await page + .getByLabel(t("web.backupPasswordConfirm"), { exact: true }) + .fill("different"); + await expect( + page.getByRole("button", { name: t("web.backupExportTitle"), exact: true }), + ).toBeDisabled(); + await page + .getByLabel(t("web.backupPasswordConfirm"), { exact: true }) + .fill("web-e2e-backup-password"); + const download = page.waitForEvent("download"); + await page + .getByRole("button", { name: t("web.backupExportTitle"), exact: true }) + .click(); + const backup = await download; + expect(backup.suggestedFilename()).toMatch(/\.nya$/); + const backupBytes = readFileSync(await backup.path()); + expect(backupBytes.includes(Buffer.from("portable-secret"))).toBe(false); + await command("delete_password", { id: "web-e2e-password" }); + await menu("settings.importConfig"); + await page.getByRole("button", { name: /NyaTerm \(\.nya\)/ }).click(); + const chooser = page.waitForEvent("filechooser"); + await page + .getByRole("button", { + name: t("savedConnections.restoreBackupConfirmAction"), + exact: true, + }) + .click(); + await ( + await chooser + ).setFiles({ + name: "fixture.nya", + mimeType: "application/octet-stream", + buffer: backupBytes, + }); + await page + .getByLabel(t("web.backupPassword"), { exact: true }) + .fill("wrong-password"); + await page + .getByLabel(t("web.backupOverwriteConfirm"), { exact: true }) + .check(); + await page + .getByRole("button", { name: t("web.backupImportTitle"), exact: true }) + .click(); + await expect(page.getByRole("alert")).toContainText( + t("web.backupImportFailed"), + ); + await page + .getByLabel(t("web.backupPassword"), { exact: true }) + .fill("web-e2e-backup-password"); + await page + .getByRole("button", { name: t("web.backupImportTitle"), exact: true }) + .click(); + await expect( + page.getByLabel(t("web.backupPassword"), { exact: true }), + ).toHaveCount(0); + expect( + await command("get_saved_password_value", { id: "web-e2e-password" }), + ).toBe("portable-secret"); + + await command("save_quick_commands", { + config: { + commands: [ + { + id: "web-quick-fixture", + label: "Web quick fixture", + command: "printf fixture", + }, + ], + categories: [], + }, + }); + const settings = await command("get_app_settings"); + settings.ui.active_right_panel = "quickCommands"; + settings.ui.right_open_panels = ["quickCommands"]; + await command("save_app_settings", { settings }); + await page.reload(); + await expect( + page.getByText("Web quick fixture", { exact: true }), + ).toBeVisible(); + const quickDownload = page.waitForEvent("download"); + await page + .getByRole("button", { name: t("quickCommands.export"), exact: true }) + .click(); + const quickBytes = readFileSync(await (await quickDownload).path()); + expect( + JSON.parse(quickBytes.toString()).commands.some( + (entry) => entry.id === "web-quick-fixture", + ), + ).toBe(true); + await command("save_quick_commands", { + config: { commands: [], categories: [] }, + }); + await page + .getByRole("button", { name: t("quickCommands.import"), exact: true }) + .click(); + const quickChooser = page.waitForEvent("filechooser"); + await page + .getByRole("button", { + name: new RegExp(t("quickCommands.importNyaTermJson")), + }) + .click(); + await ( + await quickChooser + ).setFiles({ + name: "quick.json", + mimeType: "application/json", + buffer: quickBytes, + }); + await expect + .poll(async () => + (await command("get_quick_commands")).commands.some( + (entry) => entry.id === "web-quick-fixture", + ), + ) + .toBe(true); + + await page + .getByRole("button", { name: t("settings.title"), exact: true }) + .click(); + const child = page.frameLocator('iframe[src*="window=settings"]'); + await child + .getByRole("button", { name: t("settings.general"), exact: true }) + .first() + .click(); + await expect( + child.getByRole("button", { name: t("settings.openLogs"), exact: true }), + ).toHaveCount(0); + const diagnosticDownload = page.waitForEvent("download"); + await child + .getByRole("button", { name: t("settings.exportDiagnostics"), exact: true }) + .click(); + expect( + readFileSync(await (await diagnosticDownload).path()) + .subarray(0, 2) + .toString(), + ).toBe("PK"); + await child + .getByRole("button", { name: t("settings.appearance"), exact: true }) + .first() + .click(); + await child + .getByRole("button", { name: t("settings.themeDesignerOpen"), exact: true }) + .click(); + await child + .getByRole("button", { + name: t("settings.themeDesignerCopyTheme"), + exact: true, + }) + .click(); + const themeDownload = page.waitForEvent("download"); + await child + .getByRole("button", { + name: t("settings.themeDesignerExport"), + exact: true, + }) + .click(); + const themeData = JSON.parse( + readFileSync(await (await themeDownload).path()).toString(), + ); + themeData.name = "Web theme fixture"; + const themeChooser = page.waitForEvent("filechooser"); + await child + .getByRole("button", { + name: t("settings.themeDesignerImport"), + exact: true, + }) + .click(); + await ( + await themeChooser + ).setFiles({ + name: "theme.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(themeData)), + }); + await expect + .poll(async () => + (await command("get_app_settings")).appearance.custom_themes.some( + (theme) => theme.name === "Web theme fixture", + ), + ) + .toBe(true); + await child + .getByRole("dialog") + .getByRole("button", { name: t("common.close"), exact: true }) + .click(); + + await child + .getByRole("button", { name: t("common.confirm"), exact: true }) + .click(); + await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0); + await page.reload(); + await expect( + page.getByText(t("menu.file"), { exact: true }).first(), + ).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/scripts/web-local-e2e.mjs b/scripts/web-local-e2e.mjs new file mode 100644 index 00000000..503e2664 --- /dev/null +++ b/scripts/web-local-e2e.mjs @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createServer } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; + +const listener = createServer(); +await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); +const port = listener.address().port; +await new Promise((resolve) => listener.close(resolve)); +const data = mkdtempSync(join(tmpdir(), "nyaterm-local-e2e-")); +const artifacts = resolve( + "artifacts/web-local-e2e", + process.env.NYATERM_LOCAL_E2E_BASE_PATH ? "subpath" : "root", +); +mkdirSync(artifacts, { recursive: true }); +const basePath = process.env.NYATERM_LOCAL_E2E_BASE_PATH ?? "/"; +const base = `http://127.0.0.1:${port}${basePath}`; +const password = randomBytes(32).toString("base64"); +const env = { + ...process.env, + NYATERM_WEB_PASSWORD: password, + NYATERM_WEB_ENCRYPTION_KEY: randomBytes(32).toString("base64"), + NYATERM_WEB_DATA_DIR: data, + NYATERM_WEB_DIST: resolve("dist"), + NYATERM_WEB_PUBLIC_URL: base, + NYATERM_WEB_BIND: `127.0.0.1:${port}`, +}; +delete env.NYATERM_WEB_PASSWORD_FILE; +delete env.NYATERM_WEB_ENCRYPTION_KEY_FILE; +const exe = resolve( + "src-tauri/crates/nyaterm-web/target/debug/nyaterm-web" + + (process.platform === "win32" ? ".exe" : ""), +); +const server = spawn(exe, [], { + env, + stdio: ["ignore", "ignore", "inherit"], + windowsHide: true, +}); +let serverError; +server.on("error", (error) => { + serverError = error; +}); +try { + let ready = false; + for (let attempt = 0; attempt < 200; attempt++) { + if (serverError) throw serverError; + if (server.exitCode !== null) + throw new Error("Local Web server exited during startup"); + try { + if ((await fetch(base)).ok) { + ready = true; + break; + } + } catch {} + await delay(50); + } + if (!ready) throw new Error("Local Web server was not ready"); + const test = spawn( + process.execPath, + [ + "node_modules/@playwright/test/cli.js", + "test", + "--config", + "e2e/playwright.config.mjs", + ], + { + env: { + ...process.env, + NYATERM_E2E_FEATURES_ONLY: "1", + NYATERM_E2E_URL: base, + NYATERM_E2E_PASSWORD: password, + NYATERM_E2E_ARTIFACTS: artifacts, + }, + stdio: "inherit", + windowsHide: true, + }, + ); + const code = await new Promise((resolve, reject) => { + test.once("error", reject); + test.once("exit", resolve); + }); + if (code !== 0) process.exitCode = 1; +} finally { + if (server.exitCode === null && !serverError) { + const exited = new Promise((resolve) => server.once("exit", resolve)); + server.kill(); + await exited; + } + rmSync(data, { recursive: true, force: true }); +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9d03bcb8..7367c920 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5007,9 +5007,12 @@ dependencies = [ name = "nyaterm-core" version = "0.1.0" dependencies = [ + "aes 0.8.4", "aes-gcm 0.10.3", "async-http-proxy", "base64 0.22.1", + "cbc 0.1.2", + "crypto_secretbox", "dirs", "encoding_rs", "font-kit", @@ -5017,19 +5020,24 @@ dependencies = [ "genai", "hex", "hmac 0.12.1", + "keyring", "md-5 0.10.6", "nucleo-matcher", "nyaterm-gpu", + "pbkdf2 0.12.2", + "quick-xml 0.40.1", "rand 0.8.8", "redb", "regex", "reqwest 0.13.4", "russh", "russh-sftp", + "rusty-leveldb", "serde", "serde_json", "sha1 0.10.7", "sha2 0.10.9", + "sha3 0.10.9", "ssh-key 0.6.7", "strip-ansi-escapes", "thiserror 2.0.20", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7e0e5156..784e9f5d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -86,7 +86,7 @@ nyaterm-core = { path = "crates/nyaterm-core", features = ["test-support"] } tokio = { version = "1", features = ["test-util"] } [dependencies] -nyaterm-core = { path = "crates/nyaterm-core" } +nyaterm-core = { path = "crates/nyaterm-core", features = ["desktop-importer"] } tempfile = "3" nyaterm-gpu = { path = "crates/nyaterm-gpu" } nyaterm-otp = { path = "crates/otp" } diff --git a/src-tauri/crates/nyaterm-core/Cargo.lock b/src-tauri/crates/nyaterm-core/Cargo.lock index e6a8a856..d11bf03b 100644 --- a/src-tauri/crates/nyaterm-core/Cargo.lock +++ b/src-tauri/crates/nyaterm-core/Cargo.lock @@ -110,6 +110,30 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.50" @@ -134,6 +158,99 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "async_io_stream" version = "0.3.3" @@ -199,7 +316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", - "pbkdf2", + "pbkdf2 0.13.0", "sha2 0.11.0", ] @@ -246,6 +363,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "block-padding" version = "0.4.2" @@ -255,6 +381,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.10.0" @@ -313,6 +452,15 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cbc" version = "0.2.1" @@ -381,6 +529,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout 0.1.4", + "zeroize", ] [[package]] @@ -429,6 +578,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -548,6 +706,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -624,6 +791,21 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "crypto_secretbox" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d6cf87adf719ddf43a805e92c6870a531aedda35ff640442cbaf8674e141e1" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "generic-array 0.14.7", + "poly1305 0.8.0", + "salsa20 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "ctr" version = "0.9.2" @@ -757,6 +939,35 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "deflate64" version = "0.1.12" @@ -1095,7 +1306,7 @@ dependencies = [ "digest 0.11.3", "ff 0.14.0", "group 0.14.0", - "hkdf", + "hkdf 0.13.0", "hybrid-array", "pem-rfc7468 1.0.0", "pkcs8 0.11.0", @@ -1119,6 +1330,12 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -1131,12 +1348,44 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + [[package]] name = "errno" version = "0.3.14" @@ -1147,6 +1396,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1312,6 +1591,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.34" @@ -1360,6 +1649,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1570,6 +1872,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1582,6 +1890,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1915,6 +2232,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding 0.3.3", "generic-array 0.14.7", ] @@ -1924,10 +2242,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", + "block-padding 0.4.2", "hybrid-array", ] +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + [[package]] name = "internal-russh-num-bigint" version = "0.5.0" @@ -2027,6 +2351,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.2" @@ -2047,6 +2380,22 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -2068,6 +2417,15 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2151,6 +2509,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2205,7 +2572,7 @@ dependencies = [ "module-lattice", "pkcs8 0.11.0", "rand_core 0.10.1", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -2247,11 +2614,24 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.3" @@ -2284,6 +2664,20 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -2310,6 +2704,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2335,6 +2738,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2349,9 +2763,12 @@ dependencies = [ name = "nyaterm-core" version = "0.1.0" dependencies = [ + "aes 0.8.4", "aes-gcm 0.10.3", "async-http-proxy", "base64 0.22.1", + "cbc 0.1.2", + "crypto_secretbox", "dirs", "encoding_rs", "font-kit", @@ -2359,19 +2776,24 @@ dependencies = [ "genai", "hex", "hmac 0.12.1", + "keyring", "md-5", "nucleo-matcher", "nyaterm-gpu", + "pbkdf2 0.12.2", + "quick-xml", "rand 0.8.8", "redb", "regex", "reqwest", "russh", "russh-sftp", + "rusty-leveldb", "serde", "serde_json", "sha1 0.10.7", "sha2 0.10.9", + "sha3 0.10.9", "ssh-key 0.6.7", "strip-ansi-escapes", "tempfile", @@ -2456,6 +2878,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -2553,6 +2985,12 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -2600,6 +3038,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -2650,6 +3098,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2679,9 +3138,9 @@ checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", - "cbc", + "cbc 0.2.1", "der 0.8.2", - "pbkdf2", + "pbkdf2 0.13.0", "rand_core 0.10.1", "scrypt", "sha2 0.11.0", @@ -2716,6 +3175,31 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "poly1305" version = "0.9.1" @@ -2832,6 +3316,15 @@ dependencies = [ "wnaf", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2847,6 +3340,15 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-xml" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3109,10 +3611,10 @@ version = "0.62.1" dependencies = [ "aes 0.9.3", "bitflags 2.13.2", - "block-padding", + "block-padding 0.4.2", "byteorder", "bytes", - "cbc", + "cbc 0.2.1", "cipher 0.5.2", "crypto-bigint 0.7.5", "ctr 0.10.1", @@ -3135,7 +3637,7 @@ dependencies = [ "hmac 0.13.0", "inout 0.2.2", "internal-russh-num-bigint", - "keccak", + "keccak 0.2.2", "log", "md5", "ml-kem", @@ -3145,7 +3647,7 @@ dependencies = [ "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", "pageant", - "pbkdf2", + "pbkdf2 0.13.0", "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.11.0", @@ -3156,12 +3658,12 @@ dependencies = [ "rsa 0.10.0-rc.18", "russh-cryptovec", "russh-util", - "salsa20", + "salsa20 0.11.0", "scrypt", "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", - "sha3", + "sha3 0.11.0", "signature 3.0.0", "spki 0.8.0", "ssh-encoding 0.3.0", @@ -3179,7 +3681,7 @@ name = "russh-cryptovec" version = "0.62.0" dependencies = [ "log", - "nix", + "nix 0.31.3", "ssh-encoding 0.3.0", "windows-sys 0.61.2", ] @@ -3229,7 +3731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", - "errno", + "errno 0.3.14", "libc", "linux-raw-sys", "windows-sys 0.61.2", @@ -3274,12 +3776,36 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-leveldb" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ddda8f34100fca4bf1b0bbf743d12d27b5d84c35c74a52f392aac26f54e82d" +dependencies = [ + "bytes", + "crc32c", + "errno 0.2.8", + "fs2", + "integer-encoding", + "rand 0.8.8", + "snap", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "salsa20" version = "0.11.0" @@ -3345,8 +3871,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", - "pbkdf2", - "salsa20", + "pbkdf2 0.13.0", + "salsa20 0.11.0", "sha2 0.11.0", ] @@ -3378,6 +3904,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array 0.14.7", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3460,6 +4018,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3559,6 +4128,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -3566,7 +4145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.2", ] [[package]] @@ -3575,6 +4154,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno 0.3.14", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3619,6 +4208,12 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.5" @@ -3678,7 +4273,7 @@ dependencies = [ "cipher 0.5.2", "ctutils", "des", - "poly1305", + "poly1305 0.9.1", "ssh-encoding 0.3.0", "zeroize", ] @@ -3763,6 +4358,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -4053,6 +4654,36 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -4153,6 +4784,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -4543,7 +5185,25 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4561,14 +5221,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4586,48 +5263,105 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wio" version = "0.2.2" @@ -4655,6 +5389,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" @@ -4689,6 +5433,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1 0.10.7", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.59" @@ -4735,6 +5535,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -4786,7 +5600,7 @@ dependencies = [ "indexmap 2.14.2", "lzma-rust2", "memchr", - "pbkdf2", + "pbkdf2 0.13.0", "ppmd-rust", "sha1 0.11.0", "time", @@ -4862,3 +5676,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/src-tauri/crates/nyaterm-core/Cargo.toml b/src-tauri/crates/nyaterm-core/Cargo.toml index 4827f442..a8269731 100644 --- a/src-tauri/crates/nyaterm-core/Cargo.toml +++ b/src-tauri/crates/nyaterm-core/Cargo.toml @@ -6,10 +6,11 @@ rust-version = "1.94" [features] test-support = [] +desktop-importer = ["dep:keyring", "dep:crypto_secretbox", "dep:rusty-leveldb"] [dependencies] serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } thiserror = "2" redb = "4.1.0" aes-gcm = "0.10" @@ -46,6 +47,16 @@ vnc-rs = { path = "../../vendor/vnc-rs" } tokio-socks = "0.5.2" async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] } + +aes = "0.8" +cbc = "0.1" +pbkdf2 = "0.12" +sha3 = "0.10" +quick-xml = "0.40" +crypto_secretbox = { version = "0.1", optional = true } +rusty-leveldb = { version = "4.0", optional = true } +keyring = { version = "3.6", optional = true, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] } + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/crates/nyaterm-core/src/core/backup.rs b/src-tauri/crates/nyaterm-core/src/core/backup.rs new file mode 100644 index 00000000..28165313 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/backup.rs @@ -0,0 +1,140 @@ +//! Password-portable backups. Runtime key material is never replaced during an operation. +use super::{backup_crypto, portable_snapshot::*}; +use crate::{ + config, + error::{AppError, AppResult}, + storage, + utils::crypto, +}; +use aes_gcm::{Aes256Gcm, Key, KeyInit, aead::OsRng}; + +pub const MAX_BACKUP_BYTES: usize = 50 * 1024 * 1024; + +pub fn build_backup(app_version: &str) -> AppResult { + let mut settings = config::load_app_settings(&())?; + // Loading can retain undecipherable settings for recovery; exports must reject them. + settings.ai = config::decrypt_ai_settings(settings.ai)?; + let mut snapshot = PortableSnapshot { + schema_version: PORTABLE_SNAPSHOT_SCHEMA_VERSION, + snapshot_kind: PortableSnapshotKind::Backup, + revision_id: uuid::Uuid::new_v4().to_string(), + device_id: config::load_cloud_sync_state(&())?.device_id, + created_at_ms: current_time_ms(), + payload_hash: String::new(), + app_version: app_version.into(), + settings: PortableAppSettings::from_app_settings(&settings, &PortableSnapshotKind::Backup), + sessions: config::load_sessions(&())?, + keys: config::load_keys(&())?, + passwords: config::load_passwords(&())?, + credentials: config::load_credentials(&())?, + otp: config::load_otp_entries(&())?, + proxies: config::load_proxies(&())?, + proxy_groups: config::load_proxy_groups(&())?, + tunnels: config::load_tunnels(&())?, + tunnel_groups: config::load_tunnel_groups(&())?, + quick_commands: config::load_quick_commands(&())?, + history: storage::list_command_history_entries(usize::MAX)?, + master_key_token: storage::load_master_key_token()?, + known_hosts: storage::render_known_hosts_export()?, + notes: storage::load_notes_snapshot()?, + }; + snapshot.payload_hash = calculate_payload_hash(&snapshot)?; + Ok(snapshot) +} + +fn transcode( + snapshot: &mut PortableSnapshot, + source: &Key, + target: &Key, +) -> AppResult<()> { + let convert = |value: &mut Option| -> AppResult<()> { + if let Some(token) = value { + *token = crypto::transcode_secret(token, source, target)?; + } + Ok(()) + }; + for connection in &mut snapshot.sessions.connections { + if let Some(auth) = &mut connection.auth { + convert(&mut auth.password)?; + } + } + for key in &mut snapshot.keys.keys { + convert(&mut key.key)?; + convert(&mut key.cert)?; + convert(&mut key.passphrase)?; + } + for account in &mut snapshot.passwords.passwords { + convert(&mut account.password)?; + } + for credential in &mut snapshot.credentials.credentials { + convert(&mut credential.password)?; + } + for otp in &mut snapshot.otp.entries { + convert(&mut otp.secret)?; + } + for proxy in &mut snapshot.proxies { + convert(&mut proxy.password)?; + } + Ok(()) +} + +pub fn export_backup(password: &str, app_version: &str) -> AppResult> { + validate_password(password)?; + let mut snapshot = build_backup(app_version)?; + let portable_key = Aes256Gcm::generate_key(OsRng); + transcode(&mut snapshot, &crypto::get_master_key()?, &portable_key)?; + snapshot.master_key_token = Some(crypto::backup_key_token(&portable_key, password)?); + snapshot.payload_hash = calculate_payload_hash(&snapshot)?; + let encoded = zeroize::Zeroizing::new(encode_portable_snapshot(&snapshot)?); + let encrypted = backup_crypto::encrypt_snapshot_bytes(&encoded, password)?; + if encrypted.len() > MAX_BACKUP_BYTES { + return Err(AppError::Config("Backup exceeds 50 MiB".into())); + } + Ok(encrypted) +} + +/// Decode and re-encrypt everything before acquiring a database write transaction. +pub fn prepare_import(bytes: &[u8], password: &str) -> AppResult { + validate_password(password)?; + if bytes.len() > MAX_BACKUP_BYTES { + return Err(AppError::Config("Backup exceeds 50 MiB".into())); + } + let decoded = zeroize::Zeroizing::new(backup_crypto::decrypt_snapshot_bytes(bytes, password)?); + let mut snapshot = decode_portable_snapshot(&decoded)?; + if snapshot.snapshot_kind != PortableSnapshotKind::Backup { + return Err(AppError::Config("Expected a configuration backup".into())); + } + storage::backup::validate_backup_data(&snapshot)?; + let source = crypto::backup_key( + snapshot + .master_key_token + .as_deref() + .ok_or_else(|| AppError::Crypto("Backup key missing".into()))?, + password, + )?; + transcode(&mut snapshot, &source, &crypto::get_master_key()?)?; + // Keep the destination key token and master password. AI fields in portable settings are plaintext. + snapshot.master_key_token = storage::load_master_key_token()?; + snapshot.payload_hash = calculate_payload_hash(&snapshot)?; + Ok(snapshot) +} + +/// Merge target-only settings immediately before the transaction, under the instance lock. +pub fn settings_for_import(snapshot: &PortableSnapshot) -> AppResult { + let mut settings = snapshot.settings.clone().apply_to( + config::load_app_settings(&())?, + &PortableSnapshotKind::Backup, + ); + settings.ai = config::encrypt_ai_settings(settings.ai)?; + settings.cloud_sync = config::encrypt_cloud_sync_settings(settings.cloud_sync)?; + Ok(settings) +} + +fn validate_password(password: &str) -> AppResult<()> { + if password.is_empty() || password.len() > 1024 { + return Err(AppError::Config( + "Backup password must contain 1–1024 bytes".into(), + )); + } + Ok(()) +} diff --git a/src-tauri/crates/nyaterm-core/src/core/backup_crypto.rs b/src-tauri/crates/nyaterm-core/src/core/backup_crypto.rs new file mode 100644 index 00000000..81808e75 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/backup_crypto.rs @@ -0,0 +1,67 @@ +use crate::error::{AppError, AppResult}; + +use aes_gcm::aead::{Aead, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; +use sha2::{Digest, Sha256}; + +const CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"nyaterm-cloud-snapshot-v1:"; +const LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"dragonfly-cloud-snapshot-v1:"; + +fn derive_snapshot_key_with_prefix(prefix: &[u8], master_password: &str) -> Key { + let mut hasher = Sha256::new(); + hasher.update(prefix); + hasher.update(master_password.as_bytes()); + let digest = hasher.finalize(); + *Key::::from_slice(&digest) +} + +fn derive_snapshot_key(master_password: &str) -> Key { + derive_snapshot_key_with_prefix(CLOUD_SNAPSHOT_KEY_PREFIX, master_password) +} + +fn derive_legacy_snapshot_key(master_password: &str) -> Key { + derive_snapshot_key_with_prefix(LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX, master_password) +} + +pub fn encrypt_snapshot_bytes(plaintext: &[u8], master_password: &str) -> AppResult> { + let key = derive_snapshot_key(&master_password); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|error| AppError::Crypto(format!("cloud snapshot encryption failed: {error}")))?; + + let mut combined = nonce.to_vec(); + combined.extend_from_slice(&ciphertext); + Ok(combined) +} + +pub fn decrypt_snapshot_bytes(ciphertext: &[u8], master_password: &str) -> AppResult> { + if ciphertext.len() < 13 { + return Err(AppError::Crypto( + "cloud snapshot ciphertext is too short".to_string(), + )); + } + + match decrypt_snapshot_bytes_with_key(ciphertext, &derive_snapshot_key(&master_password)) { + Ok(plaintext) => Ok(plaintext), + Err(new_error) => decrypt_snapshot_bytes_with_key( + ciphertext, + &derive_legacy_snapshot_key(&master_password), + ) + .map_err(|legacy_error| { + AppError::Crypto(format!( + "cloud snapshot decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" + )) + }), + } +} + +fn decrypt_snapshot_bytes_with_key(ciphertext: &[u8], key: &Key) -> AppResult> { + let cipher = Aes256Gcm::new(key); + let (nonce_bytes, payload) = ciphertext.split_at(12); + let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); + cipher + .decrypt(nonce, payload) + .map_err(|error| AppError::Crypto(format!("cloud snapshot decryption failed: {error}"))) +} diff --git a/src-tauri/src/core/importer/common.rs b/src-tauri/crates/nyaterm-core/src/core/importer/common.rs similarity index 100% rename from src-tauri/src/core/importer/common.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/common.rs diff --git a/src-tauri/src/core/importer/electerm.rs b/src-tauri/crates/nyaterm-core/src/core/importer/electerm.rs similarity index 100% rename from src-tauri/src/core/importer/electerm.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/electerm.rs diff --git a/src-tauri/src/core/importer/finalshell.rs b/src-tauri/crates/nyaterm-core/src/core/importer/finalshell.rs similarity index 100% rename from src-tauri/src/core/importer/finalshell.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/finalshell.rs diff --git a/src-tauri/src/core/importer/merge.rs b/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs similarity index 92% rename from src-tauri/src/core/importer/merge.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/merge.rs index fa060ab8..8c2281ad 100644 --- a/src-tauri/src/core/importer/merge.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/merge.rs @@ -1,7 +1,11 @@ fn build_group_path(groups: &[Group], id: &str) -> Vec { let mut segments = Vec::new(); let mut current = id; + let mut visited = std::collections::HashSet::new(); loop { + if !visited.insert(current) { + break; + } if let Some(g) = groups.iter().find(|g| g.id == current) { segments.push(g.name.clone()); if let Some(ref pid) = g.parent_id { @@ -65,10 +69,7 @@ fn ensure_group_path( Some(leaf_id) } -fn import_legacy_sessions( - app: &tauri::AppHandle, - imported: Vec, -) -> AppResult { +fn import_legacy_sessions(app: &impl Sized, imported: Vec) -> AppResult { if imported.is_empty() { return Ok(0); } @@ -134,7 +135,7 @@ fn import_legacy_sessions( } fn import_prepared_nyaterm_json( - app: &tauri::AppHandle, + app: &impl Sized, prepared: PreparedJsonImport, ) -> AppResult { if prepared.connections.is_empty() { @@ -182,13 +183,18 @@ fn import_prepared_nyaterm_json( let mut passwords = config::load_passwords(app)?; passwords.passwords.extend(prepared.passwords); - config::save_passwords(app, &passwords)?; let mut keys = config::load_keys(app)?; keys.keys.extend(prepared.ssh_keys); - config::save_keys(app, &keys)?; - - config::save_config(app, &cfg)?; + crate::storage::backup::import_connections( + &config::SessionsConfig { + connections: cfg.connections, + groups: cfg.groups, + custom_icons: cfg.custom_icons, + }, + &passwords, + &keys, + )?; Ok(count) } @@ -216,9 +222,9 @@ fn parse_json_import_content(content: &str) -> AppResult { } fn is_electerm_bookmarks_json(value: &serde_json::Value) -> bool { - value - .as_object() - .is_some_and(|object| object.contains_key("bookmarkGroups") && object.contains_key("bookmarks")) + value.as_object().is_some_and(|object| { + object.contains_key("bookmarkGroups") && object.contains_key("bookmarks") + }) } fn is_nyaterm_json_import(value: &serde_json::Value) -> bool { @@ -234,16 +240,13 @@ fn is_nyaterm_json_import(value: &serde_json::Value) -> bool { // ── Tauri Command ─────────────────────────────────────────────────────────── pub fn import_sessions( - app: tauri::AppHandle, + app: &impl Sized, file_path: String, windterm_master_password: Option, ) -> AppResult { let path = Path::new(&file_path); if path.is_dir() { let count = import_legacy_sessions(&app, parse_finalshell(&file_path)?)?; - if count > 0 { - let _ = app.emit("connections-changed", ()); - } return Ok(count); } @@ -268,19 +271,14 @@ pub fn import_sessions( )); }; - if count > 0 { - let _ = app.emit("connections-changed", ()); - } Ok(count) } +#[cfg(feature = "desktop-importer")] pub fn import_termius_sessions( - app: tauri::AppHandle, + app: &impl Sized, indexed_db_path: Option, ) -> AppResult { let count = import_prepared_nyaterm_json(&app, parse_termius_indexed_db(indexed_db_path)?)?; - if count > 0 { - let _ = app.emit("connections-changed", ()); - } Ok(count) } diff --git a/src-tauri/src/core/importer/mobaxterm.rs b/src-tauri/crates/nyaterm-core/src/core/importer/mobaxterm.rs similarity index 94% rename from src-tauri/src/core/importer/mobaxterm.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/mobaxterm.rs index 678452eb..b6e4de14 100644 --- a/src-tauri/src/core/importer/mobaxterm.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/mobaxterm.rs @@ -1,7 +1,11 @@ fn parse_mobaxterm(path: &str) -> AppResult> { let raw = std::fs::read(path).map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?; - let content = decode_bytes(&raw); + parse_mobaxterm_bytes(&raw) +} + +fn parse_mobaxterm_bytes(raw: &[u8]) -> AppResult> { + let content = decode_bytes(raw); let sections = parse_ini_sections(&content); let mut sessions = Vec::new(); diff --git a/src-tauri/crates/nyaterm-core/src/core/importer/mod.rs b/src-tauri/crates/nyaterm-core/src/core/importer/mod.rs new file mode 100644 index 00000000..c6c38bcb --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/importer/mod.rs @@ -0,0 +1,110 @@ +//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), WindTerm (.sessions), +//! SecureCRT (.xml), FinalShell conn directories, NyaTerm JSON files, and Electerm bookmarks. + +use crate::config::{ + self, AiExecutionProfile, ConnectionAuth, ConnectionType, Group, SavedConnection, +}; +use crate::error::{AppError, AppResult}; +#[cfg(not(test))] +use crate::utils::crypto; +use serde::Deserialize; +use std::collections::HashMap; +use std::io::Read; +use std::path::Path; + +#[cfg(not(test))] +fn encrypt_import_secret(plaintext: &str) -> AppResult { + crypto::encrypt(plaintext) +} + +#[cfg(test)] +fn encrypt_import_secret(plaintext: &str) -> AppResult { + Ok(format!("test-encrypted:{plaintext}")) +} + +include!("types.rs"); +include!("text.rs"); +include!("common.rs"); +include!("xshell.rs"); +include!("mobaxterm.rs"); +include!("windterm.rs"); +include!("securecrt.rs"); +include!("finalshell.rs"); +include!("nyaterm_json.rs"); +include!("electerm.rs"); +#[cfg(feature = "desktop-importer")] +include!("termius.rs"); +include!("merge.rs"); +include!("tests.rs"); + +#[derive(serde::Serialize)] +pub struct BrowserImportResult { + pub imported: usize, + pub warnings: Vec, +} + +/// Content-only import: never resolves paths from uploaded files on the server. +pub fn import_browser_connections(source: &str, bytes: &[u8]) -> AppResult { + if bytes.len() > 10 * 1024 * 1024 { + return Err(AppError::Config("Import exceeds 10 MiB".into())); + } + let content = decode_bytes(bytes); + let mut warnings = Vec::new(); + let imported = match source { + "xshell" => { + warnings.push("credentials_not_in_export".into()); + import_legacy_sessions(&(), parse_xshell_reader(std::io::Cursor::new(bytes))?)? + } + "mobaxterm" => { + warnings.push("credentials_not_in_export".into()); + import_legacy_sessions(&(), parse_mobaxterm_bytes(bytes)?)? + } + "securecrt" => { + warnings.push("credentials_not_in_export".into()); + import_legacy_sessions(&(), parse_securecrt_content(&content)?)? + } + "windterm" => { + let entries: Vec = serde_json::from_str(&content)?; + // Encrypted auto-login and external key files require Desktop's profile resources. + for entry in &entries { + let login = entry.get("session.autoLogin"); + let has_encrypted = login.and_then(|v| v.as_str()).is_some_and(|v| { + !v.trim().is_empty() && serde_json::from_str::(v).is_err() + }); + let parsed_login = login + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()); + let login_value = parsed_login.as_ref().or(login); + let has_key = entry.as_object().is_some_and(|fields| { + fields.iter().any(|(key, value)| { + key.starts_with("ssh.identityFilePath") + && value.as_str().is_some_and(|path| !path.trim().is_empty()) + }) + }) || login_value + .and_then(|v| v.get("Public Key")) + .and_then(|v| v.as_object()) + .is_some_and(|fields| { + fields.iter().any(|(key, value)| { + key.ends_with(".path") + && value.as_str().is_some_and(|path| !path.trim().is_empty()) + }) + }); + if has_encrypted || has_key { + return Err(AppError::Config("WindTerm profile resources required; import on Desktop and transfer a .nya backup".into())); + } + } + import_prepared_nyaterm_json( + &(), + parse_windterm_content_impl(&content, None, None, false)?, + )? + } + "electerm" => import_prepared_nyaterm_json(&(), parse_electerm_json_content(&content)?)?, + "nyaterm_json" => import_prepared_nyaterm_json(&(), parse_nyaterm_json_content(&content)?)?, + _ => { + return Err(AppError::Config( + "This import source requires Desktop".into(), + )); + } + }; + Ok(BrowserImportResult { imported, warnings }) +} diff --git a/src-tauri/src/core/importer/nyaterm_json.rs b/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs similarity index 99% rename from src-tauri/src/core/importer/nyaterm_json.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs index cce54e70..5e7a3ff9 100644 --- a/src-tauri/src/core/importer/nyaterm_json.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/nyaterm_json.rs @@ -361,10 +361,7 @@ fn prepare_json_ssh_auth( }) } "agent" => { - if auth.password.is_some() - || auth.password_ref.is_some() - || auth.key_ref.is_some() - { + if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() { return Err(AppError::Config(format!( "{context}: agent auth cannot include password, password_ref, or key_ref" ))); diff --git a/src-tauri/src/core/importer/securecrt.rs b/src-tauri/crates/nyaterm-core/src/core/importer/securecrt.rs similarity index 98% rename from src-tauri/src/core/importer/securecrt.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/securecrt.rs index 644548a8..cb849123 100644 --- a/src-tauri/src/core/importer/securecrt.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/securecrt.rs @@ -1,6 +1,6 @@ -use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; use quick_xml::XmlVersion; +use quick_xml::events::{BytesStart, Event}; #[derive(Default)] struct SecureCrtKeyFrame { @@ -105,7 +105,9 @@ fn securecrt_session_from_frame( frame: &SecureCrtKeyFrame, ancestors: &[SecureCrtKeyFrame], ) -> Option { - let sessions_index = ancestors.iter().position(|frame| frame.name == "Sessions")?; + let sessions_index = ancestors + .iter() + .position(|frame| frame.name == "Sessions")?; let protocol = frame .fields .get("Protocol Name") diff --git a/src-tauri/src/core/importer/termius.rs b/src-tauri/crates/nyaterm-core/src/core/importer/termius.rs similarity index 95% rename from src-tauri/src/core/importer/termius.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/termius.rs index 468a9777..11ab02c7 100644 --- a/src-tauri/src/core/importer/termius.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/termius.rs @@ -196,10 +196,7 @@ fn load_termius_local_key() -> AppResult> { } } - let candidates = [ - ("Termius/localKey", "localKey"), - ("Termius", "localKey"), - ]; + let candidates = [("Termius/localKey", "localKey"), ("Termius", "localKey")]; for (service, user) in candidates { match keyring::Entry::new(service, user).and_then(|entry| entry.get_secret()) { @@ -297,10 +294,8 @@ fn normalize_termius_local_key_candidates( } fn read_leveldb_records(db_path: &Path) -> AppResult> { - let tmp_path = std::env::temp_dir().join(format!( - "nyaterm-termius-leveldb-{}", - uuid::Uuid::new_v4() - )); + let tmp_path = + std::env::temp_dir().join(format!("nyaterm-termius-leveldb-{}", uuid::Uuid::new_v4())); copy_leveldb_dir(db_path, &tmp_path)?; let result = read_copied_leveldb_records(&tmp_path); @@ -516,7 +511,12 @@ fn is_record_marker(strings: &[TermiusTaggedValue], index: usize) -> bool { } fn collect_host_record(strings: &[TermiusTaggedValue], index: usize) -> Option { - let record = collect_fields(strings, index, &["address", "label", "username", "password"], 80); + let record = collect_fields( + strings, + index, + &["address", "label", "username", "password"], + 80, + ); let address = record.get("address").cloned(); let label = record.get("label").cloned(); if address.as_ref().is_none_or(|value| value.trim().is_empty()) @@ -562,7 +562,10 @@ fn collect_identity_record( index: usize, ) -> Option { let record = collect_fields(strings, index, &["label", "username", "password"], 80); - if !record.contains_key("username") && !record.contains_key("password") && !record.contains_key("ssh_key") { + if !record.contains_key("username") + && !record.contains_key("password") + && !record.contains_key("ssh_key") + { return None; } @@ -631,7 +634,9 @@ fn collect_fields( .as_str() .is_some_and(|text| is_termius_field_name(text) || is_record_type_name(text)); if !is_nested_name { - fields.entry(key.to_string()).or_insert(value.to_field_value()); + fields + .entry(key.to_string()) + .or_insert(value.to_field_value()); } } cursor += 1; @@ -675,8 +680,7 @@ fn is_record_type_name(value: &str) -> bool { fn is_termius_field_name(value: &str) -> bool { matches!( value, - "id" - | "local_id" + "id" | "local_id" | "updated_at" | "label" | "address" @@ -722,17 +726,11 @@ fn record_id(strings: &[TermiusTaggedValue], index: usize) -> String { uuid::Uuid::new_v4().to_string() } -fn nested_record_id( - strings: &[TermiusTaggedValue], - index: usize, - field: &str, -) -> Option { +fn nested_record_id(strings: &[TermiusTaggedValue], index: usize, field: &str) -> Option { let start = record_start(strings, index); let end = record_end(strings, index, 80); for cursor in start..end.saturating_sub(2) { - if strings[cursor].as_str() == Some(field) - && strings[cursor + 1].as_str() == Some("id") - { + if strings[cursor].as_str() == Some(field) && strings[cursor + 1].as_str() == Some("id") { let value = strings[cursor + 2].to_field_value(); let trimmed = value.trim(); if !trimmed.is_empty() { @@ -815,7 +813,9 @@ fn dedupe_latest_ssh_configs(configs: Vec) -> Vec) -> Vec { - dedupe_latest(identities, termius_identity_key, |item| item.updated_at.as_deref()) + dedupe_latest(identities, termius_identity_key, |item| { + item.updated_at.as_deref() + }) } fn dedupe_latest_ssh_keys(keys: Vec) -> Vec { @@ -884,8 +884,10 @@ fn termius_group_key(group: &TermiusRawGroup) -> String { } fn build_termius_group_paths(groups: &[TermiusRawGroup]) -> HashMap> { - let by_key: HashMap = - groups.iter().map(|group| (termius_group_key(group), group)).collect(); + let by_key: HashMap = groups + .iter() + .map(|group| (termius_group_key(group), group)) + .collect(); let mut paths = HashMap::new(); for group in groups { let key = termius_group_key(group); @@ -907,12 +909,12 @@ fn build_termius_group_path( } let group = by_key.get(key)?; let label = normalize_optional_string(group.label.clone())?; - let mut path = if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty()) - { - build_termius_group_path(parent, by_key, visited).unwrap_or_default() - } else { - Vec::new() - }; + let mut path = + if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty()) { + build_termius_group_path(parent, by_key, visited).unwrap_or_default() + } else { + Vec::new() + }; path.push(label); Some(path) } @@ -994,7 +996,10 @@ fn prepare_termius_passwords( for identity in identities { if let Some(password) = normalize_optional_string(identity.password.clone()) { let id = uuid::Uuid::new_v4().to_string(); - ids.insert(format!("identity:{}", termius_identity_key(identity)), id.clone()); + ids.insert( + format!("identity:{}", termius_identity_key(identity)), + id.clone(), + ); for alias in termius_record_aliases(&identity.id, identity.local_id.as_deref()) { ids.insert(format!("identity:{alias}"), id.clone()); } @@ -1052,8 +1057,7 @@ fn prepare_termius_connections( .identity_id .as_deref() .or_else(|| ssh_config.and_then(|config| config.identity_id.as_deref())); - let identity = identity_id - .and_then(|id| identities_by_key.get(id).copied()); + let identity = identity_id.and_then(|id| identities_by_key.get(id).copied()); let username = normalize_optional_string(host.username.clone()) .or_else(|| identity.and_then(|item| normalize_optional_string(item.username.clone()))) .unwrap_or_else(|| "root".to_string()); @@ -1147,7 +1151,8 @@ fn prepare_termius_auth( } let host_password_key = format!("host:{}", termius_host_key(host)); - let identity_password_key = identity.map(|item| format!("identity:{}", termius_identity_key(item))); + let identity_password_key = + identity.map(|item| format!("identity:{}", termius_identity_key(item))); let password_id = password_ids .get(&host_password_key) .cloned() diff --git a/src-tauri/src/core/importer/tests.rs b/src-tauri/crates/nyaterm-core/src/core/importer/tests.rs similarity index 97% rename from src-tauri/src/core/importer/tests.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/tests.rs index 02f6d692..a4e08c9c 100644 --- a/src-tauri/src/core/importer/tests.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/tests.rs @@ -1,4 +1,3 @@ - #[cfg(test)] mod tests { use super::*; @@ -343,7 +342,10 @@ mod tests { ConnectionType::Ssh { host, username, .. } if host == "bad.example.com" && username == "fallback" )); - assert_eq!(prepared.connections[0].auth.as_ref().expect("auth").mode, "none"); + assert_eq!( + prepared.connections[0].auth.as_ref().expect("auth").mode, + "none" + ); assert!(matches!( &prepared.connections[1].config, ConnectionType::Ssh { host, username, .. } @@ -646,10 +648,9 @@ mod tests { let mut buffer = vec![0_u8; plaintext.len() + WINDTERM_AES_IV_LENGTH]; buffer[..plaintext.len()].copy_from_slice(plaintext.as_bytes()); - let ciphertext = - cbc::Encryptor::::new(&crypto.key.into(), &crypto.iv.into()) - .encrypt_padded_mut::(&mut buffer, plaintext.len()) - .expect("encrypt windterm payload"); + let ciphertext = cbc::Encryptor::::new(&crypto.key.into(), &crypto.iv.into()) + .encrypt_padded_mut::(&mut buffer, plaintext.len()) + .expect("encrypt windterm payload"); base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext) } @@ -854,6 +855,7 @@ mod tests { ); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_normalizes_local_key_variants() { use base64::Engine as _; @@ -869,10 +871,7 @@ mod tests { let key = normalize_termius_local_key_bytes(raw.as_bytes()).expect("raw bytes key"); assert_eq!(key.as_ref(), raw.as_bytes()); - let utf16le: Vec = raw - .encode_utf16() - .flat_map(u16::to_le_bytes) - .collect(); + let utf16le: Vec = raw.encode_utf16().flat_map(u16::to_le_bytes).collect(); let key = normalize_termius_local_key_bytes(&utf16le).expect("utf16 key"); assert_eq!(key.as_ref(), raw.as_bytes()); @@ -881,6 +880,7 @@ mod tests { assert_eq!(key.as_ref(), raw.as_bytes()); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_decrypts_secretbox_values_and_rejects_corruption() { let key = *b"12345678901234567890123456789012"; @@ -896,6 +896,7 @@ mod tests { assert!(error.to_string().contains("Cannot decrypt Termius")); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_tagged_string_parser_handles_multibyte_lengths() { let long = "x".repeat(300); @@ -907,6 +908,7 @@ mod tests { assert_eq!(strings, vec!["private_key".to_string(), long]); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_maps_host_ports_from_linked_ssh_configs() { let mut bytes = Vec::new(); @@ -1031,7 +1033,10 @@ mod tests { let prepared = parse_json_import_content(json).expect("parse electerm"); - assert_eq!(prepared.groups, vec![vec!["Prod".to_string(), "Web".to_string()]]); + assert_eq!( + prepared.groups, + vec![vec!["Prod".to_string(), "Web".to_string()]] + ); assert_eq!( prepared.connections[0].group_path, Some(vec!["Prod".to_string(), "Web".to_string()]) @@ -1088,6 +1093,7 @@ mod tests { ); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_maps_hosts_groups_passwords_and_keys() { crate::utils::crypto::set_master_password(None); @@ -1166,17 +1172,28 @@ mod tests { Some(vec!["Production".to_string()]) ); - let password_auth = prepared.connections[1].auth.as_ref().expect("password auth"); + let password_auth = prepared.connections[1] + .auth + .as_ref() + .expect("password auth"); assert_eq!(password_auth.mode, "password"); assert!(password_auth.account_id.is_some()); assert!(password_auth.password_id.is_none()); - assert!(prepared.passwords.iter().any(|entry| entry.username == "root")); - assert!(prepared - .passwords - .iter() - .any(|entry| entry.username == "deploy")); + assert!( + prepared + .passwords + .iter() + .any(|entry| entry.username == "root") + ); + assert!( + prepared + .passwords + .iter() + .any(|entry| entry.username == "deploy") + ); } + #[cfg(feature = "desktop-importer")] #[test] fn termius_identity_password_keeps_host_username() { crate::utils::crypto::set_master_password(None); diff --git a/src-tauri/src/core/importer/text.rs b/src-tauri/crates/nyaterm-core/src/core/importer/text.rs similarity index 100% rename from src-tauri/src/core/importer/text.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/text.rs diff --git a/src-tauri/src/core/importer/types.rs b/src-tauri/crates/nyaterm-core/src/core/importer/types.rs similarity index 99% rename from src-tauri/src/core/importer/types.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/types.rs index dd4788ae..91897bcd 100644 --- a/src-tauri/src/core/importer/types.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/types.rs @@ -209,4 +209,3 @@ fn default_serial_stop_bits() -> String { fn default_serial_backspace_mode() -> String { "ctrl_h".to_string() } - diff --git a/src-tauri/src/core/importer/windterm.rs b/src-tauri/crates/nyaterm-core/src/core/importer/windterm.rs similarity index 91% rename from src-tauri/src/core/importer/windterm.rs rename to src-tauri/crates/nyaterm-core/src/core/importer/windterm.rs index 1b0913ff..3e8223ee 100644 --- a/src-tauri/src/core/importer/windterm.rs +++ b/src-tauri/crates/nyaterm-core/src/core/importer/windterm.rs @@ -34,6 +34,15 @@ fn parse_windterm_content_with_crypto( content: &str, crypto: Option<&WindtermCrypto>, source_path: Option<&Path>, +) -> AppResult { + parse_windterm_content_impl(content, crypto, source_path, true) +} + +fn parse_windterm_content_impl( + content: &str, + crypto: Option<&WindtermCrypto>, + source_path: Option<&Path>, + allow_local_files: bool, ) -> AppResult { let entries: Vec = serde_json::from_str(&content) .map_err(|e| AppError::Config(format!("Invalid WindTerm JSON: {e}")))?; @@ -118,6 +127,7 @@ fn parse_windterm_content_with_crypto( .and_then(|value| value.as_bool()) .unwrap_or(false); let auth = prepare_windterm_auth( + allow_local_files, entry, auto_login.as_ref(), source_path, @@ -252,17 +262,15 @@ fn parse_windterm_auto_login( let plaintext = String::from_utf8(plaintext).map_err(|_| { AppError::Config("Failed to decode decrypted WindTerm autoLogin text".to_string()) })?; - let value: serde_json::Value = serde_json::from_str(&plaintext) - .map_err(|_| AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string()))?; + let value: serde_json::Value = serde_json::from_str(&plaintext).map_err(|_| { + AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string()) + })?; value.as_object().cloned().map(Some).ok_or_else(|| { AppError::Config("WindTerm session.autoLogin must decode to a JSON object".to_string()) }) } -fn decrypt_windterm_auto_login( - ciphertext: &[u8], - crypto: &WindtermCrypto, -) -> AppResult> { +fn decrypt_windterm_auto_login(ciphertext: &[u8], crypto: &WindtermCrypto) -> AppResult> { if ciphertext.is_empty() || ciphertext.len() % WINDTERM_AES_IV_LENGTH != 0 { return Err(AppError::Config( "Invalid WindTerm autoLogin ciphertext length".to_string(), @@ -294,6 +302,7 @@ fn parse_windterm_group_path(entry: &serde_json::Value) -> Option> { } fn prepare_windterm_auth( + allow_local_files: bool, entry: &serde_json::Value, auto_login: Option<&serde_json::Map>, source_path: Option<&Path>, @@ -328,7 +337,16 @@ fn prepare_windterm_auth( }); } - if let Some(key_id) = import_windterm_key(entry, auto_login, source_path, session_name, ssh_keys, key_ids)? { + if allow_local_files + && let Some(key_id) = import_windterm_key( + entry, + auto_login, + source_path, + session_name, + ssh_keys, + key_ids, + )? + { return Ok(ConnectionAuth { mode: "key".to_string(), account_id: None, @@ -386,17 +404,15 @@ fn import_windterm_key( return Ok(None); } - let Some((key_path, resolved_path, key_content)) = - key_paths.into_iter().find_map(|path| { - let resolved = resolve_windterm_key_path(path, source_path)?; - let content = std::fs::read_to_string(&resolved).ok()?; - if content.trim().is_empty() { - None - } else { - Some((path, resolved, content)) - } - }) - else { + let Some((key_path, resolved_path, key_content)) = key_paths.into_iter().find_map(|path| { + let resolved = resolve_windterm_key_path(path, source_path)?; + let content = std::fs::read_to_string(&resolved).ok()?; + if content.trim().is_empty() { + None + } else { + Some((path, resolved, content)) + } + }) else { return Ok(None); }; @@ -412,6 +428,13 @@ fn import_windterm_key( if let Some(id) = key_ids.get(&dedupe_key) { return Ok(Some(id.clone())); } + if passphrase.is_none() + && let Some((_, id)) = key_ids + .iter() + .find(|((path, _), _)| path == &normalized_path) + { + return Ok(Some(id.clone())); + } let id = uuid::Uuid::new_v4().to_string(); let display_name = Path::new(key_path) @@ -457,7 +480,10 @@ fn resolve_windterm_key_path(path: &str, source_path: Option<&Path>) -> Option AppResult> { let file = std::fs::File::open(path) .map_err(|e| AppError::Config(format!("Cannot open file: {e}")))?; + parse_xshell_reader(file) +} + +fn parse_xshell_reader(file: impl Read + std::io::Seek) -> AppResult> { let mut archive = zip::ZipArchive::new(file) .map_err(|e| AppError::Config(format!("Invalid ZIP/XTS file: {e}")))?; let mut sessions = Vec::new(); + let mut expanded_bytes = 0u64; + if archive.len() > 10_000 { + return Err(AppError::Config("Too many archive entries".into())); + } for i in 0..archive.len() { - let mut entry = archive + let entry = archive .by_index(i) .map_err(|e| AppError::Config(format!("ZIP entry error: {e}")))?; @@ -19,10 +27,18 @@ fn parse_xshell(path: &str) -> AppResult> { } let mut raw = Vec::new(); + expanded_bytes = expanded_bytes.saturating_add(entry.size()); + if expanded_bytes > 50 * 1024 * 1024 { + return Err(AppError::Config("Archive exceeds 50 MiB".into())); + } entry + .take(50 * 1024 * 1024 + 1) .read_to_end(&mut raw) .map_err(|e| AppError::Config(format!("Failed to read {entry_path}: {e}")))?; + if raw.len() > 50 * 1024 * 1024 { + return Err(AppError::Config("Archive exceeds 50 MiB".into())); + } let content = decode_bytes(&raw); if let Some(sess) = parse_xsh_content(&content, &entry_path) { diff --git a/src-tauri/crates/nyaterm-core/src/core/keyword_highlights.rs b/src-tauri/crates/nyaterm-core/src/core/keyword_highlights.rs new file mode 100644 index 00000000..cd3b5877 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/keyword_highlights.rs @@ -0,0 +1,84 @@ +use crate::config; +use crate::error::{AppError, AppResult}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +#[derive(Debug, Clone, Serialize)] +pub struct KeywordHighlightImportResult { + pub imported_rules: usize, + pub updated_rules: usize, + pub total_rules: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum KeywordHighlightImportFile { + Config { + keyword_highlights: Vec, + }, + Rules(Vec), +} + +pub fn parse_keyword_highlight_import(raw: &str) -> AppResult> { + let import_file: KeywordHighlightImportFile = serde_json::from_str(raw) + .map_err(|error| AppError::Config(format!("Invalid highlight rules JSON: {error}")))?; + + Ok(match import_file { + KeywordHighlightImportFile::Config { keyword_highlights } => keyword_highlights, + KeywordHighlightImportFile::Rules(rules) => rules, + }) +} + +pub fn merge_keyword_highlight_rules( + existing: &mut Vec, + imported: Vec, + mut next_id: impl FnMut() -> String, +) -> AppResult { + let mut imported_rules = 0; + let mut updated_rules = 0; + let mut indexes = existing + .iter() + .enumerate() + .filter_map(|(index, rule)| (!rule.id.trim().is_empty()).then(|| (rule.id.clone(), index))) + .collect::>(); + + for mut rule in imported { + rule.name = rule.name.trim().to_string(); + rule.patterns = rule + .patterns + .into_iter() + .map(|pattern| pattern.trim().to_string()) + .filter(|pattern| !pattern.is_empty()) + .collect(); + + if rule.name.is_empty() || rule.patterns.is_empty() { + continue; + } + + rule.id = rule.id.trim().to_string(); + if rule.id.is_empty() { + rule.id = next_id(); + } + + if let Some(index) = indexes.get(&rule.id).copied() { + existing[index] = rule; + updated_rules += 1; + } else { + let id = rule.id.clone(); + existing.push(rule); + indexes.insert(id, existing.len() - 1); + imported_rules += 1; + } + } + + if imported_rules == 0 && updated_rules == 0 { + return Err(AppError::Config( + "No valid highlight rules found in import file".to_string(), + )); + } + + Ok(KeywordHighlightImportResult { + imported_rules, + updated_rules, + total_rules: existing.len(), + }) +} diff --git a/src-tauri/crates/nyaterm-core/src/core/mod.rs b/src-tauri/crates/nyaterm-core/src/core/mod.rs index 84a1c633..7de91eb9 100644 --- a/src-tauri/crates/nyaterm-core/src/core/mod.rs +++ b/src-tauri/crates/nyaterm-core/src/core/mod.rs @@ -6,3 +6,9 @@ pub mod capabilities; pub mod monitoring; pub mod quick_commands; pub mod translate; + +pub mod backup; +pub mod backup_crypto; +pub mod importer; +pub mod keyword_highlights; +pub mod portable_snapshot; diff --git a/src-tauri/src/core/portable_snapshot/codec.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/codec.rs similarity index 80% rename from src-tauri/src/core/portable_snapshot/codec.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/codec.rs index 9e6618c9..35d75942 100644 --- a/src-tauri/src/core/portable_snapshot/codec.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/codec.rs @@ -2,7 +2,7 @@ pub fn decode_portable_snapshot(bytes: &[u8]) -> AppResult { Ok(decode_portable_snapshot_with_source_hash(bytes)?.snapshot) } -pub(crate) fn decode_portable_snapshot_with_source_hash( +pub fn decode_portable_snapshot_with_source_hash( bytes: &[u8], ) -> AppResult { let payload = catch_unwind(AssertUnwindSafe(|| -> AppResult> { @@ -29,34 +29,38 @@ fn decode_portable_snapshot_redb_with_source_hash( } fn read_portable_snapshot_redb(bytes: &[u8]) -> AppResult { - catch_unwind(AssertUnwindSafe(|| -> AppResult { - let temp = TempRedbFile::new("portable-snapshot-decode"); - fs::write(temp.path(), bytes)?; - let db = Database::open(temp.path()).map_err(storage_error)?; - let read = db.begin_read().map_err(storage_error)?; - let meta_table = read - .open_table(SNAPSHOT_META_TABLE) - .map_err(storage_error)?; - let meta_raw = meta_table - .get(SNAPSHOT_META_KEY) - .map_err(storage_error)? - .ok_or_else(|| AppError::Config("portable snapshot is missing metadata".to_string()))? - .value() - .to_string(); - let meta: PortableSnapshotMeta = serde_json::from_str(&meta_raw)?; + catch_unwind(AssertUnwindSafe( + || -> AppResult { + let temp = TempRedbFile::new("portable-snapshot-decode"); + fs::write(temp.path(), bytes)?; + let db = Database::open(temp.path()).map_err(storage_error)?; + let read = db.begin_read().map_err(storage_error)?; + let meta_table = read + .open_table(SNAPSHOT_META_TABLE) + .map_err(storage_error)?; + let meta_raw = meta_table + .get(SNAPSHOT_META_KEY) + .map_err(storage_error)? + .ok_or_else(|| { + AppError::Config("portable snapshot is missing metadata".to_string()) + })? + .value() + .to_string(); + let meta: PortableSnapshotMeta = serde_json::from_str(&meta_raw)?; - if meta.schema_version == 2 { - decode_v2_snapshot(&read, meta) - } else if meta.schema_version == PORTABLE_SNAPSHOT_SCHEMA_VERSION { - let entities = read_string_table(&read, SNAPSHOT_ENTITIES_TABLE)?; - decode_v3_snapshot(meta, &entities) - } else { - Err(AppError::Config(format!( - "Unsupported portable snapshot version {}", - meta.schema_version - ))) - } - })) + if meta.schema_version == 2 { + decode_v2_snapshot(&read, meta) + } else if meta.schema_version == PORTABLE_SNAPSHOT_SCHEMA_VERSION { + let entities = read_string_table(&read, SNAPSHOT_ENTITIES_TABLE)?; + decode_v3_snapshot(meta, &entities) + } else { + Err(AppError::Config(format!( + "Unsupported portable snapshot version {}", + meta.schema_version + ))) + } + }, + )) .unwrap_or_else(|_| { Err(AppError::Storage( "Portable snapshot redb payload is corrupt or incomplete".to_string(), @@ -146,6 +150,12 @@ pub(crate) fn encode_v3_raw_snapshot_redb_for_test( } fn encode_compressed_snapshot_payload(redb_payload: &[u8]) -> AppResult> { + // Do not produce an archive the shared decoder cannot restore. + if redb_payload.len() as u64 > MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES { + return Err(AppError::Config( + "Snapshot exceeds the 50 MiB restore limit".into(), + )); + } let cursor = Cursor::new(Vec::new()); let mut zip = zip::ZipWriter::new(cursor); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); diff --git a/src-tauri/src/core/portable_snapshot/hash.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/hash.rs similarity index 96% rename from src-tauri/src/core/portable_snapshot/hash.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/hash.rs index 84a224ba..2327dedc 100644 --- a/src-tauri/src/core/portable_snapshot/hash.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/hash.rs @@ -1,4 +1,4 @@ -fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> { +pub fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> { if snapshot.schema_version != PORTABLE_SNAPSHOT_SCHEMA_VERSION { return Err(AppError::Config(format!( "Unsupported portable snapshot version {}", @@ -14,7 +14,7 @@ fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> { Ok(()) } -pub(crate) fn calculate_payload_hash(snapshot: &PortableSnapshot) -> AppResult { +pub fn calculate_payload_hash(snapshot: &PortableSnapshot) -> AppResult { let payload_bytes = serde_json::to_vec(&SnapshotHashInput { settings: &snapshot.settings, sessions: &snapshot.sessions, @@ -93,7 +93,9 @@ fn log_snapshot_hash_normalized(snapshot: &PortableSnapshot, source_payload_hash ); } -pub(crate) fn calculate_v3_raw_payload_hash(entities: &BTreeMap) -> AppResult { +pub(crate) fn calculate_v3_raw_payload_hash( + entities: &BTreeMap, +) -> AppResult { let settings = read_raw_entity(entities, "settings")?; let sessions = read_raw_entity(entities, "sessions")?; let keys = read_raw_entity(entities, "keys")?; @@ -166,4 +168,3 @@ pub(crate) fn calculate_v3_raw_payload_hash(entities: &BTreeMap) } Ok(hash) } - diff --git a/src-tauri/src/core/portable_snapshot/legacy.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/legacy.rs similarity index 100% rename from src-tauri/src/core/portable_snapshot/legacy.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/legacy.rs diff --git a/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/mod.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/mod.rs new file mode 100644 index 00000000..e367064e --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/mod.rs @@ -0,0 +1,43 @@ +//! Portable backup wire format shared by Desktop and Web. +use crate::config::{ + self, ActivityBarLayout, AppSettings, DiagnosticsSettings, InteractionSettings, SearchSettings, + TerminalSettings, TransferSettings, TranslationSettings, +}; +use crate::error::{AppError, AppResult}; +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + io::{Cursor, Read, Write}, + panic::{AssertUnwindSafe, catch_unwind}, + path::{Path, PathBuf}, +}; +use zip::write::SimpleFileOptions; +pub const PORTABLE_SNAPSHOT_SCHEMA_VERSION: u32 = 3; +const SNAPSHOT_META_KEY: &str = "meta"; +const SNAPSHOT_JSON_PORTABLE_SETTINGS: &str = "portable-settings"; +const SNAPSHOT_ZIP_MANIFEST_NAME: &str = "manifest.json"; +const SNAPSHOT_ZIP_PAYLOAD_NAME: &str = "snapshot.redb"; +const MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES: u64 = 50 * 1024 * 1024; +const SNAPSHOT_META_TABLE: TableDefinition<&str, &str> = TableDefinition::new("snapshot_meta"); +const SNAPSHOT_ENTITIES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("entity_docs"); +const SNAPSHOT_V2_JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs"); +const SNAPSHOT_V2_TEXT_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("text_docs"); +include!("types.rs"); +include!("codec.rs"); +include!("hash.rs"); +include!("legacy.rs"); +include!("redb_codec.rs"); +include!("temp_redb.rs"); +include!("tests.rs"); +pub fn sync_settings_payload_changed( + previous: &AppSettings, + next: &AppSettings, +) -> AppResult { + let previous = PortableAppSettings::from_app_settings(previous, &PortableSnapshotKind::Sync); + let next = PortableAppSettings::from_app_settings(next, &PortableSnapshotKind::Sync); + Ok(serde_json::to_vec(&previous)? != serde_json::to_vec(&next)?) +} diff --git a/src-tauri/src/core/portable_snapshot/redb_codec.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/redb_codec.rs similarity index 98% rename from src-tauri/src/core/portable_snapshot/redb_codec.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/redb_codec.rs index c7de53e6..55ed7efb 100644 --- a/src-tauri/src/core/portable_snapshot/redb_codec.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/redb_codec.rs @@ -53,7 +53,7 @@ fn read_string_table( Ok(values) } -fn current_time_ms() -> u64 { +pub fn current_time_ms() -> u64 { let millis = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() diff --git a/src-tauri/src/core/portable_snapshot/temp_redb.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/temp_redb.rs similarity index 100% rename from src-tauri/src/core/portable_snapshot/temp_redb.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/temp_redb.rs diff --git a/src-tauri/src/core/portable_snapshot/tests.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs similarity index 97% rename from src-tauri/src/core/portable_snapshot/tests.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs index 4b4857c2..1913d94b 100644 --- a/src-tauri/src/core/portable_snapshot/tests.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/tests.rs @@ -2,12 +2,12 @@ #[allow(deprecated)] mod tests { use super::{ - PORTABLE_SNAPSHOT_SCHEMA_VERSION, PortableAppSettings, PortableSnapshot, - PortableSnapshotKind, PortableUiSettings, SNAPSHOT_ZIP_PAYLOAD_NAME, + AgentEndpointTargetPlatform, PORTABLE_SNAPSHOT_SCHEMA_VERSION, PortableAppSettings, + PortableSnapshot, PortableSnapshotKind, PortableUiSettings, SNAPSHOT_ZIP_PAYLOAD_NAME, calculate_payload_hash, calculate_v3_raw_payload_hash, encode_portable_snapshot, encode_portable_snapshot_redb, encode_v3_raw_snapshot_redb_for_test, normalize_backup_sessions_for_platform, preserve_device_local_sessions, - strip_device_local_sessions, sync_settings_payload_changed, AgentEndpointTargetPlatform, + strip_device_local_sessions, sync_settings_payload_changed, }; use crate::config::{self, ActivityBarLayout, AppSettings}; use crate::error::AppError; @@ -40,11 +40,13 @@ mod tests { })) .expect("sessions"); - assert!(normalize_backup_sessions_for_platform( - &mut sessions, - AgentEndpointTargetPlatform::Unix - ) - .expect("normalize backup sessions")); + assert!( + normalize_backup_sessions_for_platform( + &mut sessions, + AgentEndpointTargetPlatform::Unix + ) + .expect("normalize backup sessions") + ); let config::ConnectionType::Ssh { auth_agent_endpoint, @@ -90,11 +92,13 @@ mod tests { })) .expect("sessions"); - assert!(normalize_backup_sessions_for_platform( - &mut sessions, - AgentEndpointTargetPlatform::Windows - ) - .expect("normalize backup sessions")); + assert!( + normalize_backup_sessions_for_platform( + &mut sessions, + AgentEndpointTargetPlatform::Windows + ) + .expect("normalize backup sessions") + ); let config::ConnectionType::Ssh { auth_agent_endpoint, @@ -106,7 +110,10 @@ mod tests { }; assert!(auth_agent_endpoint.is_none()); let forwarding = agent_forwarding_config.as_ref().expect("forwarding config"); - assert_eq!(forwarding.sources.external_agent_endpoints, vec![config::SshAgentEndpoint::Pageant]); + assert_eq!( + forwarding.sources.external_agent_endpoints, + vec![config::SshAgentEndpoint::Pageant] + ); assert!(forwarding.enabled); } @@ -134,11 +141,13 @@ mod tests { })) .expect("sessions"); - assert!(normalize_backup_sessions_for_platform( - &mut sessions, - AgentEndpointTargetPlatform::Windows - ) - .is_err()); + assert!( + normalize_backup_sessions_for_platform( + &mut sessions, + AgentEndpointTargetPlatform::Windows + ) + .is_err() + ); } #[test] @@ -519,7 +528,10 @@ mod tests { else { panic!("expected SSH connection"); }; - assert_eq!(auth_agent_endpoint, &Some(config::SshAgentEndpoint::Pageant)); + assert_eq!( + auth_agent_endpoint, + &Some(config::SshAgentEndpoint::Pageant) + ); assert!(legacy_agent_forwarding.is_none()); assert_eq!( agent_forwarding_config, @@ -760,7 +772,8 @@ mod tests { Some("2026-08-03T12:00:00.000Z") ); assert_eq!( - asset.accelerators + asset + .accelerators .as_ref() .and_then(|items| items.first()) .map(|item| &item.r#type), @@ -1090,8 +1103,7 @@ mod tests { snapshot.payload_hash = calculate_payload_hash(&snapshot).expect("current snapshot hash"); let mut entities = snapshot_entities(&snapshot); - let mut legacy_sessions = - serde_json::to_value(&snapshot.sessions).expect("sessions json"); + let mut legacy_sessions = serde_json::to_value(&snapshot.sessions).expect("sessions json"); let connection = legacy_sessions["connections"][0] .as_object_mut() .expect("connection object"); @@ -1122,9 +1134,11 @@ mod tests { ); let normalized_sessions = serde_json::to_value(&decoded.snapshot.sessions).expect("normalized sessions"); - assert!(normalized_sessions["connections"][0]["asset"] - .get("tags") - .is_none()); + assert!( + normalized_sessions["connections"][0]["asset"] + .get("tags") + .is_none() + ); } #[test] @@ -1219,5 +1233,4 @@ mod tests { "compressed snapshot should be smaller than legacy redb" ); } - } diff --git a/src-tauri/src/core/portable_snapshot/types.rs b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/types.rs similarity index 99% rename from src-tauri/src/core/portable_snapshot/types.rs rename to src-tauri/crates/nyaterm-core/src/core/portable_snapshot/types.rs index 3f1ba808..d40334a0 100644 --- a/src-tauri/src/core/portable_snapshot/types.rs +++ b/src-tauri/crates/nyaterm-core/src/core/portable_snapshot/types.rs @@ -6,13 +6,13 @@ pub enum PortableSnapshotKind { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AgentEndpointTargetPlatform { +pub enum AgentEndpointTargetPlatform { Unix, Windows, } impl AgentEndpointTargetPlatform { - pub(crate) fn current() -> Self { + pub fn current() -> Self { if cfg!(windows) { Self::Windows } else { @@ -45,7 +45,7 @@ fn normalize_portable_session_tags(sessions: &mut config::SessionsConfig) -> boo } /// Removes device-specific Agent endpoints that cannot work on the restore target. -pub(crate) fn normalize_backup_sessions_for_platform( +pub fn normalize_backup_sessions_for_platform( sessions: &mut config::SessionsConfig, platform: AgentEndpointTargetPlatform, ) -> crate::error::AppResult { @@ -150,7 +150,7 @@ pub struct PortableSnapshot { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct DecodedPortableSnapshot { +pub struct DecodedPortableSnapshot { pub snapshot: PortableSnapshot, pub source_payload_hash: String, } diff --git a/src-tauri/crates/nyaterm-core/src/storage/backup.rs b/src-tauri/crates/nyaterm-core/src/storage/backup.rs new file mode 100644 index 00000000..6bac4f63 --- /dev/null +++ b/src-tauri/crates/nyaterm-core/src/storage/backup.rs @@ -0,0 +1,120 @@ +use super::{ + SettingsDocKey, Storage, credentials::*, history::replace_command_history_in_txn, + known_hosts::replace_known_hosts_text_in_txn, notes::replace_notes_in_txn, + sessions::replace_sessions_in_txn, tables::SETTINGS_TABLE, util::*, +}; +use crate::{config, core::portable_snapshot::PortableSnapshot, error::AppResult}; + +pub fn restore_backup( + snapshot: &PortableSnapshot, + settings: &config::AppSettings, +) -> AppResult<()> { + super::storage()?.restore_backup(snapshot, settings) +} + +impl Storage { + pub fn restore_backup( + &self, + snapshot: &PortableSnapshot, + settings: &config::AppSettings, + ) -> AppResult<()> { + crate::core::portable_snapshot::validate_portable_snapshot(snapshot)?; + let txn = self.db.begin_write().map_err(storage_error)?; + replace_sessions_in_txn(&txn, &snapshot.sessions)?; + replace_passwords_in_txn(&txn, &snapshot.passwords)?; + replace_ssh_keys_in_txn(&txn, &snapshot.keys)?; + replace_credentials_in_txn(&txn, &snapshot.credentials)?; + replace_otp_in_txn(&txn, &snapshot.otp)?; + replace_proxies_in_txn(&txn, &snapshot.proxies)?; + replace_tunnels_in_txn(&txn, &snapshot.tunnels)?; + replace_command_history_in_txn(&txn, &snapshot.history)?; + replace_notes_in_txn(&txn, &snapshot.notes)?; + replace_known_hosts_text_in_txn(&txn, &snapshot.known_hosts)?; + for (key, value) in [ + (SettingsDocKey::AppSettings, serde_json::to_value(settings)?), + ( + SettingsDocKey::QuickCommands, + serde_json::to_value(&snapshot.quick_commands)?, + ), + ( + SettingsDocKey::ProxyGroups, + serde_json::json!({"groups":snapshot.proxy_groups}), + ), + ( + SettingsDocKey::TunnelGroups, + serde_json::json!({"groups":snapshot.tunnel_groups}), + ), + ] { + write_json_in_txn(&txn, SETTINGS_TABLE, key.storage_key(), &value)?; + } + let mut sync = self + .get_settings_doc::(SettingsDocKey::CloudSyncState)? + .unwrap_or_default(); + sync.last_synced_payload_hash = None; + sync.last_applied_remote_revision = None; + write_json_in_txn( + &txn, + SETTINGS_TABLE, + SettingsDocKey::CloudSyncState.storage_key(), + &sync, + )?; + txn.commit().map_err(storage_error)?; + Ok(()) + } + + pub fn import_connections( + &self, + sessions: &config::SessionsConfig, + passwords: &config::PasswordsConfig, + keys: &config::KeysConfig, + ) -> AppResult<()> { + let txn = self.db.begin_write().map_err(storage_error)?; + replace_sessions_in_txn(&txn, sessions)?; + replace_passwords_in_txn(&txn, passwords)?; + replace_ssh_keys_in_txn(&txn, keys)?; + txn.commit().map_err(storage_error)?; + Ok(()) + } +} + +pub fn import_connections( + sessions: &config::SessionsConfig, + passwords: &config::PasswordsConfig, + keys: &config::KeysConfig, +) -> AppResult<()> { + super::storage()?.import_connections(sessions, passwords, keys) +} + +/// Reject ambiguous entity IDs before writes can silently replace another entity. +pub fn validate_backup_data(snapshot: &PortableSnapshot) -> AppResult<()> { + fn unique<'a>(ids: impl Iterator) -> AppResult<()> { + let mut seen = std::collections::HashSet::new(); + for id in ids { + if id.trim().is_empty() || !seen.insert(id) { + return Err(crate::error::AppError::Config( + "Backup contains empty or duplicate entity IDs".into(), + )); + } + } + Ok(()) + } + macro_rules! check { + ($values:expr) => { + unique($values.iter().map(|entry| entry.id.as_str()))? + }; + } + check!(snapshot.sessions.connections); + check!(snapshot.sessions.groups); + check!(snapshot.sessions.custom_icons); + check!(snapshot.keys.keys); + check!(snapshot.passwords.passwords); + check!(snapshot.credentials.credentials); + check!(snapshot.otp.entries); + check!(snapshot.proxies); + check!(snapshot.proxy_groups); + check!(snapshot.tunnels); + check!(snapshot.tunnel_groups); + check!(snapshot.quick_commands.commands); + check!(snapshot.quick_commands.categories); + super::notes::validate_notes_snapshot(&snapshot.notes) +} diff --git a/src-tauri/crates/nyaterm-core/src/storage/mod.rs b/src-tauri/crates/nyaterm-core/src/storage/mod.rs index 1f9f21cf..764f4e11 100644 --- a/src-tauri/crates/nyaterm-core/src/storage/mod.rs +++ b/src-tauri/crates/nyaterm-core/src/storage/mod.rs @@ -2,6 +2,7 @@ #![allow(dead_code)] +pub mod backup; mod credentials; mod history; mod known_hosts; diff --git a/src-tauri/crates/nyaterm-core/src/storage/notes.rs b/src-tauri/crates/nyaterm-core/src/storage/notes.rs index 9da20cb1..1baeed76 100644 --- a/src-tauri/crates/nyaterm-core/src/storage/notes.rs +++ b/src-tauri/crates/nyaterm-core/src/storage/notes.rs @@ -436,23 +436,8 @@ impl Storage { } pub fn replace_notes_snapshot(&self, snapshot: &NotesSnapshot) -> AppResult<()> { - validate_notes_snapshot(snapshot)?; let txn = self.db.begin_write().map_err(storage_error)?; - clear_prefix_in_txn(&txn, NOTE_FOLDERS_TABLE, NOTE_FOLDER_PREFIX)?; - clear_prefix_in_txn(&txn, NOTES_TABLE, NOTE_DOCUMENT_PREFIX)?; - clear_prefix_in_txn(&txn, NOTE_SUMMARIES_TABLE, NOTE_SUMMARY_PREFIX)?; - for folder in &snapshot.folders { - write_note_folder_in_txn(&txn, folder)?; - } - for note in &snapshot.notes { - write_note_in_txn(&txn, note)?; - write_note_summary_in_txn(&txn, &NoteSummary::from(note.clone()))?; - } - write_meta_u32( - &txn, - META_NOTE_SUMMARY_INDEX_VERSION, - NOTE_SUMMARY_INDEX_VERSION, - )?; + replace_notes_in_txn(&txn, snapshot)?; txn.commit().map_err(storage_error)?; Ok(()) } @@ -752,7 +737,7 @@ fn next_sort_order_for_parent( .saturating_add(1) } -fn validate_notes_snapshot(snapshot: &NotesSnapshot) -> AppResult<()> { +pub(crate) fn validate_notes_snapshot(snapshot: &NotesSnapshot) -> AppResult<()> { let mut folder_ids = HashSet::new(); let mut note_ids = HashSet::new(); for folder in &snapshot.folders { @@ -1073,3 +1058,26 @@ mod tests { ); } } + +pub(super) fn replace_notes_in_txn( + txn: &redb::WriteTransaction, + snapshot: &NotesSnapshot, +) -> AppResult<()> { + validate_notes_snapshot(snapshot)?; + clear_prefix_in_txn(txn, NOTE_FOLDERS_TABLE, NOTE_FOLDER_PREFIX)?; + clear_prefix_in_txn(txn, NOTES_TABLE, NOTE_DOCUMENT_PREFIX)?; + clear_prefix_in_txn(txn, NOTE_SUMMARIES_TABLE, NOTE_SUMMARY_PREFIX)?; + for folder in &snapshot.folders { + write_note_folder_in_txn(txn, folder)?; + } + for note in &snapshot.notes { + write_note_in_txn(txn, note)?; + write_note_summary_in_txn(txn, &NoteSummary::from(note.clone()))?; + } + write_meta_u32( + txn, + META_NOTE_SUMMARY_INDEX_VERSION, + NOTE_SUMMARY_INDEX_VERSION, + )?; + Ok(()) +} diff --git a/src-tauri/crates/nyaterm-core/src/storage/tests.rs b/src-tauri/crates/nyaterm-core/src/storage/tests.rs index af8ebafc..276ac5db 100644 --- a/src-tauri/crates/nyaterm-core/src/storage/tests.rs +++ b/src-tauri/crates/nyaterm-core/src/storage/tests.rs @@ -20,6 +20,39 @@ fn test_storage(name: &str) -> (PathBuf, Storage) { let storage = Storage::open(&dir).expect("open storage"); (dir, storage) } + +#[test] +fn backup_transaction_rolls_back_entities_written_before_a_note_failure() { + use crate::core::portable_snapshot::*; + let (dir, storage) = test_storage("backup-rollback"); + let sessions = SessionsConfig { + connections: vec![sample_connection("preserved", None, 0)], + ..Default::default() + }; + storage.replace_sessions(&sessions).unwrap(); + let settings = crate::config::AppSettings::default(); + let mut snapshot: PortableSnapshot = serde_json::from_value(serde_json::json!({ + "schema_version":PORTABLE_SNAPSHOT_SCHEMA_VERSION,"snapshot_kind":"backup", + "revision_id":"fixture","device_id":"fixture","created_at_ms":0,"payload_hash":"","app_version":"fixture", + "settings":PortableAppSettings::from_app_settings(&settings,&PortableSnapshotKind::Backup), + "sessions":{"connections":[]}, + "notes":{"folders":[{"id":"cycle","name":"bad","parent_id":"cycle","sort_order":0,"created_at_ms":0,"updated_at_ms":0}],"notes":[]} + })).unwrap(); + snapshot.payload_hash = calculate_payload_hash(&snapshot).unwrap(); + assert!(storage.restore_backup(&snapshot, &settings).is_err()); + assert_eq!( + storage.load_sessions().unwrap().connections[0].id, + "preserved" + ); + drop(storage); + let reopened = Storage::open(&dir).unwrap(); + assert_eq!( + reopened.load_sessions().unwrap().connections[0].id, + "preserved" + ); + drop(reopened); + fs::remove_dir_all(dir).unwrap(); +} fn sample_group(id: &str, sort_order: i32) -> Group { Group { id: id.to_string(), diff --git a/src-tauri/crates/nyaterm-core/src/utils/crypto.rs b/src-tauri/crates/nyaterm-core/src/utils/crypto.rs index c4a99dc7..058b8671 100644 --- a/src-tauri/crates/nyaterm-core/src/utils/crypto.rs +++ b/src-tauri/crates/nyaterm-core/src/utils/crypto.rs @@ -182,7 +182,7 @@ fn unwrap_master_key_with_compatible_wrapping( } /// Loads the master key from redb `master.key`, creating it on first use. -fn get_master_key() -> AppResult> { +pub(crate) fn get_master_key() -> AppResult> { if let Some(encoded) = crate::storage::load_master_key_token()? { let raw = B64 .decode(encoded.trim()) @@ -204,6 +204,54 @@ fn get_master_key() -> AppResult> { } } +/// Portable backups wrap a key with an explicit password without changing runtime keys. +pub(crate) fn backup_key_token(key: &Key, password: &str) -> AppResult { + let cipher = Aes256Gcm::new(&derive_wrapping_key(Some(password))?); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let mut bytes = nonce.to_vec(); + bytes.extend( + cipher + .encrypt(&nonce, key.as_slice()) + .map_err(|_| AppError::Crypto("Backup key wrapping failed".into()))?, + ); + Ok(B64.encode(bytes)) +} + +pub(crate) fn backup_key(token: &str, password: &str) -> AppResult> { + let bytes = B64 + .decode(token) + .map_err(|_| AppError::Crypto("Invalid backup key".into()))?; + Ok(unwrap_master_key_with_compatible_wrapping(&bytes, Some(password))?.0) +} + +pub(crate) fn transcode_secret( + token: &str, + source: &Key, + target: &Key, +) -> AppResult { + let bytes = B64 + .decode(token) + .map_err(|_| AppError::Crypto("Invalid backup credential".into()))?; + if bytes.len() < 28 { + return Err(AppError::Crypto("Invalid backup credential".into())); + } + let plain = zeroize::Zeroizing::new( + Aes256Gcm::new(source) + .decrypt(aes_gcm::Nonce::from_slice(&bytes[..12]), &bytes[12..]) + .map_err(|_| AppError::Crypto("Backup credential decryption failed".into()))?, + ); + std::str::from_utf8(&plain) + .map_err(|_| AppError::Crypto("Invalid backup credential encoding".into()))?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + Aes256Gcm::new(target) + .encrypt(&nonce, plain.as_slice()) + .map_err(|_| AppError::Crypto("Backup credential encryption failed".into()))?, + ); + Ok(B64.encode(encrypted)) +} + pub fn verify_master_key_token() -> AppResult<()> { let _ = get_master_key()?; Ok(()) diff --git a/src-tauri/crates/nyaterm-web/Cargo.lock b/src-tauri/crates/nyaterm-web/Cargo.lock index 33a66486..b049ca89 100644 --- a/src-tauri/crates/nyaterm-web/Cargo.lock +++ b/src-tauri/crates/nyaterm-web/Cargo.lock @@ -277,7 +277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" dependencies = [ "blowfish", - "pbkdf2", + "pbkdf2 0.13.0", "sha2 0.11.0", ] @@ -324,6 +324,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.7", +] + [[package]] name = "block-padding" version = "0.4.2" @@ -391,6 +400,15 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cbc" version = "0.2.1" @@ -2018,6 +2036,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding 0.3.3", "generic-array 0.14.7", ] @@ -2027,7 +2046,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", + "block-padding 0.4.2", "hybrid-array", ] @@ -2130,6 +2149,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keccak" version = "0.2.2" @@ -2314,7 +2342,7 @@ dependencies = [ "module-lattice", "pkcs8 0.11.0", "rand_core 0.10.1", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -2400,6 +2428,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "nucleo-matcher" version = "0.3.1" @@ -2475,9 +2512,11 @@ dependencies = [ name = "nyaterm-core" version = "0.1.0" dependencies = [ + "aes 0.8.4", "aes-gcm 0.10.3", "async-http-proxy", "base64 0.22.1", + "cbc 0.1.2", "dirs", "encoding_rs", "font-kit", @@ -2488,6 +2527,8 @@ dependencies = [ "md-5", "nucleo-matcher", "nyaterm-gpu", + "pbkdf2 0.12.2", + "quick-xml", "rand 0.8.8", "redb", "regex", @@ -2498,6 +2539,7 @@ dependencies = [ "serde_json", "sha1 0.10.7", "sha2 0.10.9", + "sha3 0.10.9", "ssh-key 0.6.7", "strip-ansi-escapes", "thiserror 2.0.21", @@ -2544,14 +2586,18 @@ dependencies = [ "sha2 0.10.9", "subtle", "tempfile", + "time", "tokio", "tokio-tungstenite", "tokio-util", "tower", "tower-http", + "tracing", + "tracing-subscriber", "url", "uuid", "zeroize", + "zip", ] [[package]] @@ -2769,6 +2815,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -2848,9 +2904,9 @@ checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca" dependencies = [ "aes 0.9.3", "aes-gcm 0.11.1", - "cbc", + "cbc 0.2.1", "der 0.8.2", - "pbkdf2", + "pbkdf2 0.13.0", "rand_core 0.10.1", "scrypt", "sha2 0.11.0", @@ -3016,6 +3072,15 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-xml" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3313,10 +3378,10 @@ version = "0.62.1" dependencies = [ "aes 0.9.3", "bitflags 2.13.2", - "block-padding", + "block-padding 0.4.2", "byteorder", "bytes", - "cbc", + "cbc 0.2.1", "cipher 0.5.2", "crypto-bigint 0.7.5", "ctr 0.10.1", @@ -3339,7 +3404,7 @@ dependencies = [ "hmac 0.13.0", "inout 0.2.2", "internal-russh-num-bigint", - "keccak", + "keccak 0.2.2", "log", "md5", "ml-kem", @@ -3349,7 +3414,7 @@ dependencies = [ "p384 0.14.0-rc.15", "p521 0.14.0-rc.15", "pageant", - "pbkdf2", + "pbkdf2 0.13.0", "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.11.0", @@ -3365,7 +3430,7 @@ dependencies = [ "sec1 0.8.1", "sha1 0.11.0", "sha2 0.11.0", - "sha3", + "sha3 0.11.0", "signature 3.0.0", "spki 0.8.0", "ssh-encoding 0.3.0", @@ -3549,7 +3614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", - "pbkdf2", + "pbkdf2 0.13.0", "salsa20", "sha2 0.11.0", ] @@ -3774,6 +3839,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak 0.1.6", +] + [[package]] name = "sha3" version = "0.11.0" @@ -3781,7 +3856,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak", + "keccak 0.2.2", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", ] [[package]] @@ -4146,6 +4230,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.55" @@ -4378,6 +4471,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -4499,6 +4618,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-ext" version = "0.1.5" @@ -5063,7 +5188,7 @@ dependencies = [ "indexmap 2.14.2", "lzma-rust2", "memchr", - "pbkdf2", + "pbkdf2 0.13.0", "ppmd-rust", "sha1 0.11.0", "time", diff --git a/src-tauri/crates/nyaterm-web/Cargo.toml b/src-tauri/crates/nyaterm-web/Cargo.toml index f58f4b91..15cc30f4 100644 --- a/src-tauri/crates/nyaterm-web/Cargo.toml +++ b/src-tauri/crates/nyaterm-web/Cargo.toml @@ -26,6 +26,11 @@ russh = { path = "../../vendor/russh/russh", default-features = false, features russh-sftp = { path = "../../vendor/russh-sftp" } reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "native-tls"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["registry"] } +time = { version = "0.3", features = ["formatting"] } +zip = "8" + [dev-dependencies] tower = { version = "0.5", features = ["util"] } tempfile = "3" diff --git a/src-tauri/crates/nyaterm-web/src/ai.rs b/src-tauri/crates/nyaterm-web/src/ai.rs index 57b726e0..44abf4e7 100644 --- a/src-tauri/crates/nyaterm-web/src/ai.rs +++ b/src-tauri/crates/nyaterm-web/src/ai.rs @@ -238,7 +238,7 @@ async fn start(state: &Arc, owner: &str, args: &Value) -> Result { let events = login.events.clone(); let task_stream = stream_id.clone(); let task_session = session_id.clone(); - tokio::spawn(async move { + crate::observability::spawn(async move { let sink = stream::EventSink(move |id: &str, payload: AiStreamEventPayload| { let _ = events.send(Event { event: format!("ai-stream-{id}"), diff --git a/src-tauri/crates/nyaterm-web/src/auth.rs b/src-tauri/crates/nyaterm-web/src/auth.rs index 20fe900a..bee70e7a 100644 --- a/src-tauri/crates/nyaterm-web/src/auth.rs +++ b/src-tauri/crates/nyaterm-web/src/auth.rs @@ -42,6 +42,11 @@ pub async fn verify_unlock(state: &State, args: &serde_json::Value) -> Result= 20 { + tracing::warn!( + event = "auth.rate_limited", + reason = "attempt_budget", + "Web sign-in rate limited" + ); return Err(WebError( StatusCode::TOO_MANY_REQUESTS, "Try again later".into(), @@ -153,6 +158,11 @@ pub async fn sign_in( let mut attempts = state.login_attempts.lock().await; attempts.retain(|instant| instant.elapsed() < Duration::from_secs(60)); if attempts.len() >= 20 { + tracing::warn!( + event = "auth.rate_limited", + reason = "attempt_budget", + "Web sign-in rate limited" + ); return Err(WebError( StatusCode::TOO_MANY_REQUESTS, "Try again later".into(), @@ -163,6 +173,11 @@ pub async fn sign_in( let valid = equal(&digest(&payload.password), &state.password_hash); payload.password.zeroize(); if !valid { + tracing::warn!( + event = "auth.rejected", + reason = "invalid_credentials", + "Web sign-in rejected" + ); return Err(WebError( StatusCode::UNAUTHORIZED, "Invalid credentials".into(), @@ -191,6 +206,7 @@ pub async fn sign_in( cancel: state.shutdown.child_token(), }), ); + tracing::info!(event = "auth.accepted", "Web sign-in accepted"); Ok(( [(header::SET_COOKIE, cookie(&state, &owner, 8 * 3600))], Json(json!({"csrf":csrf})), @@ -215,6 +231,7 @@ pub async fn sign_out( axum::Extension(owner): axum::Extension, ) -> Response { state.close_owner(&owner.0).await; + tracing::info!(event = "auth.signed_out", "Web administrator signed out"); ( [(header::SET_COOKIE, cookie(&state, "", 0))], Json(json!({})), diff --git a/src-tauri/crates/nyaterm-web/src/backups.rs b/src-tauri/crates/nyaterm-web/src/backups.rs new file mode 100644 index 00000000..9931bf4f --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/backups.rs @@ -0,0 +1,167 @@ +use crate::{ + auth::Owner, + error::{Result, WebError}, + state::State, +}; +use axum::{ + Extension, Json, + extract::{Multipart, State as ExtractState}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use nyaterm_core::{core::backup, storage}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::sync::Arc; + +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +#[derive(Deserialize)] +pub struct ExportRequest { + password: String, +} + +pub async fn export( + ExtractState(state): ExtractState>, + Extension(_owner): Extension, + Json(request): Json, +) -> Result { + let _guard = state.mutation.lock().await; + let password = zeroize::Zeroizing::new(request.password); + let bytes = + crate::observability::blocking(move || backup::export_backup(&password, APP_VERSION)) + .await + .map_err(|_| WebError::bad("Backup operation failed"))??; + tracing::info!( + event = "backup.export", + bytes = bytes.len(), + "Configuration backup exported" + ); + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"nyaterm-backup.nya\"", + ), + ], + bytes, + ) + .into_response()) +} + +async fn uploaded( + mut multipart: Multipart, + limit: usize, +) -> Result<(zeroize::Zeroizing, zeroize::Zeroizing>)> { + let mut password = None; + let mut bytes = None; + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|_| WebError::bad("Invalid upload"))? + { + let name = field.name().unwrap_or("").to_owned(); + let max = if name == "file" { limit } else { 1024 }; + let mut value = zeroize::Zeroizing::new(Vec::new()); + while let Some(chunk) = field + .chunk() + .await + .map_err(|_| WebError::bad("Invalid upload"))? + { + if value.len().saturating_add(chunk.len()) > max { + return Err(WebError( + StatusCode::PAYLOAD_TOO_LARGE, + "Upload exceeds limit".into(), + )); + } + value.extend_from_slice(&chunk); + } + match name.as_str() { + "password" | "source" if password.is_none() => { + password = Some(zeroize::Zeroizing::new( + String::from_utf8(value.to_vec()) + .map_err(|_| WebError::bad("Invalid upload field"))?, + )) + } + "file" if bytes.is_none() => bytes = Some(value), + _ => return Err(WebError::bad("Unexpected or duplicate upload field")), + } + } + Ok(( + password.ok_or(WebError::bad("Missing upload field"))?, + bytes.ok_or(WebError::bad("Missing file"))?, + )) +} + +pub async fn import( + ExtractState(state): ExtractState>, + Extension(_owner): Extension, + multipart: Multipart, +) -> Result> { + let (password, bytes) = uploaded(multipart, backup::MAX_BACKUP_BYTES).await?; + let snapshot = + crate::observability::blocking(move || backup::prepare_import(&bytes, &password)) + .await + .map_err(|_| WebError::bad("Backup operation failed"))? + .map_err(import_error)?; + let _guard = state.mutation.lock().await; + crate::observability::blocking(move || { + let settings = backup::settings_for_import(&snapshot)?; + storage::backup::restore_backup(&snapshot, &settings) + }) + .await + .map_err(|_| WebError::bad("Backup operation failed"))? + .map_err(import_error)?; + crate::observability::reload_settings(); + for event in [ + "connections-changed", + "settings-changed", + "quick-commands-changed", + "command-history-changed", + "proxy-saved", + "credentials-changed", + ] { + state.broadcast(event, Value::Null).await; + } + state + .broadcast( + "notes-changed", + json!({"kind":"replaced","ids":[],"folders":[],"notes":[],"tree_changed":true}), + ) + .await; + tracing::info!(event = "backup.import", "Configuration backup restored"); + Ok(Json(json!({"status":"completed"}))) +} + +pub async fn connections( + ExtractState(state): ExtractState>, + Extension(_owner): Extension, + multipart: Multipart, +) -> Result> { + let (source, bytes) = uploaded(multipart, 10 * 1024 * 1024).await?; + let _guard = state.mutation.lock().await; + let result = crate::observability::blocking(move || nyaterm_core::core::importer::import_browser_connections(&source, &bytes)).await.map_err(|_| WebError::bad("Import operation failed"))?.map_err(|error| { + // Only a fixed message may cross the API boundary; parser errors can contain imported secrets. + crate::observability::record_error(&error); + tracing::warn!(event="connections.import_failed", "Connection import failed"); + if matches!(&error, nyaterm_core::error::AppError::Config(message) if message.starts_with("WindTerm profile")) { + WebError::bad("WindTerm profile resources required; import on Desktop and transfer a .nya backup") + } else { WebError::bad("Invalid or unsupported connection import") } + })?; + state.broadcast("connections-changed", Value::Null).await; + tracing::info!( + event = "connections.import", + count = result.imported, + "Connections imported" + ); + Ok(Json(serde_json::to_value(result)?)) +} + +fn import_error(error: nyaterm_core::error::AppError) -> WebError { + crate::observability::record_error(&error); + tracing::warn!( + event = "backup.import_failed", + "Backup password, integrity or data validation failed" + ); + WebError::bad("Invalid backup, incorrect password, or unsupported backup data") +} diff --git a/src-tauri/crates/nyaterm-web/src/commands.rs b/src-tauri/crates/nyaterm-web/src/commands.rs index 7d7de92f..b1b2855a 100644 --- a/src-tauri/crates/nyaterm-web/src/commands.rs +++ b/src-tauri/crates/nyaterm-web/src/commands.rs @@ -10,6 +10,7 @@ use axum::{ use nyaterm_core::{config, services, storage, utils::crypto}; use serde_json::{Value, json}; use std::sync::Arc; +use tracing::Instrument; #[derive(serde::Deserialize)] struct SortOrderUpdate { @@ -31,8 +32,21 @@ pub async fn route( Path(command): Path, Json(args): Json, ) -> Result> { + let span = tracing::info_span!("web.command", operation=%command, session_id=args["sessionId"].as_str()); + async move { // Explicit allowlist; no dynamic reflection or Desktop invoke handler here. let result = match command.as_str() { + "import_keyword_highlight_rules" => { + let _guard = state.mutation.lock().await; + let content = text(&args, "content")?; + if content.len() > 1024 * 1024 { return Err(WebError::bad("Import exceeds 1 MiB")); } + let rules = nyaterm_core::core::keyword_highlights::parse_keyword_highlight_import(content)?; + let result = storage::update_settings_doc::(storage::SettingsDocKey::AppSettings, |settings| { + nyaterm_core::core::keyword_highlights::merge_keyword_highlight_rules(&mut settings.terminal.keyword_highlights, rules, || uuid::Uuid::new_v4().to_string()) + })?; + state.broadcast("settings-changed", Value::Null).await; + json!(result) + }, "get_app_runtime_info" => json!({"mode":"web","portable":false,"packageManager":null,"executableDir":"","dataDir":"","configDir":"","logDir":"","webviewDataDir":"","portableMarkerPath":null}), "get_support_info" => json!({"os":format!("Web server ({})",std::env::consts::OS),"architecture":std::env::consts::ARCH,"runtime":"web","packageManager":null}), "get_app_settings" => { @@ -57,6 +71,7 @@ pub async fn route( next.ai=config::encrypt_ai_settings(config::merge_masked_ai_settings(¤t.ai,next.ai))?; next.cloud_sync=config::encrypt_cloud_sync_settings(config::merge_masked_cloud_sync_settings(¤t.cloud_sync,next.cloud_sync))?; config::save_app_settings(&(),&next)?; + crate::observability::configure(&next.diagnostics); state.broadcast("settings-changed",Value::Null).await; Value::Null }, @@ -263,5 +278,7 @@ pub async fn route( command if crate::ai::supports(command) => crate::ai::command(&state,&owner.0,command,&args).await?, _ => return Err(WebError::unsupported()), }; + tracing::info!(event="command.completed", status=200, "Web command completed"); Ok(Json(result)) + }.instrument(span).await } diff --git a/src-tauri/crates/nyaterm-web/src/error.rs b/src-tauri/crates/nyaterm-web/src/error.rs index 57d61a3c..02ee588d 100644 --- a/src-tauri/crates/nyaterm-web/src/error.rs +++ b/src-tauri/crates/nyaterm-web/src/error.rs @@ -12,6 +12,10 @@ impl WebError { pub fn bad(message: impl Into) -> Self { Self(StatusCode::BAD_REQUEST, message.into()) } + pub fn io(error: &std::io::Error, message: &'static str) -> Self { + tracing::warn!(event="io.error", error_kind="io", error_code=error.raw_os_error(), reason=?error.kind(), error_hash=%crate::observability::fingerprint(&error.to_string()), "I/O operation failed"); + Self::bad(message) + } pub fn forbidden() -> Self { Self(StatusCode::FORBIDDEN, "Access denied".into()) } @@ -23,7 +27,8 @@ impl WebError { } } impl From for WebError { - fn from(_: nyaterm_core::error::AppError) -> Self { + fn from(error: nyaterm_core::error::AppError) -> Self { + crate::observability::record_error(&error); Self::bad("Backend operation failed") } } @@ -33,17 +38,20 @@ impl From for WebError { } } impl From for WebError { - fn from(_: russh::Error) -> Self { + fn from(error: russh::Error) -> Self { + tracing::warn!(event="ssh.error", error_kind="ssh", reason=crate::observability::error_reason(&error.to_string()), error_hash=%crate::observability::fingerprint(&error.to_string()), "SSH operation failed"); Self::bad("SSH operation failed") } } impl From for WebError { - fn from(_: russh_sftp::client::error::Error) -> Self { + fn from(error: russh_sftp::client::error::Error) -> Self { + crate::observability::record_error(&nyaterm_core::error::AppError::Sftp(error)); Self::bad("SFTP operation failed") } } impl IntoResponse for WebError { fn into_response(self) -> Response { + tracing::warn!(event="api.error_response", status=self.0.as_u16(), reason=crate::observability::error_reason(&self.1), error_hash=%crate::observability::fingerprint(&self.1), "API operation rejected"); (self.0, Json(json!({"error": self.1}))).into_response() } } diff --git a/src-tauri/crates/nyaterm-web/src/lib.rs b/src-tauri/crates/nyaterm-web/src/lib.rs index 2c4b41c1..57b6cb60 100644 --- a/src-tauri/crates/nyaterm-web/src/lib.rs +++ b/src-tauri/crates/nyaterm-web/src/lib.rs @@ -1,10 +1,12 @@ pub mod ai; pub mod auth; +pub mod backups; pub mod commands; pub mod error; pub mod monitoring; pub mod network; pub mod notes; +pub mod observability; pub mod otp; pub mod plugins; pub mod remote_exec; @@ -39,7 +41,10 @@ async fn events( ) -> error::Result>>> { let login = state.login(&owner.0).await?; let mut receiver = login.events.subscribe(); + tracing::info!(event = "sse.connected", "Web event stream connected"); + let guard = observability::StreamLogGuard::new("sse.disconnected", None); let stream = async_stream::stream! { + let _guard = guard; yield Ok(Event::default().data(r#"{"event":"ready","payload":null}"#)); loop{ tokio::select!{ @@ -58,6 +63,20 @@ pub fn router(state: Arc, dist: std::path::PathBuf) -> Router { let protected = Router::new() .route("/auth/session", get(auth::current)) .route("/auth/logout", post(auth::sign_out)) + .route("/backups/export", post(backups::export)) + .route( + "/backups/import", + post(backups::import).layer(DefaultBodyLimit::max(50 * 1024 * 1024 + 16 * 1024)), + ) + .route( + "/imports/connections", + post(backups::connections).layer(DefaultBodyLimit::max(10 * 1024 * 1024 + 16 * 1024)), + ) + .route( + "/logs/frontend", + post(observability::frontend).layer(DefaultBodyLimit::max(256 * 1024)), + ) + .route("/diagnostics/export", get(observability::diagnostics)) .route("/events", get(events)) .route("/sessions", post(session::create_route)) .route("/sessions/{id}/terminal", get(session::ws_route)) @@ -101,6 +120,7 @@ pub fn router(state: Arc, dist: std::path::PathBuf) -> Router { .layer(SetResponseHeaderLayer::overriding(axum::http::header::CACHE_CONTROL,HeaderValue::from_static("no-store"))) .layer(SetResponseHeaderLayer::overriding(axum::http::header::X_CONTENT_TYPE_OPTIONS,HeaderValue::from_static("nosniff"))) .layer(SetResponseHeaderLayer::overriding(axum::http::header::REFERRER_POLICY,HeaderValue::from_static("same-origin"))) + .layer(middleware::from_fn(observability::request_log)) .with_state(state) } diff --git a/src-tauri/crates/nyaterm-web/src/main.rs b/src-tauri/crates/nyaterm-web/src/main.rs index 34d79df8..87980c2f 100644 --- a/src-tauri/crates/nyaterm-web/src/main.rs +++ b/src-tauri/crates/nyaterm-web/src/main.rs @@ -48,10 +48,7 @@ async fn main() -> Result<(), Box> { .unwrap_or_else(|_| "false".into()) .parse() .unwrap_or(false); - if url.scheme() != "https" - && !loopback - && !allow_insecure_http - { + if url.scheme() != "https" && !loopback && !allow_insecure_http { return Err("Non-loopback public URLs require HTTPS".into()); } let base_path = url.path().trim_end_matches('/').to_owned(); @@ -62,6 +59,7 @@ async fn main() -> Result<(), Box> { let data = PathBuf::from( std::env::var("NYATERM_WEB_DATA_DIR").unwrap_or_else(|_| "./nyaterm-web-data".into()), ); + nyaterm_web::observability::init(&data); std::fs::create_dir_all(&data)?; #[cfg(unix)] { @@ -69,6 +67,7 @@ async fn main() -> Result<(), Box> { std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o700))?; } nyaterm_core::storage::init(&data)?; + nyaterm_web::observability::reload_settings(); // Existing encrypted master-password data is not silently discarded. Web // unwraps it with the external server key, preserving the existing key hierarchy. if let Some(secret) = nyaterm_core::storage::load_settings_doc::< @@ -100,12 +99,12 @@ async fn main() -> Result<(), Box> { mutation: Mutex::new(()), shutdown: tokio_util::sync::CancellationToken::new(), }); - tokio::spawn(nyaterm_web::reap(state.clone())); + nyaterm_web::observability::spawn(nyaterm_web::reap(state.clone())); let bind = std::env::var("NYATERM_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:8080".into()); let listener = tokio::net::TcpListener::bind(&bind).await?; - eprintln!("NyaTerm Web listening on {bind}"); + tracing::info!(event = "server.started", "NyaTerm Web server started"); let shutdown = state.shutdown.clone(); - tokio::spawn(async move { + nyaterm_web::observability::spawn(async move { #[cfg(unix)] { let mut terminate = @@ -138,5 +137,7 @@ async fn main() -> Result<(), Box> { } }) .await; + tracing::info!(event = "server.stopped", "NyaTerm Web server stopped"); + nyaterm_web::observability::shutdown(); Ok(()) } diff --git a/src-tauri/crates/nyaterm-web/src/network.rs b/src-tauri/crates/nyaterm-web/src/network.rs index a4c128fe..ee8e257b 100644 --- a/src-tauri/crates/nyaterm-web/src/network.rs +++ b/src-tauri/crates/nyaterm-web/src/network.rs @@ -58,14 +58,20 @@ fn open_inner( match resolved.kind { TransportRouteKind::Direct => Ok(network::open_direct_transport(&host, port) .await - .map_err(|e| WebError::bad(e.to_string()))?), + .map_err(|error| { + crate::observability::record_error(&error); + WebError::bad(error.to_string()) + })?), TransportRouteKind::Proxy => { let proxy = network::resolve_proxy(&(), resolved.proxy_id.as_deref().unwrap()) - .map_err(|e| match e { - nyaterm_core::error::AppError::Crypto(_) => { - WebError::bad("Proxy credentials could not be decrypted") + .map_err(|e| { + crate::observability::record_error(&e); + match e { + nyaterm_core::error::AppError::Crypto(_) => { + WebError::bad("Proxy credentials could not be decrypted") + } + _ => WebError::bad("Proxy configuration is missing or invalid"), } - _ => WebError::bad("Proxy configuration is missing or invalid"), })?; if !matches!(proxy.protocol.as_str(), "socks5" | "http") { return Err(WebError::bad( @@ -75,7 +81,10 @@ fn open_inner( validate_target(&proxy.host, proxy.port)?; Ok(network::open_proxy_transport(proxy, &host, port) .await - .map_err(|e| WebError::bad(e.to_string()))?) + .map_err(|error| { + crate::observability::record_error(&error); + WebError::bad(error.to_string()) + })?) } TransportRouteKind::SshJump => { let id = resolved.proxy_jump_id.unwrap(); diff --git a/src-tauri/crates/nyaterm-web/src/observability.rs b/src-tauri/crates/nyaterm-web/src/observability.rs new file mode 100644 index 00000000..bd0d5d6a --- /dev/null +++ b/src-tauri/crates/nyaterm-web/src/observability.rs @@ -0,0 +1,776 @@ +//! Bounded, structured deployment logs. Unstructured data is hashed at the sink. +use crate::{ + auth::Owner, + error::{Result, WebError}, + state::State, +}; +use axum::{ + Extension, Json, + extract::{MatchedPath, Request, State as ExtractState}, + http::HeaderValue, + middleware::Next, + response::{IntoResponse, Response}, +}; +use nyaterm_core::{ + config::{self, DiagnosticsSettings}, + error::AppError, +}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + fs::{self, File, OpenOptions}, + io::{Cursor, Read, Write}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering}, + mpsc, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tracing::{ + Event, Instrument, Subscriber, + field::{Field, Visit}, +}; +use tracing_subscriber::{ + Layer, + layer::{Context, SubscriberExt}, + registry::LookupSpan, +}; + +const ROTATE_BYTES: u64 = 10 * 1024 * 1024; +const TOTAL_BYTES: u64 = 100 * 1024 * 1024; +static LOGS: OnceLock> = OnceLock::new(); +enum Job { + Entry(Vec), + Flush(mpsc::Sender<()>), + Shutdown(mpsc::Sender<()>), +} +struct Logs { + sender: mpsc::SyncSender, + dir: PathBuf, + level: AtomicU8, + retention: AtomicU32, + dropped: AtomicU64, + rates: Mutex>, +} + +pub fn fingerprint(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes()))[..16].into() +} +fn level_number(level: &tracing::Level) -> u8 { + match *level { + tracing::Level::ERROR => 3, + tracing::Level::WARN => 2, + tracing::Level::INFO => 1, + _ => 0, + } +} +#[derive(Default)] +struct Fields(Map); +impl Visit for Fields { + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name().into(), json!(value)); + } + fn record_u64(&mut self, field: &Field, value: u64) { + self.0.insert(field.name().into(), json!(value)); + } + fn record_i64(&mut self, field: &Field, value: i64) { + self.0.insert(field.name().into(), json!(value)); + } + fn record_bool(&mut self, field: &Field, value: bool) { + self.0.insert(field.name().into(), json!(value)); + } + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.record_str(field, &format!("{value:?}")); + } +} +struct LogLayer(Arc); +impl LookupSpan<'a>> Layer for LogLayer { + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::Id, + ctx: Context<'_, S>, + ) { + let mut fields = Fields::default(); + attrs.record(&mut fields); + if let Some(span) = ctx.span(id) { + span.extensions_mut().insert(fields); + } + } + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + if level_number(event.metadata().level()) < self.0.level.load(Ordering::Relaxed) { + return; + } + let mut fields = Fields::default(); + if let Some(scope) = ctx.event_scope(event) { + for span in scope.from_root() { + if let Some(values) = span.extensions().get::() { + fields.0.extend(values.0.clone()); + } + } + } + event.record(&mut fields); + let entry = json!({ + "timestamp":time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), + "level":event.metadata().level().as_str().to_lowercase(), + "target":event.metadata().target(), + "fields":sanitize_fields(fields.0), + }); + self.0.enqueue(entry); + } +} + +// An allowlist avoids leaking arbitrary error strings, command arguments or imported content. +fn sanitize_fields(fields: Map) -> Map { + fields + .into_iter() + .map(|(key, value)| { + let safe = match key.as_str() { + "request_id" | "session_id" | "connection_id" | "transfer_id" => value + .as_str() + .filter(|s| uuid::Uuid::parse_str(s).is_ok()) + .map(|s| json!(s)), + "event" | "domain" | "operation" | "error_kind" | "reason" | "method" => value + .as_str() + .filter(|s| { + s.len() <= 96 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "._:- ".contains(c)) + }) + .map(|s| json!(s)), + "error_hash" => value + .as_str() + .filter(|s| s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit())) + .map(|s| json!(s)), + "route" => value + .as_str() + .filter(|s| s.len() <= 160 && s.starts_with('/') && !s.contains('?')) + .map(|s| json!(s)), + "status" | "duration_ms" | "bytes" | "count" | "error_code" | "dropped_count" + | "limit" => { + if value.is_number() { + Some(value.clone()) + } else { + None + } + } + // Frontend data was validated and sanitized independently below. + "frontend" => value + .as_str() + .and_then(|s| serde_json::from_str::(s).ok()) + .map(|v| sanitize_frontend(&v, 0)), + _ => None, + }; + ( + key, + safe.unwrap_or_else(|| json!({"hash":fingerprint(&value.to_string())})), + ) + }) + .collect() +} +impl Logs { + fn enqueue(&self, value: Value) { + if let Ok(mut bytes) = serde_json::to_vec(&value) { + bytes.push(b'\n'); + if self.sender.try_send(Job::Entry(bytes)).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + } +} + +pub fn init(data_dir: &Path) { + let (sender, receiver) = mpsc::sync_channel(2048); + let logs = Arc::new(Logs { + sender, + dir: data_dir.join("logs"), + level: AtomicU8::new(1), + retention: AtomicU32::new(7), + dropped: AtomicU64::new(0), + rates: Mutex::new(HashMap::new()), + }); + if LOGS.set(logs.clone()).is_err() { + return; + } + let worker = logs.clone(); + std::thread::spawn(move || run_writer(worker, receiver, std::io::stdout())); + let _ = tracing::subscriber::set_global_default( + tracing_subscriber::registry().with(LogLayer(logs)), + ); +} +pub fn reload_settings() { + if let Ok(settings) = config::load_app_settings(&()) { + configure(&settings.diagnostics); + } +} +pub fn configure(settings: &DiagnosticsSettings) { + if let Some(logs) = LOGS.get() { + logs.level.store( + match settings.level { + config::DiagnosticsLogLevel::Debug => 0, + config::DiagnosticsLogLevel::Info => 1, + config::DiagnosticsLogLevel::Warn => 2, + }, + Ordering::Relaxed, + ); + logs.retention + .store(settings.retention_days.clamp(1, 30), Ordering::Relaxed); + } +} +pub fn flush() { + if let Some(logs) = LOGS.get() { + let (tx, rx) = mpsc::channel(); + if logs.sender.try_send(Job::Flush(tx)).is_ok() { + let _ = rx.recv_timeout(Duration::from_secs(3)); + } + } +} +pub fn shutdown() { + if let Some(logs) = LOGS.get() { + let (tx, rx) = mpsc::channel(); + if logs.sender.try_send(Job::Shutdown(tx)).is_ok() { + let _ = rx.recv_timeout(Duration::from_secs(3)); + } else { + flush(); + } + } +} +fn log_files(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> { + let mut files = fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name(); + let name = name.to_str()?; + if !name.starts_with("nyaterm-web-") || !name.ends_with(".jsonl") { + return None; + } + // DirEntry metadata can cache size=0 on Windows while the writer is open. + let meta = fs::metadata(entry.path()).ok()?; + if !meta.is_file() || entry.file_type().ok()?.is_symlink() { + return None; + } + Some((entry.path(), meta.len(), meta.modified().ok()?)) + }) + .collect::>(); + files.sort_by_key(|file| file.2); + files +} +fn prune(logs: &Logs, current: Option<&Path>) { + let files = log_files(&logs.dir); + let mut total: u64 = files.iter().map(|f| f.1).sum(); + let retention = Duration::from_secs(logs.retention.load(Ordering::Relaxed) as u64 * 86400); + for (path, size, modified) in files { + if Some(path.as_path()) == current { + continue; + } + if total > TOTAL_BYTES.saturating_sub(if current.is_some() { ROTATE_BYTES } else { 0 }) + || modified.elapsed().unwrap_or_default() > retention + { + if fs::remove_file(path).is_ok() { + total = total.saturating_sub(size); + } + } + } +} +fn run_writer(logs: Arc, receiver: mpsc::Receiver, mut stdout: W) -> W { + let mut file: Option = None; + let mut current: Option = None; + let mut size = 0u64; + let mut day = 0u64; + let mut warned = false; + loop { + match receiver.recv_timeout(Duration::from_secs(30)) { + Ok(Job::Shutdown(reply)) => { + let _ = stdout.flush(); + if let Some(file) = &mut file { + let _ = file.flush(); + } + drop(file); + let _ = reply.send(()); + break; + } + Ok(Job::Flush(reply)) => { + let _ = stdout.flush(); + if let Some(file) = &mut file { + let _ = file.flush(); + } + let _ = reply.send(()); + } + Ok(Job::Entry(mut bytes)) => { + let dropped = logs.dropped.swap(0, Ordering::Relaxed); + if dropped > 0 { + let mut summary = serde_json::to_vec(&json!({"level":"warn","event":"logger.queue_overflow","dropped_count":dropped})).unwrap_or_default(); + summary.push(b'\n'); + summary.extend(bytes); + bytes = summary; + } + let _ = stdout.write_all(&bytes); + let today = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + / 86400; + if file.is_none() || size + bytes.len() as u64 > ROTATE_BYTES || day != today { + file = None; + day = today; + size = 0; + current = Some(logs.dir.join(format!( + "nyaterm-web-{today}-{}.jsonl", + uuid::Uuid::new_v4() + ))); + if fs::create_dir_all(&logs.dir).is_ok() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + fs::set_permissions(&logs.dir, fs::Permissions::from_mode(0o700)); + } + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + file = options.open(current.as_ref().unwrap()).ok(); + } + prune(&logs, current.as_deref()); + } + if file + .as_mut() + .is_none_or(|file| file.write_all(&bytes).is_err()) + { + file = None; + if !warned { + let _ = std::io::stderr().write_all( + b"NyaTerm Web log file unavailable; stdout logging continues\n", + ); + warned = true; + } + } else { + size += bytes.len() as u64; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => prune(&logs, current.as_deref()), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + stdout +} + +pub async fn request_log(mut request: Request, next: Next) -> Response { + let request_id = request + .headers() + .get("x-nyaterm-request-id") + .and_then(|v| v.to_str().ok()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .unwrap_or_else(uuid::Uuid::new_v4) + .to_string(); + let route = request + .extensions() + .get::() + .map(|p| p.as_str()) + .unwrap_or("/unmatched") + .to_owned(); + let method = request.method().as_str().to_owned(); + let operation = if route.ends_with("/commands/{command}") { + request.uri().path().rsplit('/').next().unwrap_or("unknown") + } else { + route.rsplit('/').next().unwrap_or("unknown") + } + .to_owned(); + request + .extensions_mut() + .insert(RequestId(request_id.clone())); + let session_id = request + .uri() + .path() + .split("/api/sessions/") + .nth(1) + .and_then(|path| path.split('/').next()) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .map(|id| id.to_string()); + let started = Instant::now(); + let span = tracing::info_span!("web.request", request_id=%request_id, route=%route, method=%method, operation=%operation, session_id=session_id.as_deref()); + async move { + let mut response = next.run(request).await; + let status = response.status().as_u16(); + tracing::info!( + event = "http.response", + status, + duration_ms = started.elapsed().as_millis() as u64, + "API request completed" + ); + response.headers_mut().insert( + "x-nyaterm-request-id", + HeaderValue::from_str(&request_id).unwrap(), + ); + if status >= 400 + && response + .headers() + .get("content-type") + .is_some_and(|v| v.as_bytes().starts_with(b"application/json")) + { + let (parts, body) = response.into_parts(); + if let Ok(bytes) = axum::body::to_bytes(body, 64 * 1024).await { + if let Ok(mut value) = serde_json::from_slice::(&bytes) { + if let Some(object) = value.as_object_mut() { + object.insert("request_id".into(), json!(request_id)); + } + return (parts, Json(value)).into_response(); + } + return Response::from_parts(parts, axum::body::Body::from(bytes)); + } + return Response::from_parts(parts, axum::body::Body::empty()); + } + response + } + .instrument(span) + .await +} +#[derive(Clone)] +pub struct RequestId(pub String); + +pub fn spawn(future: F) -> tokio::task::JoinHandle +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future.in_current_span()) +} + +pub struct StreamLogGuard { + event: &'static str, + session_id: Option, + span: tracing::Span, +} +impl StreamLogGuard { + pub fn new(event: &'static str, session_id: Option) -> Self { + Self { + event, + session_id, + span: tracing::Span::current(), + } + } +} +impl Drop for StreamLogGuard { + fn drop(&mut self) { + let _entered = self.span.enter(); + tracing::info!( + event = self.event, + session_id = self.session_id.as_deref(), + "Web stream disconnected" + ); + } +} + +pub fn blocking(work: F) -> tokio::task::JoinHandle +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + let span = tracing::Span::current(); + tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + work() + }) +} + +pub fn error_reason(text: &str) -> &'static str { + let text = text.to_ascii_lowercase(); + if text.contains("timed out") || text.contains("timeout") { + "timeout" + } else if text.contains("refused") { + "connection_refused" + } else if text.contains("permission") || text.contains("denied") { + "permission_denied" + } else if text.contains("authentication") || text.contains("auth failed") { + "authentication_failed" + } else if text.contains("resolve") || text.contains("dns") { + "name_resolution" + } else if text.contains("not found") || text.contains("no such") { + "not_found" + } else if text.contains("disconnect") || text.contains("closed") || text.contains("eof") { + "connection_closed" + } else if text.contains("cancel") { + "cancelled" + } else if text.contains("key") || text.contains("certificate") { + "key_validation" + } else if text.contains("decrypt") || text.contains("integrity") { + "decryption_or_integrity" + } else if text.contains("invalid") || text.contains("unsupported") { + "validation" + } else { + "operation_failed" + } +} + +pub fn record_error(error: &AppError) { + let (kind, code, reason) = match error { + AppError::Io(error) => ("io", error.raw_os_error(), format!("{:?}", error.kind())), + AppError::Ssh(error) => ("ssh", None, error_reason(&error.to_string()).into()), + AppError::SshKey(_) => ("ssh_key", None, "key_validation".into()), + AppError::Sftp(error) => ( + "sftp", + match error { + russh_sftp::client::error::Error::Status(status) => Some(status.status_code as i32), + _ => None, + }, + error_reason(&error.to_string()).into(), + ), + AppError::Json(_) => ("json", None, "invalid_parameters".into()), + AppError::Crypto(_) => ("crypto", None, "decryption_or_integrity".into()), + AppError::Config(_) => ("config", None, "validation".into()), + AppError::Storage(_) => ("storage", None, "database_operation".into()), + AppError::Channel(error) => ("channel", None, error_reason(error).into()), + _ => ("backend", None, error_reason(&error.to_string()).into()), + }; + tracing::warn!(event="backend.error", error_kind=kind, error_code=code, reason, error_hash=%fingerprint(&error.to_string()), "Backend operation failed"); +} + +fn sanitize_frontend(value: &Value, depth: u8) -> Value { + if depth > 6 { + return json!("[TRUNCATED]"); + } + match value { + Value::Object(map) => Value::Object( + map.iter() + .take(40) + .map(|(key, value)| { + let lower = key.to_lowercase(); + let safe = if [ + "password", + "secret", + "token", + "otp", + "command", + "content", + "clipboard", + "key", + "cookie", + "csrf", + "authorization", + "passphrase", + ] + .iter() + .any(|s| lower.contains(s)) + && !lower.ends_with("_id") + { + json!("[REDACTED]") + } else if matches!(key.as_str(), "level" | "domain" | "event" | "name") + && value.as_str().is_some_and(|s| { + s.len() <= 96 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + }) + { + value.clone() + } else if key.ends_with("_id") + && value + .as_str() + .is_some_and(|s| uuid::Uuid::parse_str(s).is_ok()) + { + value.clone() + } else { + sanitize_frontend(value, depth + 1) + }; + // Unknown property names are untrusted too. + let key = if key.len() <= 64 + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + { + key.clone() + } else { + fingerprint(key) + }; + (key, safe) + }) + .collect(), + ), + Value::Array(items) => Value::Array( + items + .iter() + .take(50) + .map(|v| sanitize_frontend(v, depth + 1)) + .collect(), + ), + Value::String(text) => json!({"hash":fingerprint(text)}), + _ => value.clone(), + } +} +#[derive(serde::Deserialize)] +pub struct FrontendBatch { + entries: Vec, +} +pub async fn frontend( + ExtractState(state): ExtractState>, + Extension(owner): Extension, + Json(batch): Json, +) -> Result> { + state.login(&owner.0).await?; + if batch.entries.len() > 50 { + return Err(WebError::bad("Log batch exceeds 50 entries")); + } + if let Some(logs) = LOGS.get() { + let mut rates = logs.rates.lock().unwrap_or_else(|e| e.into_inner()); + rates.retain(|_, (start, _)| start.elapsed() < Duration::from_secs(60)); + if rates.len() >= 1024 { + return Err(WebError( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "Log rate limit exceeded".into(), + )); + } + let (_, count) = rates + .entry(fingerprint(&owner.0)) + .or_insert((Instant::now(), 0)); + if *count >= 120 { + return Err(WebError( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "Log rate limit exceeded".into(), + )); + } + *count += 1; + drop(rates); + for entry in batch.entries { + let level = entry["level"] + .as_str() + .ok_or(WebError::bad("Invalid log level"))?; + let fields = &entry; + match level { + "error" => tracing::error!(event="frontend.log", frontend=%fields), + "warn" => tracing::warn!(event="frontend.log", frontend=%fields), + "info" => tracing::info!(event="frontend.log", frontend=%fields), + "debug" => tracing::debug!(event="frontend.log", frontend=%fields), + _ => return Err(WebError::bad("Invalid log level")), + } + } + } + Ok(Json(json!({"status":"accepted"}))) +} + +pub async fn diagnostics() -> Result { + let dir = LOGS.get().map(|logs| logs.dir.clone()); + let bytes = tokio::task::spawn_blocking(move || -> std::io::Result> { + flush(); + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + zip.start_file("manifest.json",options)?; + zip.write_all(serde_json::to_string_pretty(&json!({"runtime":"web","server_version":env!("CARGO_PKG_VERSION"),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"log_budget_bytes":20*1024*1024,"capabilities":["ssh","telnet","vnc","sftp","browserFiles","backups"]}))?.as_bytes())?; + if let Some(dir) = dir { + let mut budget = 20*1024*1024; + for (path,size,_) in log_files(&dir).into_iter().rev() { + if size > budget { continue; } budget-=size; + zip.start_file(format!("logs/{}",path.file_name().unwrap().to_string_lossy()),options)?; + let mut bytes = Vec::new(); File::open(path)?.take(size).read_to_end(&mut bytes)?; + zip.write_all(&bytes)?; + } + } + Ok(zip.finish()?.into_inner()) + }).await.map_err(|_|WebError::bad("Diagnostics export failed"))?.map_err(|_|WebError::bad("Diagnostics export failed"))?; + Ok(( + [ + (axum::http::header::CONTENT_TYPE, "application/zip"), + ( + axum::http::header::CONTENT_DISPOSITION, + "attachment; filename=\"nyaterm-diagnostics.zip\"", + ), + ], + bytes, + ) + .into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn test_logs(dir: PathBuf) -> (Arc, mpsc::Receiver) { + let (sender, receiver) = mpsc::sync_channel(2048); + ( + Arc::new(Logs { + sender, + dir, + level: AtomicU8::new(1), + retention: AtomicU32::new(7), + dropped: AtomicU64::new(0), + rates: Mutex::new(HashMap::new()), + }), + receiver, + ) + } + #[test] + fn writer_rotates_and_exports_live_file_sizes() { + let temp = tempfile::tempdir().unwrap(); + let (logs, receiver) = test_logs(temp.path().to_owned()); + let worker = logs.clone(); + let thread = std::thread::spawn(move || run_writer(worker, receiver, std::io::sink())); + for _ in 0..110 { + logs.sender + .send(Job::Entry(vec![b'x'; 100 * 1024])) + .unwrap(); + } + let (tx, rx) = mpsc::channel(); + logs.sender.send(Job::Flush(tx)).unwrap(); + rx.recv().unwrap(); + let files = log_files(temp.path()); + assert_eq!(files.len(), 2); + assert!( + files + .iter() + .all(|(_, size, _)| *size > 0 && *size <= ROTATE_BYTES) + ); + assert_eq!( + files.iter().map(|(_, size, _)| size).sum::(), + 110 * 100 * 1024 + ); + let (tx, rx) = mpsc::channel(); + logs.sender.send(Job::Shutdown(tx)).unwrap(); + rx.recv().unwrap(); + thread.join().unwrap(); + } + #[test] + fn pruning_respects_retention_and_total_budget() { + let temp = tempfile::tempdir().unwrap(); + let old = temp.path().join("nyaterm-web-old.jsonl"); + File::create(&old) + .unwrap() + .set_modified(SystemTime::now() - Duration::from_secs(8 * 86400)) + .unwrap(); + let (logs, _) = test_logs(temp.path().to_owned()); + prune(&logs, None); + assert!(!old.exists()); + let large = temp.path().join("nyaterm-web-large.jsonl"); + File::create(&large) + .unwrap() + .set_len(TOTAL_BYTES + 1) + .unwrap(); + let unrelated = temp.path().join("database.redb"); + std::fs::write(&unrelated, b"preserve").unwrap(); + prune(&logs, None); + assert!(!large.exists()); + assert!(unrelated.exists()); + } + #[test] + fn unwritable_file_sink_preserves_stdout() { + let temp = tempfile::tempdir().unwrap(); + let blocked = temp.path().join("file"); + std::fs::write(&blocked, b"blocked").unwrap(); + let (logs, receiver) = test_logs(blocked); + logs.sender.send(Job::Entry(b"fixture\n".to_vec())).unwrap(); + let (tx, _) = mpsc::channel(); + logs.sender.send(Job::Shutdown(tx)).unwrap(); + assert_eq!(run_writer(logs, receiver, Vec::new()), b"fixture\n"); + } + #[test] + fn frontend_secrets_and_arbitrary_messages_never_persist() { + let value = sanitize_frontend(&json!({"domain":"ui.error","event":"command.error","message":"token abc123","data":{"private_key":"PEM", "password":42,"command":"cat /secret","host":"private.internal"},"error":{"message":"password abc123"}}),0).to_string(); + assert!(!value.contains("abc123")); + assert!(!value.contains("PEM")); + assert!(!value.contains("private.internal")); + assert!(!value.contains("/secret")); + assert!(value.contains("command.error")); + } +} diff --git a/src-tauri/crates/nyaterm-web/src/remote_exec.rs b/src-tauri/crates/nyaterm-web/src/remote_exec.rs index fe1ec8db..a24bddc7 100644 --- a/src-tauri/crates/nyaterm-web/src/remote_exec.rs +++ b/src-tauri/crates/nyaterm-web/src/remote_exec.rs @@ -10,7 +10,7 @@ struct ChannelGuard(Option>); impl Drop for ChannelGuard { fn drop(&mut self) { if let Some(channel) = self.0.take() { - tokio::spawn(async move { + crate::observability::spawn(async move { let _ = tokio::time::timeout(Duration::from_secs(2), channel.close()).await; }); } diff --git a/src-tauri/crates/nyaterm-web/src/session.rs b/src-tauri/crates/nyaterm-web/src/session.rs index e4ea3bcc..c005bf67 100644 --- a/src-tauri/crates/nyaterm-web/src/session.rs +++ b/src-tauri/crates/nyaterm-web/src/session.rs @@ -31,6 +31,7 @@ use std::{ }; use tokio::sync::{Mutex, OwnedMutexGuard, mpsc}; use tokio_util::sync::CancellationToken; +use tracing::Instrument; pub struct Handler { state: Arc, @@ -447,13 +448,14 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result Err(WebError::bad("Session creation cancelled")), result = tokio::time::timeout(Duration::from_secs(120), connect(&state,&session,target)) => result.unwrap_or(Err(WebError::bad("SSH connection timed out"))), }; match result { Ok(channel) => { + tracing::info!(event="session.connected", session_id=%session.id, "SSH session connected"); session.ready.store(true, Ordering::Release); state .event(&session.owner, "sessions-changed", Value::Null) @@ -461,6 +463,7 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result { + tracing::warn!(event="session.connect_failed", session_id=%session.id, reason=crate::observability::error_reason(&error.1), "SSH connection failed"); state .event( &session.owner, @@ -476,6 +479,7 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result); impl Drop for Attachment { diff --git a/src-tauri/crates/nyaterm-web/src/sftp.rs b/src-tauri/crates/nyaterm-web/src/sftp.rs index ddfbe958..07af0297 100644 --- a/src-tauri/crates/nyaterm-web/src/sftp.rs +++ b/src-tauri/crates/nyaterm-web/src/sftp.rs @@ -210,7 +210,7 @@ pub async fn command( .await? .shutdown() .await - .map_err(|_| WebError::bad("Remote write failed"))?; + .map_err(|error| WebError::io(&error, "Remote write failed"))?; apply_mode(&sftp, path(args, "path")?, permissions).await?; Value::Null } @@ -326,7 +326,7 @@ pub async fn command( .take(limit + 1) .read_to_end(&mut bytes) .await - .map_err(|_| WebError::bad("Remote read failed"))?; + .map_err(|error| WebError::io(&error, "Remote read failed"))?; if bytes.len() as u64 > limit { return Err(WebError::bad("Remote file exceeds preview limit")); } @@ -351,7 +351,7 @@ pub async fn command( .take(limit + 1) .read_to_end(&mut bytes) .await - .map_err(|_| WebError::bad("Remote read failed"))?; + .map_err(|error| WebError::io(&error, "Remote read failed"))?; if bytes.len() as u64 > limit { return Err(WebError::bad("Remote file is too large")); } @@ -392,7 +392,7 @@ pub async fn command( .take(4 * 1024 * 1024 + 1) .read_to_end(&mut old) .await - .map_err(|_| WebError::bad("Remote read failed"))?; + .map_err(|error| WebError::io(&error, "Remote read failed"))?; hash_changed = files::content_hash(&old) != expected; } if changed || hash_changed { @@ -406,10 +406,10 @@ pub async fn command( let mut file = sftp.create(p).await?; file.write_all(content.as_bytes()) .await - .map_err(|_| WebError::bad("Remote write failed"))?; + .map_err(|error| WebError::io(&error, "Remote write failed"))?; file.shutdown() .await - .map_err(|_| WebError::bad("Remote write failed"))?; + .map_err(|error| WebError::io(&error, "Remote write failed"))?; let attrs = sftp.metadata(p).await?; json!(files::WriteRemoteTextResult::saved( attrs.mtime.unwrap_or(0) as u64, @@ -604,7 +604,7 @@ async fn copy_entry(state: &Arc, owner: &str, command: &str, args: &Value let size = reader .read(&mut buffer) .await - .map_err(|_| WebError::bad("Remote read failed"))?; + .map_err(|error| WebError::io(&error, "Remote read failed"))?; if size == 0 { break; } @@ -615,13 +615,13 @@ async fn copy_entry(state: &Arc, owner: &str, command: &str, args: &Value writer .write_all(&buffer[..size]) .await - .map_err(|_| WebError::bad("Remote write failed"))?; + .map_err(|error| WebError::io(&error, "Remote write failed"))?; transfer.progress("progress").await; } writer .shutdown() .await - .map_err(|_| WebError::bad("Remote write failed"))?; + .map_err(|error| WebError::io(&error, "Remote write failed"))?; if transfer.bytes != transfer.total { return Err(WebError::bad("Source changed during copy")); } @@ -676,6 +676,7 @@ impl Transfer { self.state.event(&self.owner,"transfer-event",json!({"id":self.id,"session_id":self.session_id,"file_name":self.path.rsplit('/').next().unwrap_or("file"),"remote_path":self.path,"local_path":"","direction":self.direction,"kind":"file","bytes_transferred":self.bytes,"total_size":self.total,"size":self.total,"status":status})).await; } async fn finish(&mut self, status: &str) { + tracing::info!(event="file.transfer_finished", transfer_id=%self.id, bytes=self.bytes, "Web file transfer finished"); self.progress(status).await; self.done = true; } @@ -690,7 +691,7 @@ impl Drop for Transfer { let id = self.id.clone(); let sid = self.session_id.clone(); let done = self.done; - tokio::spawn(async move { + crate::observability::spawn(async move { // Session shutdown waits for permits, including asynchronous cleanup. let _permits = permits; if let Some(path) = temporary { @@ -871,12 +872,12 @@ pub async fn upload( } file.write_all(&chunk) .await - .map_err(|_| WebError::bad("SFTP write failed"))?; + .map_err(|error| WebError::io(&error, "SFTP write failed"))?; transfer.progress("progress").await; } file.shutdown() .await - .map_err(|_| WebError::bad("SFTP write failed"))?; + .map_err(|error| WebError::io(&error, "SFTP write failed"))?; if overwrite { sftp.rename_replace(&temporary, &transfer.path) .await diff --git a/src-tauri/crates/nyaterm-web/src/state.rs b/src-tauri/crates/nyaterm-web/src/state.rs index 93ab652d..98cd64fc 100644 --- a/src-tauri/crates/nyaterm-web/src/state.rs +++ b/src-tauri/crates/nyaterm-web/src/state.rs @@ -114,6 +114,10 @@ impl State { )) } pub async fn close_owner(&self, owner: &str) { + tracing::info!( + event = "auth.session_closed", + "Web login closed and sessions cancelled" + ); if let Some(login) = self.logins.lock().await.remove(owner) { login.cancel.cancel(); } @@ -141,7 +145,7 @@ impl Drop for PromptCleanup { fn drop(&mut self) { let state = self.state.clone(); let id = self.id.clone(); - tokio::spawn(async move { + crate::observability::spawn(async move { state.prompts.lock().await.remove(&id); }); } diff --git a/src-tauri/crates/nyaterm-web/src/telnet.rs b/src-tauri/crates/nyaterm-web/src/telnet.rs index 5d6df280..0041e978 100644 --- a/src-tauri/crates/nyaterm-web/src/telnet.rs +++ b/src-tauri/crates/nyaterm-web/src/telnet.rs @@ -108,7 +108,7 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result, owner: &str, args: Value) -> Result, owner: &str, args: Value) -> Result, @@ -62,11 +63,20 @@ pub async fn create(state: Arc, owner: &str, args: Value) -> Result, owner: &str, args: Value) -> Result, owner: &str, args: Value) -> Result, owner: &str, args: Value) -> Result, mut socket: WebSocket) { let SessionProtocol::Vnc(web) = &session.protocol else { diff --git a/src-tauri/crates/nyaterm-web/tests/backups.rs b/src-tauri/crates/nyaterm-web/tests/backups.rs new file mode 100644 index 00000000..e5259789 --- /dev/null +++ b/src-tauri/crates/nyaterm-web/tests/backups.rs @@ -0,0 +1,470 @@ +//! Real server processes exercise independent key hierarchies and persisted restores. +use base64::{Engine, engine::general_purpose::STANDARD}; +use nyaterm_core::{ + config, + core::{backup, backup_crypto, portable_snapshot::*}, + storage, + utils::crypto, +}; +use serde_json::{Value, json}; +use std::{ + io::{Cursor, Read, Write}, + path::Path, + process::{Child, Command, Stdio}, + time::Duration, +}; + +const LOGIN: &str = "integration-login-password-more-than-32"; +const DESKTOP_PASSWORD: &str = "desktop-backup-password"; +const WEB_PASSWORD: &str = "portable-web-backup-password"; +const SECRET: &str = "integration-secret-never-in-logs"; +struct Server { + process: Child, + client: reqwest::Client, + base: String, + cookie: String, + csrf: String, +} +impl Drop for Server { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} +impl Server { + async fn start(data: &Path, dist: &Path, key: u8, subpath: &str) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let base = format!("http://{address}/{subpath}"); + let process = Command::new(env!("CARGO_BIN_EXE_nyaterm-web")) + .env_remove("NYATERM_WEB_PASSWORD_FILE") + .env_remove("NYATERM_WEB_ENCRYPTION_KEY_FILE") + .env("NYATERM_WEB_PASSWORD", LOGIN) + .env("NYATERM_WEB_ENCRYPTION_KEY", STANDARD.encode([key; 32])) + .env("NYATERM_WEB_DATA_DIR", data) + .env("NYATERM_WEB_DIST", dist) + .env("NYATERM_WEB_PUBLIC_URL", &base) + .env("NYATERM_WEB_BIND", address.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut server = Self { + process, + client: reqwest::Client::builder().no_proxy().build().unwrap(), + base, + cookie: String::new(), + csrf: String::new(), + }; + let mut ready = false; + for _ in 0..200 { + if server.client.get(&server.base).send().await.is_ok() { + ready = true; + break; + } + assert!( + server.process.try_wait().unwrap().is_none(), + "Server exited during startup" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(ready); + let response = server + .client + .post(format!("{}api/auth/login", server.base)) + .header("origin", server.origin()) + .header("x-nyaterm-request", "1") + .json(&json!({"password":LOGIN})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + server.cookie = response.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .into(); + server.csrf = response.json::().await.unwrap()["csrf"] + .as_str() + .unwrap() + .into(); + server + } + fn origin(&self) -> String { + url::Url::parse(&self.base) + .unwrap() + .origin() + .ascii_serialization() + } + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + self.client + .request(method, format!("{}api/{path}", self.base)) + .header("origin", self.origin()) + .header("cookie", &self.cookie) + .header("x-nyaterm-csrf", &self.csrf) + .header("x-nyaterm-request", "1") + } + async fn command(&self, command: &str, args: Value) -> Value { + let response = self + .request(reqwest::Method::POST, &format!("commands/{command}")) + .json(&args) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200, "{command}"); + response.json().await.unwrap() + } + async fn import(&self, bytes: &[u8], password: &str) -> reqwest::Response { + self.upload("backups/import", "password", password, bytes) + .await + } + async fn upload( + &self, + route: &str, + field: &str, + value: &str, + bytes: &[u8], + ) -> reqwest::Response { + let mut body=format!("--fixture\r\nContent-Disposition: form-data; name=\"{field}\"\r\n\r\n{value}\r\n--fixture\r\nContent-Disposition: form-data; name=\"file\"; filename=\"fixture.nya\"\r\nContent-Type: application/octet-stream\r\n\r\n").into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\r\n--fixture--\r\n"); + self.request(reqwest::Method::POST, route) + .header("content-type", "multipart/form-data; boundary=fixture") + .body(body) + .send() + .await + .unwrap() + } + async fn export(&self) -> Vec { + let response = self + .request(reqwest::Method::POST, "backups/export") + .json(&json!({"password":WEB_PASSWORD})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + response.bytes().await.unwrap().to_vec() + } + async fn verify_secrets(&self) { + for (command, id, expected) in [ + ("get_saved_password_value", "password", SECRET), + ("get_saved_credential_password", "credential", SECRET), + ("get_ssh_key_private_key", "key", "private-key-fixture"), + ("get_connection_password_value", "connection", SECRET), + ("get_otp_secret_value", "otp", "JBSWY3DPEHPK3PXP"), + ] { + assert_eq!( + self.command(command, json!({"id":id})).await, + json!(expected) + ); + } + } +} + +#[tokio::test] +async fn desktop_web_cross_key_backups_are_atomic_and_diagnostics_are_redacted() { + let temp = tempfile::tempdir().unwrap(); + let dist = temp.path().join("dist"); + std::fs::create_dir(&dist).unwrap(); + std::fs::write(dist.join("index.html"), "NyaTerm").unwrap(); + storage::init(&temp.path().join("desktop")).unwrap(); + crypto::set_master_password(Some(DESKTOP_PASSWORD.into())); + config::save_passwords(&(),&serde_json::from_value(json!({"passwords":[{"id":"password","name":"fixture","password":crypto::encrypt(SECRET).unwrap()}]})).unwrap()).unwrap(); + config::save_credentials(&(),&serde_json::from_value(json!({"credentials":[{"id":"credential","name":"fixture","username":"test","password":crypto::encrypt(SECRET).unwrap()}]})).unwrap()).unwrap(); + config::save_keys(&(),&serde_json::from_value(json!({"keys":[{"id":"key","name":"fixture","key":crypto::encrypt("private-key-fixture").unwrap()}]})).unwrap()).unwrap(); + config::save_otp_entries(&(),&serde_json::from_value(json!({"entries":[{"id":"otp","otp_type":"totp","issuer":"fixture","username":"test","secret":crypto::encrypt("JBSWY3DPEHPK3PXP").unwrap()}]})).unwrap()).unwrap(); + config::save_sessions(&(),&serde_json::from_value(json!({"connections":[{"id":"connection","name":"fixture","type":"ssh","host":"private.example","auth":{"mode":"password","password":crypto::encrypt(SECRET).unwrap()}},{"id":"desktop-only","name":"serial preserved","type":"serial","port_name":"COM5"}]})).unwrap()).unwrap(); + let original_key = storage::load_master_key_token().unwrap(); + // This is exactly Desktop's existing snapshot + password encryption path. + let desktop_snapshot = backup::build_backup("1.2.12").unwrap(); + let desktop_bytes = backup_crypto::encrypt_snapshot_bytes( + &encode_portable_snapshot(&desktop_snapshot).unwrap(), + DESKTOP_PASSWORD, + ) + .unwrap(); + let source_data = temp.path().join("source"); + let target_data = temp.path().join("target"); + let source = Server::start(&source_data, &dist, 41, "").await; + assert_eq!( + source + .import(&desktop_bytes, DESKTOP_PASSWORD) + .await + .status(), + 200 + ); + source.verify_secrets().await; + let exported = source.export().await; + assert!( + !exported + .windows(SECRET.len()) + .any(|bytes| bytes == SECRET.as_bytes()) + ); + // A Web backup decodes through Desktop's wire codec and its credentials decrypt with the wrapped backup key. + let decoded = decode_portable_snapshot( + &backup_crypto::decrypt_snapshot_bytes(&exported, WEB_PASSWORD).unwrap(), + ) + .unwrap(); + storage::save_master_key_token(decoded.master_key_token.as_deref().unwrap()).unwrap(); + crypto::set_master_password(Some(WEB_PASSWORD.into())); + assert_eq!( + crypto::decrypt(decoded.passwords.passwords[0].password.as_deref().unwrap()).unwrap(), + SECRET + ); + assert_eq!( + crypto::decrypt(decoded.keys.keys[0].key.as_deref().unwrap()).unwrap(), + "private-key-fixture" + ); + storage::save_master_key_token(original_key.as_deref().unwrap()).unwrap(); + crypto::set_master_password(Some(DESKTOP_PASSWORD.into())); + let target = Server::start(&target_data, &dist, 42, "terminal/").await; + assert_eq!(target.import(&exported, WEB_PASSWORD).await.status(), 200); + target.verify_secrets().await; + let saved = target.command("get_saved_connections", json!({})).await; + assert!( + saved + .as_array() + .unwrap() + .iter() + .any(|c| c["id"] == "desktop-only") + ); + let response = target.import(&exported, "wrong-password").await; + assert_eq!(response.status(), 400); + let id = response.headers()["x-nyaterm-request-id"] + .to_str() + .unwrap() + .to_owned(); + assert_eq!(response.json::().await.unwrap()["request_id"], id); + target.verify_secrets().await; + let mut corrupt = exported.clone(); + corrupt[20] ^= 1; + assert_eq!(target.import(&corrupt, WEB_PASSWORD).await.status(), 400); + target.verify_secrets().await; + // Valid encrypted snapshots with invalid note relationships must roll back all preceding writes. + let mut invalid = desktop_snapshot.clone(); + invalid.sessions.connections.clear(); + invalid.notes=serde_json::from_value(json!({"folders":[{"id":"cycle","name":"bad","parent_id":"cycle","sort_order":0,"created_at_ms":0,"updated_at_ms":0}],"notes":[]})).unwrap(); + invalid.payload_hash = calculate_payload_hash(&invalid).unwrap(); + let invalid = backup_crypto::encrypt_snapshot_bytes( + &encode_portable_snapshot(&invalid).unwrap(), + DESKTOP_PASSWORD, + ) + .unwrap(); + assert_eq!( + target.import(&invalid, DESKTOP_PASSWORD).await.status(), + 400 + ); + target.verify_secrets().await; + assert_eq!( + target + .client + .get(format!("{}api/diagnostics/export", target.base)) + .send() + .await + .unwrap() + .status(), + 401 + ); + assert_eq!( + target + .client + .post(format!("{}api/backups/export", target.base)) + .header("origin", target.origin()) + .header("cookie", &target.cookie) + .json(&json!({"password":WEB_PASSWORD})) + .send() + .await + .unwrap() + .status(), + 403 + ); + let frontend=target.request(reqwest::Method::POST,"logs/frontend").json(&json!({"entries":[{"level":"error","domain":"ui.error","event":"fixture.failure","message":SECRET,"data":{"password":SECRET,"host":"private.example","content":SECRET}}]})).send().await.unwrap(); + assert_eq!(frontend.status(), 200); + let zip = target + .request(reqwest::Method::GET, "diagnostics/export") + .send() + .await + .unwrap(); + assert_eq!(zip.status(), 200); + let mut archive = zip::ZipArchive::new(Cursor::new(zip.bytes().await.unwrap())).unwrap(); + let mut logs = String::new(); + for i in 0..archive.len() { + let mut file = archive.by_index(i).unwrap(); + let mut text = String::new(); + file.read_to_string(&mut text).unwrap(); + logs.push_str(&text); + assert!(!file.name().ends_with("redb")); + } + for secret in [ + SECRET, + LOGIN, + DESKTOP_PASSWORD, + WEB_PASSWORD, + "private.example", + "private-key-fixture", + ] { + assert!(!logs.contains(secret), "Diagnostics leaked a secret"); + } + assert!(logs.contains("backup.import"), "{logs}"); + assert!(logs.contains("fixture.failure"), "{logs}"); + assert!(logs.contains(&id)); + let oversized_batch = target + .request(reqwest::Method::POST, "logs/frontend") + .json(&json!({"entries":vec![json!({"level":"info"});51]})) + .send() + .await + .unwrap(); + assert_eq!(oversized_batch.status(), 400); + let oversized_body = target + .request(reqwest::Method::POST, "logs/frontend") + .json(&json!({"entries":[{"level":"info","message":"x".repeat(256*1024)}]})) + .send() + .await + .unwrap(); + assert_eq!(oversized_body.status(), 413); + for _ in 0..119 { + assert_eq!( + target + .request(reqwest::Method::POST, "logs/frontend") + .json(&json!({"entries":[]})) + .send() + .await + .unwrap() + .status(), + 200 + ); + } + assert_eq!( + target + .request(reqwest::Method::POST, "logs/frontend") + .json(&json!({"entries":[]})) + .send() + .await + .unwrap() + .status(), + 429 + ); + let mut settings = target.command("get_app_settings", json!({})).await; + settings["diagnostics"]["level"] = json!("warn"); + settings["diagnostics"]["retention_days"] = json!(1); + target + .command("save_app_settings", json!({"settings":settings})) + .await; + let suppressed_id = uuid::Uuid::new_v4().to_string(); + assert_eq!( + target + .request(reqwest::Method::POST, "commands/get_saved_connections") + .header("x-nyaterm-request-id", &suppressed_id) + .json(&json!({})) + .send() + .await + .unwrap() + .status(), + 200 + ); + let response = target + .request(reqwest::Method::GET, "diagnostics/export") + .send() + .await + .unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(response.bytes().await.unwrap())).unwrap(); + for index in 0..archive.len() { + let mut file = archive.by_index(index).unwrap(); + let mut content = String::new(); + file.read_to_string(&mut content).unwrap(); + assert!( + !content.contains(&suppressed_id), + "Level changes must apply without a restart" + ); + } + settings["diagnostics"]["level"] = json!("info"); + target + .command("save_app_settings", json!({"settings":settings})) + .await; + drop(target); + let restarted = Server::start(&target_data, &dist, 42, "terminal/").await; + restarted.verify_secrets().await; + let import_json=br#"{"version":1,"sessions":[{"name":"imported","type":"ssh","host":"host.example","username":"root","auth":{"mode":"password","password":"fixture"}}]}"#; + let response = restarted + .upload("imports/connections", "source", "nyaterm_json", import_json) + .await; + assert_eq!(response.status(), 200); + assert_eq!(response.json::().await.unwrap()["imported"], 1); + // Exercise every browser-supported source using uploaded bytes, with no server paths. + let mut xts = zip::ZipWriter::new(Cursor::new(Vec::new())); + xts.start_file("fixture.xsh", zip::write::SimpleFileOptions::default()) + .unwrap(); + xts.write_all(b"[CONNECTION]\nProtocol=SSH\nHost=xshell.example\nPort=22\n[CONNECTION:AUTHENTICATION]\nUserName=root\n").unwrap(); + let xts = xts.finish().unwrap().into_inner(); + for (source, bytes) in [ + ("xshell", xts.as_slice()), + ("mobaxterm", b"[Bookmarks]\nMoba=#109#0%mobaxterm.example%22%root%\n".as_slice()), + ("securecrt", br#"securecrt.exampleSSH2root"#.as_slice()), + ("electerm", br#"{"bookmarks":[{"id":"fixture","title":"Electerm","host":"electerm.example","username":"root","type":"ssh"}]}"#.as_slice()), + ("windterm", br#"[{"session.label":"WindTerm","session.protocol":"SSH","session.target":"windterm.example","session.autoLogin":"{\"Password\":\"windterm-secret\",\"PasswordEnabled\":true,\"session.user\":\"root\"}"}]"#.as_slice()), + ] { + let response=restarted.upload("imports/connections","source",source,bytes).await; + assert_eq!(response.status(),200,"{source}"); + assert_eq!(response.json::().await.unwrap()["imported"],1,"{source}"); + } + let saved = restarted.command("get_saved_connections", json!({})).await; + for (name, password) in [("WindTerm", "windterm-secret"), ("imported", "fixture")] { + let id = saved + .as_array() + .unwrap() + .iter() + .find(|entry| entry["name"] == name) + .unwrap()["id"] + .as_str() + .unwrap(); + assert_eq!( + restarted + .command("get_connection_password_value", json!({"id":id})) + .await, + json!(password) + ); + } + let over_limit = vec![b'x'; 10 * 1024 * 1024 + 1]; + assert_eq!( + restarted + .upload("imports/connections", "source", "nyaterm_json", &over_limit) + .await + .status(), + 413 + ); + assert_eq!( + restarted + .import(&vec![b'x'; 50 * 1024 * 1024 + 1], WEB_PASSWORD) + .await + .status(), + 413 + ); + restarted.verify_secrets().await; + // Unknown source, encrypted profiles and Unix as well as Windows key paths are refused. + assert_eq!( + restarted + .upload("imports/connections", "source", "finalshell", b"{}") + .await + .status(), + 400 + ); + let external_key=br#"[{"session.protocol":"SSH","session.target":"test","ssh.identityFilePath.unix":"/server/private/key"}]"#; + assert_eq!( + restarted + .upload("imports/connections", "source", "windterm", external_key) + .await + .status(), + 400 + ); + let windterm = + br#"[{"session.protocol":"SSH","session.target":"test","session.autoLogin":"encrypted"}]"#; + assert_eq!( + restarted + .upload("imports/connections", "source", "windterm", windterm) + .await + .status(), + 400 + ); +} diff --git a/src-tauri/src/cmd/settings.rs b/src-tauri/src/cmd/settings.rs index 101b65d4..1ef1007a 100644 --- a/src-tauri/src/cmd/settings.rs +++ b/src-tauri/src/cmd/settings.rs @@ -4,26 +4,12 @@ use crate::error::{AppError, AppResult}; use crate::observability::{self, StructuredLog, StructuredLogLevel}; use crate::utils::crypto; use crate::utils::fonts::{FontInfo, list_system_font_families, list_system_font_infos}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::sync::Arc; use tauri::Emitter; -#[derive(Debug, Clone, Serialize)] -pub struct KeywordHighlightImportResult { - pub imported_rules: usize, - pub updated_rules: usize, - pub total_rules: usize, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum KeywordHighlightImportFile { - Config { - keyword_highlights: Vec, - }, - Rules(Vec), -} +pub use nyaterm_core::core::keyword_highlights::{ + KeywordHighlightImportResult, merge_keyword_highlight_rules, parse_keyword_highlight_import, +}; fn schedule_cloud_sync_notify(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { @@ -374,71 +360,6 @@ fn log_master_password_persist_action(action: &'static str, had_existing_passwor }); } -fn parse_keyword_highlight_import(raw: &str) -> AppResult> { - let import_file: KeywordHighlightImportFile = serde_json::from_str(raw) - .map_err(|error| AppError::Config(format!("Invalid highlight rules JSON: {error}")))?; - - Ok(match import_file { - KeywordHighlightImportFile::Config { keyword_highlights } => keyword_highlights, - KeywordHighlightImportFile::Rules(rules) => rules, - }) -} - -fn merge_keyword_highlight_rules( - existing: &mut Vec, - imported: Vec, - mut next_id: impl FnMut() -> String, -) -> AppResult { - let mut imported_rules = 0; - let mut updated_rules = 0; - let mut indexes = existing - .iter() - .enumerate() - .filter_map(|(index, rule)| (!rule.id.trim().is_empty()).then(|| (rule.id.clone(), index))) - .collect::>(); - - for mut rule in imported { - rule.name = rule.name.trim().to_string(); - rule.patterns = rule - .patterns - .into_iter() - .map(|pattern| pattern.trim().to_string()) - .filter(|pattern| !pattern.is_empty()) - .collect(); - - if rule.name.is_empty() || rule.patterns.is_empty() { - continue; - } - - rule.id = rule.id.trim().to_string(); - if rule.id.is_empty() { - rule.id = next_id(); - } - - if let Some(index) = indexes.get(&rule.id).copied() { - existing[index] = rule; - updated_rules += 1; - } else { - let id = rule.id.clone(); - existing.push(rule); - indexes.insert(id, existing.len() - 1); - imported_rules += 1; - } - } - - if imported_rules == 0 && updated_rules == 0 { - return Err(AppError::Config( - "No valid highlight rules found in import file".to_string(), - )); - } - - Ok(KeywordHighlightImportResult { - imported_rules, - updated_rules, - total_rules: existing.len(), - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/core/cloud_sync/crypto.rs b/src-tauri/src/core/cloud_sync/crypto.rs index f4c71f0d..79760b24 100644 --- a/src-tauri/src/core/cloud_sync/crypto.rs +++ b/src-tauri/src/core/cloud_sync/crypto.rs @@ -1,110 +1,19 @@ use crate::error::{AppError, AppResult}; use crate::utils::crypto::get_master_password; -use aes_gcm::aead::{Aead, OsRng}; -use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit}; -use sha2::{Digest, Sha256}; - -const CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"nyaterm-cloud-snapshot-v1:"; -const LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"dragonfly-cloud-snapshot-v1:"; - -fn derive_snapshot_key_with_prefix(prefix: &[u8], master_password: &str) -> Key { - let mut hasher = Sha256::new(); - hasher.update(prefix); - hasher.update(master_password.as_bytes()); - let digest = hasher.finalize(); - *Key::::from_slice(&digest) -} - -fn derive_snapshot_key(master_password: &str) -> Key { - derive_snapshot_key_with_prefix(CLOUD_SNAPSHOT_KEY_PREFIX, master_password) -} - -fn derive_legacy_snapshot_key(master_password: &str) -> Key { - derive_snapshot_key_with_prefix(LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX, master_password) -} - pub fn require_master_password() -> AppResult { get_master_password() - .filter(|value| !value.is_empty()) - .ok_or_else(|| AppError::Config("master password is not set".to_string())) + .filter(|v| !v.is_empty()) + .ok_or_else(|| AppError::Config("master password is not set".into())) } - pub fn encrypt_snapshot_bytes(plaintext: &[u8]) -> AppResult> { - let master_password = require_master_password()?; - let key = derive_snapshot_key(&master_password); - let cipher = Aes256Gcm::new(&key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext) - .map_err(|error| AppError::Crypto(format!("cloud snapshot encryption failed: {error}")))?; - - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&ciphertext); - Ok(combined) + nyaterm_core::core::backup_crypto::encrypt_snapshot_bytes( + plaintext, + &require_master_password()?, + ) } - pub fn decrypt_snapshot_bytes(ciphertext: &[u8]) -> AppResult> { - let master_password = require_master_password()?; - if ciphertext.len() < 13 { - return Err(AppError::Crypto( - "cloud snapshot ciphertext is too short".to_string(), - )); - } - - match decrypt_snapshot_bytes_with_key(ciphertext, &derive_snapshot_key(&master_password)) { - Ok(plaintext) => Ok(plaintext), - Err(new_error) => decrypt_snapshot_bytes_with_key( - ciphertext, - &derive_legacy_snapshot_key(&master_password), - ) - .map_err(|legacy_error| { - AppError::Crypto(format!( - "cloud snapshot decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})" - )) - }), - } -} - -fn decrypt_snapshot_bytes_with_key(ciphertext: &[u8], key: &Key) -> AppResult> { - let cipher = Aes256Gcm::new(key); - let (nonce_bytes, payload) = ciphertext.split_at(12); - let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); - cipher - .decrypt(nonce, payload) - .map_err(|error| AppError::Crypto(format!("cloud snapshot decryption failed: {error}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::utils::crypto::set_master_password; - use std::sync::Mutex; - - static MASTER_PASSWORD_TEST_LOCK: Mutex<()> = Mutex::new(()); - - fn encrypt_snapshot_bytes_with_key_for_test(plaintext: &[u8], key: &Key) -> Vec { - let cipher = Aes256Gcm::new(key); - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); - let ciphertext = cipher.encrypt(&nonce, plaintext).expect("encrypt snapshot"); - let mut combined = nonce.to_vec(); - combined.extend_from_slice(&ciphertext); - combined - } - - #[test] - fn legacy_dragonfly_cloud_snapshot_prefix_can_be_decrypted() { - let _guard = MASTER_PASSWORD_TEST_LOCK - .lock() - .expect("lock master password"); - set_master_password(Some("secret".to_string())); - let legacy_key = derive_legacy_snapshot_key("secret"); - let ciphertext = encrypt_snapshot_bytes_with_key_for_test(b"snapshot", &legacy_key); - - assert_eq!( - decrypt_snapshot_bytes(&ciphertext).expect("decrypt legacy snapshot"), - b"snapshot" - ); - - set_master_password(None); - } + nyaterm_core::core::backup_crypto::decrypt_snapshot_bytes( + ciphertext, + &require_master_password()?, + ) } diff --git a/src-tauri/src/core/importer/mod.rs b/src-tauri/src/core/importer/mod.rs index 9979a6b0..fe5556a2 100644 --- a/src-tauri/src/core/importer/mod.rs +++ b/src-tauri/src/core/importer/mod.rs @@ -1,38 +1,24 @@ -//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), WindTerm (.sessions), -//! SecureCRT (.xml), FinalShell conn directories, NyaTerm JSON files, and Electerm bookmarks. - -use crate::config::{ - self, AiExecutionProfile, ConnectionAuth, ConnectionType, Group, SavedConnection, -}; -use crate::error::{AppError, AppResult}; -#[cfg(not(test))] -use crate::utils::crypto; -use serde::Deserialize; -use std::collections::HashMap; -use std::io::Read; -use std::path::Path; +use crate::error::AppResult; use tauri::Emitter; - -#[cfg(not(test))] -fn encrypt_import_secret(plaintext: &str) -> AppResult { - crypto::encrypt(plaintext) +pub fn import_sessions( + app: tauri::AppHandle, + file_path: String, + windterm_master_password: Option, +) -> AppResult { + let count = + nyaterm_core::core::importer::import_sessions(&app, file_path, windterm_master_password)?; + if count > 0 { + let _ = app.emit("connections-changed", ()); + } + Ok(count) } - -#[cfg(test)] -fn encrypt_import_secret(plaintext: &str) -> AppResult { - Ok(format!("test-encrypted:{plaintext}")) +pub fn import_termius_sessions( + app: tauri::AppHandle, + indexed_db_path: Option, +) -> AppResult { + let count = nyaterm_core::core::importer::import_termius_sessions(&app, indexed_db_path)?; + if count > 0 { + let _ = app.emit("connections-changed", ()); + } + Ok(count) } - -include!("types.rs"); -include!("text.rs"); -include!("common.rs"); -include!("xshell.rs"); -include!("mobaxterm.rs"); -include!("windterm.rs"); -include!("securecrt.rs"); -include!("finalshell.rs"); -include!("nyaterm_json.rs"); -include!("electerm.rs"); -include!("termius.rs"); -include!("merge.rs"); -include!("tests.rs"); diff --git a/src-tauri/src/core/portable_snapshot/build.rs b/src-tauri/src/core/portable_snapshot/build.rs index 656f31be..8f3b0896 100644 --- a/src-tauri/src/core/portable_snapshot/build.rs +++ b/src-tauri/src/core/portable_snapshot/build.rs @@ -43,12 +43,3 @@ pub fn build_portable_snapshot( snapshot.payload_hash = calculate_payload_hash(&snapshot)?; Ok(snapshot) } - -pub(crate) fn sync_settings_payload_changed( - previous: &config::AppSettings, - next: &config::AppSettings, -) -> AppResult { - let previous = PortableAppSettings::from_app_settings(previous, &PortableSnapshotKind::Sync); - let next = PortableAppSettings::from_app_settings(next, &PortableSnapshotKind::Sync); - Ok(serde_json::to_vec(&previous)? != serde_json::to_vec(&next)?) -} diff --git a/src-tauri/src/core/portable_snapshot/mod.rs b/src-tauri/src/core/portable_snapshot/mod.rs index 6106682b..b211acc5 100644 --- a/src-tauri/src/core/portable_snapshot/mod.rs +++ b/src-tauri/src/core/portable_snapshot/mod.rs @@ -1,41 +1,8 @@ -use crate::config::{ - self, ActivityBarLayout, AppSettings, DiagnosticsSettings, InteractionSettings, SearchSettings, - TerminalSettings, TransferSettings, TranslationSettings, -}; -use crate::error::{AppError, AppResult}; -use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; -use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::fs; -use std::io::{Cursor, Read, Write}; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::path::{Path, PathBuf}; +use super::{QuickCommandsStore, SessionManager}; +use crate::config; +use crate::error::AppResult; +pub use nyaterm_core::core::portable_snapshot::*; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; -use zip::write::SimpleFileOptions; - -use super::{QuickCommandsStore, SessionManager}; - -const PORTABLE_SNAPSHOT_SCHEMA_VERSION: u32 = 3; -const SNAPSHOT_META_KEY: &str = "meta"; -const SNAPSHOT_JSON_PORTABLE_SETTINGS: &str = "portable-settings"; -const SNAPSHOT_ZIP_MANIFEST_NAME: &str = "manifest.json"; -const SNAPSHOT_ZIP_PAYLOAD_NAME: &str = "snapshot.redb"; -const MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES: u64 = 50 * 1024 * 1024; - -const SNAPSHOT_META_TABLE: TableDefinition<&str, &str> = TableDefinition::new("snapshot_meta"); -const SNAPSHOT_ENTITIES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("entity_docs"); -const SNAPSHOT_V2_JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs"); -const SNAPSHOT_V2_TEXT_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("text_docs"); - -include!("types.rs"); include!("build.rs"); -include!("codec.rs"); include!("apply.rs"); -include!("hash.rs"); -include!("legacy.rs"); -include!("redb_codec.rs"); -include!("temp_redb.rs"); -include!("tests.rs"); diff --git a/src/components/dialog/app/BrowserBackupDialog.tsx b/src/components/dialog/app/BrowserBackupDialog.tsx new file mode 100644 index 00000000..673342cd --- /dev/null +++ b/src/components/dialog/app/BrowserBackupDialog.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export function BrowserBackupDialog({ + mode, + busy, + error, + onClose, + onSubmit, +}: { + mode: "import" | "export"; + busy: boolean; + error: string; + onClose: () => void; + onSubmit: (password: string) => Promise; +}) { + const { t } = useTranslation(); + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [overwrite, setOverwrite] = useState(false); + const importing = mode === "import"; + const valid = + !!password && + new TextEncoder().encode(password).length <= 1024 && + (importing ? overwrite : password === confirmation); + return ( + { + if (!open && !busy) onClose(); + }} + > + + + + {t(importing ? "web.backupImportTitle" : "web.backupExportTitle")} + + + {t(importing ? "web.backupImportDescription" : "web.backupExportDescription")} + + +
{ + event.preventDefault(); + if (valid && !busy) void onSubmit(password); + }} + > +
+ + setPassword(event.target.value)} + /> +
+ {!importing && ( +
+ + setConfirmation(event.target.value)} + /> + {confirmation && confirmation !== password && ( +

{t("web.backupPasswordMismatch")}

+ )} +
+ )} + {importing && ( +
+ setOverwrite(checked === true)} + /> + +
+ )} + {error && ( +

+ {error} +

+ )} + + + + +
+
+
+ ); +} diff --git a/src/components/dialog/connections/ImportDialog.tsx b/src/components/dialog/connections/ImportDialog.tsx index d8dec799..1d1c24cb 100644 --- a/src/components/dialog/connections/ImportDialog.tsx +++ b/src/components/dialog/connections/ImportDialog.tsx @@ -1,5 +1,3 @@ -import { open as openFileDialog } from "@/lib/backend/platform/dialog"; -import { openUrl } from "@/lib/backend/platform/opener"; import { type ComponentType, useState } from "react"; import { useTranslation } from "react-i18next"; import { MdDataObject, MdOpenInNew, MdTerminal } from "react-icons/md"; @@ -27,6 +25,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useApp } from "@/context/AppContext"; import { useConfigTransfer } from "@/hooks/useConfigTransfer"; +import { pickBrowserFile } from "@/lib/backend/browserArtifacts"; +import { importBrowserConnections } from "@/lib/backend/configTransfer"; +import { open as openFileDialog } from "@/lib/backend/platform/dialog"; +import { openUrl } from "@/lib/backend/platform/opener"; +import { runtime } from "@/lib/backend/runtime"; import { invoke } from "@/lib/invoke"; import { logger } from "@/lib/logger"; @@ -124,6 +127,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { const [windtermMasterPassword, setWindtermMasterPassword] = useState(""); const [windtermImporting, setWindtermImporting] = useState(false); const [confirmBackupRestore, setConfirmBackupRestore] = useState(false); + const [browserImporting, setBrowserImporting] = useState(false); const docsUrl = i18n.language.toLowerCase().startsWith("zh") ? SESSION_IMPORT_DOC_URLS.zh : SESSION_IMPORT_DOC_URLS.en; @@ -172,6 +176,36 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { }; const handleSelect = async (source: ImportSource) => { + if (runtime === "web") { + if (browserImporting || source.picker === "directory" || source.id === "ssh_config") return; + setBrowserImporting(true); + try { + const file = await pickBrowserFile( + (source.extensions ?? []).map((ext) => `.${ext}`).join(","), + ); + if (!file) return; + const result = await importBrowserConnections(source.id, file); + finishSessionImport(result.imported); + for (const warning of result.warnings) + if (warning === "credentials_not_in_export") toast.info(t("web.importCredentialsHint")); + onClose(); + } catch (error) { + logger.error({ + domain: "settings.persistence", + event: "connections.import_failed", + message: "Browser connection import failed", + error, + }); + toast.error( + String(error).includes("WindTerm profile resources") + ? t("web.windtermResourcesRequired") + : t("savedConnections.importFailed", { error: String(error) }), + ); + } finally { + setBrowserImporting(false); + } + return; + } onClose(); if (source.id === "ssh_config") { @@ -274,7 +308,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { return ( <> - !v && onClose()}> + !v && !browserImporting && onClose()}> {t("savedConnections.importDialogTitle")} @@ -289,7 +323,12 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) { diff --git a/src/components/dialog/security-auth/KeyEditorDialog.tsx b/src/components/dialog/security-auth/KeyEditorDialog.tsx index aebc1cec..cf6f1de7 100644 --- a/src/components/dialog/security-auth/KeyEditorDialog.tsx +++ b/src/components/dialog/security-auth/KeyEditorDialog.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; +import { runtime } from "@/lib/backend/runtime"; export type KeyMaterialMode = "content" | "file"; @@ -107,24 +108,37 @@ function KeyMaterialInput({
- onModeChange(value as KeyMaterialMode)} - className="shrink-0" - > - - - - {t("settings.keyInputContentMode")} - - - - {t("settings.keyInputFileMode")} - - - + {runtime === "web" ? ( + + ) : ( + onModeChange(value as KeyMaterialMode)} + className="shrink-0" + > + + + + {t("settings.keyInputContentMode")} + + + + {t("settings.keyInputFileMode")} + + + + )}
- {mode === "content" ? ( + {mode === "content" || runtime === "web" ? (